-
Notifications
You must be signed in to change notification settings - Fork 125
feat(payments): Add LangGraph integration for payment handling #546
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ragsu43
wants to merge
6
commits into
aws:main
Choose a base branch
from
ragsu43:ragsu/langgraph-integration
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8950be1
feat(payments): Add LangGraph integration for payment handling
ragsu43 e84026f
refactor(payments): Unify LangGraph and Strands config into single class
ragsu43 d56abba
define _ERROR_MESSAGES dict at the root level of payments SDK instead…
ragsu43 6b4f356
added langgraph dependencies to pyproject.toml for CI
ragsu43 28a49ac
fix: linter check failing due to long lines, wrapped and fixed length…
ragsu43 bef4763
style: apply ruff formatting locally for push and rerun lint check
ragsu43 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,62 +1,49 @@ | ||
| """Configuration for AgentCorePaymentsPlugin.""" | ||
| """Configuration for AgentCore Payments integrations (Strands and LangGraph).""" | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import Callable, List, Optional | ||
| from dataclasses import dataclass, field | ||
| from typing import Any, Callable, Dict, List, Optional | ||
|
|
||
| from .handlers import PaymentResponseHandler | ||
|
|
||
|
|
||
| @dataclass | ||
| class AgentCorePaymentsPluginConfig: | ||
| """Configuration for AgentCorePaymentsPlugin. | ||
| """Configuration for AgentCore Payments integrations. | ||
|
|
||
| This unified config is used by both the Strands plugin and LangGraph middleware. | ||
|
|
||
| Attributes: | ||
| payment_manager_arn: ARN of the payment manager service | ||
| region: AWS region for the payment manager | ||
| payment_manager_arn: ARN of the payment manager service. | ||
| user_id: User ID for payment processing. Required for SigV4 auth. | ||
| Optional for bearer token auth (JWT identifies the user). | ||
| When set with bearer auth, propagated via X-Amzn-Bedrock-AgentCore-Payments-User-Id header. | ||
| payment_instrument_id: Optional payment instrument ID for the user. | ||
| Can be set later via update_payment_instrument_id(). | ||
| payment_session_id: Optional payment session ID for the transaction. | ||
| Can be set later via update_payment_session_id(). | ||
| network_preferences_config: Optional list of network CAIP2 identifiers | ||
| in order of preference. If not provided, defaults to the system default. | ||
| auto_payment: Whether to automatically process 402 payment requirements. | ||
| Defaults to True to maintain existing behavior. | ||
| max_interrupt_retries: Maximum number of interrupt retries per tool use. | ||
| Defaults to 5. Set to 0 to disable interrupt retries entirely (no interrupts will be raised). | ||
| agent_name: Optional agent name to propagate via the | ||
| X-Amzn-Bedrock-AgentCore-Payments-Agent-Name HTTP header on every | ||
| AgentCore payments data-plane API call. When set, the header is automatically injected | ||
| by PaymentManager and propagated for Payments. | ||
| bearer_token: Optional static JWT bearer token for OAuth/CUSTOM_JWT authentication. | ||
| When set, PaymentManager uses Bearer token auth instead of SigV4. | ||
| Mutually exclusive with token_provider. | ||
| token_provider: Optional callable that returns a fresh JWT bearer token string. | ||
| Called before each request to support token refresh. | ||
| Mutually exclusive with bearer_token. | ||
| payment_tool_allowlist: Optional list of tool names that are eligible for | ||
| automatic X402 payment processing. When None (default), all tools are | ||
| eligible (preserving existing behavior). When set, only tool calls whose | ||
| name appears in this list will trigger payment processing; all others are | ||
| skipped. | ||
| provide_http_request: Whether the plugin should register its built-in | ||
| ``http_request`` ``@tool`` on the agent. Defaults to True so adding the | ||
| plugin gives a turnkey paid-HTTP experience. Set to False if you want | ||
| to ship your own ``http_request`` tool — Strands raises a ValueError | ||
| on duplicate tool names, so you must opt out of the plugin's version | ||
| before passing your own. Auto-payment of 402 responses still works | ||
| against any tool whose output carries the ``PAYMENT_REQUIRED:`` | ||
| content marker, so disabling this flag does not disable interception. | ||
| post_payment_retry_delay_seconds: Seconds to wait after generating a | ||
| payment header before allowing the tool to be retried. The x402 | ||
| EIP-3009 ``transferWithAuthorization`` contract requires | ||
| ``block.timestamp > validAfter`` (strict greater-than). Some signing | ||
| services set ``validAfter`` close to the current time, which can | ||
| cause the merchant facilitator to submit before ``validAfter`` | ||
| elapses, producing a misleading "invalid_payload" response. A small | ||
| delay between signing and retry lets the chain advance one block so | ||
| the authorization is valid by the time the seller submits. Defaults | ||
| to 3.0 seconds (about one Base Sepolia block). Set to 0 to disable. | ||
| payment_connector_id: Payment connector ID (optional). | ||
| region: AWS region for the payment manager. | ||
| network_preferences_config: Ordered list of network CAIP2 identifiers. | ||
| auto_payment: Whether to automatically process 402 responses. Default True. | ||
| agent_name: Agent name propagated via HTTP header on data-plane calls. | ||
| bearer_token: Static JWT for OAuth/CUSTOM_JWT auth. Mutually exclusive with token_provider. | ||
| token_provider: Callable returning a fresh JWT. Mutually exclusive with bearer_token. | ||
| payment_tool_allowlist: Tool names eligible for payment processing. None = all tools. | ||
| provide_http_request: Whether the integration registers its built-in http_request tool. | ||
| post_payment_retry_delay_seconds: Delay after signing before retry. Default 3.0s. | ||
| max_interrupt_retries: Maximum number of interrupt retries per tool use (Strands only). | ||
| Defaults to 5. Set to 0 to disable interrupt retries entirely. | ||
| custom_handlers: Custom PaymentResponseHandler instances keyed by tool name. | ||
| Takes precedence over the built-in handler registry during resolution. | ||
| auto_session: Whether to auto-create a payment session on first 402 if | ||
| payment_session_id is not set. Default False. | ||
| auto_session_budget: Budget for auto-created sessions (USD). Default "1.00". | ||
| auto_session_expiry_minutes: Expiry time for auto-created sessions. Default 60. | ||
| on_payment_error: Optional callback invoked when a payment exception occurs. | ||
| Receives PaymentErrorContext, returns ErrorResolution.RETRY or .PROPAGATE. | ||
| When None (default), errors produce deterministic ToolMessages directly. | ||
| max_error_retries: Maximum times the error callback can return RETRY per tool call. | ||
| Default 3. Set to 0 to disable the callback entirely. | ||
| """ | ||
|
|
||
| payment_manager_arn: str | ||
|
|
@@ -65,51 +52,52 @@ class AgentCorePaymentsPluginConfig: | |
| payment_session_id: Optional[str] = None | ||
| payment_connector_id: Optional[str] = None | ||
| region: Optional[str] = None | ||
| network_preferences_config: Optional[list[str]] = None | ||
| network_preferences_config: Optional[List[str]] = None | ||
| auto_payment: bool = True | ||
| max_interrupt_retries: int = 5 | ||
| agent_name: Optional[str] = None | ||
| bearer_token: Optional[str] = None | ||
| token_provider: Optional[Callable[[], str]] = None | ||
| payment_tool_allowlist: Optional[List[str]] = None | ||
| provide_http_request: bool = True | ||
| post_payment_retry_delay_seconds: float = 3.0 | ||
| max_interrupt_retries: int = 5 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Type annotation contradicts validation The field is typed custom_handlers: Optional[Dict[str, "PaymentResponseHandler"]] = field(default=None)This way the type signature matches the runtime contract and callers get proper type-checking. |
||
| custom_handlers: Optional[Dict[str, Any]] = field(default=None) | ||
| auto_session: bool = False | ||
| auto_session_budget: str = "1.00" | ||
| auto_session_expiry_minutes: int = 60 | ||
| on_payment_error: Optional[Callable] = None | ||
| max_error_retries: int = 3 | ||
|
|
||
| def __post_init__(self) -> None: | ||
| """Validate configuration after initialization.""" | ||
| if not self.payment_manager_arn: | ||
| raise ValueError("payment_manager_arn is required") | ||
|
|
||
| if not self.payment_manager_arn.startswith("arn:"): | ||
| raise ValueError(f"Invalid ARN format: {self.payment_manager_arn}") | ||
|
|
||
| if self.bearer_token is not None and self.token_provider is not None: | ||
| raise ValueError("bearer_token and token_provider are mutually exclusive") | ||
| if self.bearer_token is not None and not isinstance(self.bearer_token, str): | ||
| raise ValueError(f"bearer_token must be a string, got {type(self.bearer_token).__name__}") | ||
|
|
||
| if self.token_provider is not None and not callable(self.token_provider): | ||
| raise ValueError(f"token_provider must be callable, got {type(self.token_provider).__name__}") | ||
|
|
||
| if self.user_id is not None and self.user_id and not self.user_id.strip(): | ||
| raise ValueError("user_id cannot be whitespace-only") | ||
|
|
||
| if not self.user_id and self.bearer_token is None and self.token_provider is None: | ||
| raise ValueError("user_id is required for SigV4 auth (when bearer_token/token_provider not set)") | ||
| if self.user_id is not None and self.user_id and not self.user_id.strip(): | ||
| raise ValueError("user_id cannot be whitespace-only") | ||
|
|
||
| if not isinstance(self.auto_payment, bool): | ||
| raise ValueError(f"auto_payment must be a boolean, got {type(self.auto_payment).__name__}") | ||
|
|
||
| if self.bearer_token is not None and self.token_provider is not None: | ||
| raise ValueError("bearer_token and token_provider are mutually exclusive. Provide only one.") | ||
| if not isinstance(self.provide_http_request, bool): | ||
| raise ValueError(f"provide_http_request must be a boolean, got {type(self.provide_http_request).__name__}") | ||
|
|
||
| if self.payment_tool_allowlist is not None: | ||
| if not isinstance(self.payment_tool_allowlist, list): | ||
| raise ValueError("payment_tool_allowlist must be a list of tool name strings") | ||
| if not all(isinstance(t, str) for t in self.payment_tool_allowlist): | ||
| raise ValueError("All entries in payment_tool_allowlist must be strings") | ||
|
|
||
| if not isinstance(self.provide_http_request, bool): | ||
| raise ValueError(f"provide_http_request must be a boolean, got {type(self.provide_http_request).__name__}") | ||
|
|
||
| if not isinstance(self.post_payment_retry_delay_seconds, (int, float)) or isinstance( | ||
| self.post_payment_retry_delay_seconds, bool | ||
| ): | ||
|
|
@@ -122,6 +110,24 @@ def __post_init__(self) -> None: | |
| f"post_payment_retry_delay_seconds must be >= 0, got {self.post_payment_retry_delay_seconds}" | ||
| ) | ||
|
|
||
| if self.custom_handlers is not None: | ||
| if not isinstance(self.custom_handlers, dict): | ||
| raise ValueError( | ||
| "custom_handlers must be a dict mapping tool names to PaymentResponseHandler instances" | ||
| ) | ||
| if not all(isinstance(k, str) for k in self.custom_handlers): | ||
| raise ValueError("All keys in custom_handlers must be strings") | ||
| if not all(isinstance(v, PaymentResponseHandler) for v in self.custom_handlers.values()): | ||
| raise ValueError("All values in custom_handlers must be PaymentResponseHandler instances") | ||
|
|
||
| if self.on_payment_error is not None and not callable(self.on_payment_error): | ||
| raise ValueError(f"on_payment_error must be callable, got {type(self.on_payment_error).__name__}") | ||
|
|
||
| if not isinstance(self.max_error_retries, int) or isinstance(self.max_error_retries, bool): | ||
| raise ValueError(f"max_error_retries must be an int, got {type(self.max_error_retries).__name__}") | ||
| if self.max_error_retries < 0: | ||
| raise ValueError(f"max_error_retries must be >= 0, got {self.max_error_retries}") | ||
|
|
||
| def update_payment_session_id(self, payment_session_id: str) -> None: | ||
| """Update the payment session ID. | ||
|
|
||
|
|
@@ -141,3 +147,40 @@ def update_payment_instrument_id(self, payment_instrument_id: str) -> None: | |
| if not payment_instrument_id: | ||
| raise ValueError("payment_instrument_id cannot be empty") | ||
| self.payment_instrument_id = payment_instrument_id | ||
|
|
||
| def add_to_allowlist(self, *tool_names: str) -> None: | ||
| """Add tool names to the payment allowlist. | ||
|
|
||
| Creates the allowlist if it doesn't exist yet (switching from "all tools" | ||
| to explicit allowlist mode). | ||
|
|
||
| Args: | ||
| tool_names: One or more tool names to add. | ||
| """ | ||
| if self.payment_tool_allowlist is None: | ||
| self.payment_tool_allowlist = [] | ||
| for name in tool_names: | ||
| if not isinstance(name, str): | ||
| raise ValueError(f"Tool name must be a string, got {type(name).__name__}") | ||
| if name not in self.payment_tool_allowlist: | ||
| self.payment_tool_allowlist.append(name) | ||
|
|
||
| def remove_from_allowlist(self, *tool_names: str) -> None: | ||
| """Remove tool names from the payment allowlist. | ||
|
|
||
| If the allowlist becomes empty, sets it to None (all tools eligible). | ||
|
|
||
| Args: | ||
| tool_names: One or more tool names to remove. | ||
| """ | ||
| if self.payment_tool_allowlist is None: | ||
| return | ||
| for name in tool_names: | ||
| if name in self.payment_tool_allowlist: | ||
| self.payment_tool_allowlist.remove(name) | ||
| if not self.payment_tool_allowlist: | ||
| self.payment_tool_allowlist = None | ||
|
|
||
|
|
||
| # Backward-compatible alias for LangGraph imports | ||
| AgentCorePaymentsConfig = AgentCorePaymentsPluginConfig | ||
74 changes: 74 additions & 0 deletions
74
src/bedrock_agentcore/payments/integrations/error_messages.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| """Shared deterministic error messages for payment exceptions. | ||
|
|
||
| These messages are designed to be shown to LLMs via tool results. They instruct | ||
| the model not to retry and to inform the user of the specific issue. | ||
|
|
||
| Used by: LangGraph middleware, and available for any future plugin integration. | ||
| """ | ||
|
|
||
| from typing import Dict, Type | ||
|
|
||
| from bedrock_agentcore.payments.manager import ( | ||
| InsufficientBudget, | ||
| PaymentError, | ||
| PaymentInstrumentConfigurationRequired, | ||
| PaymentInstrumentNotFound, | ||
| PaymentSessionConfigurationRequired, | ||
| PaymentSessionExpired, | ||
| PaymentSessionNotFound, | ||
| ) | ||
|
|
||
| # Maps exception types to deterministic, LLM-instructive messages. | ||
| PAYMENT_ERROR_MESSAGES: Dict[Type[Exception], str] = { | ||
| PaymentInstrumentConfigurationRequired: ( | ||
| "No payment instrument configured. Do not retry this call. " | ||
| "Inform the user they need to configure a payment instrument before making paid requests." | ||
| ), | ||
| PaymentSessionConfigurationRequired: ( | ||
| "No payment session configured. Do not retry this call. " | ||
| "Inform the user they need to create a payment session before making paid requests." | ||
| ), | ||
| PaymentInstrumentNotFound: ( | ||
| "Payment instrument not found. Do not retry this call. " | ||
| "Inform the user their payment instrument ID is invalid or has been deleted." | ||
| ), | ||
| PaymentSessionNotFound: ( | ||
| "Payment session not found. Do not retry this call. " | ||
| "Inform the user their payment session ID is invalid or has expired." | ||
| ), | ||
| PaymentSessionExpired: ( | ||
| "Payment session has expired. Do not retry this call. " | ||
| "Inform the user they need to create a new payment session." | ||
| ), | ||
| InsufficientBudget: ( | ||
| "Insufficient budget. The payment amount exceeds the remaining session limit. " | ||
| "Do not retry this call. Inform the user they need to increase their session budget " | ||
| "or create a new session with higher limits." | ||
| ), | ||
| } | ||
|
|
||
|
|
||
| def get_payment_error_message(exception: Exception) -> str: | ||
| """Get the deterministic error message for a payment exception. | ||
|
|
||
| Looks up the exception type in the message map. Falls back to a generic | ||
| message that includes the exception string for unrecognized types. | ||
|
|
||
| Args: | ||
| exception: The payment exception. | ||
|
|
||
| Returns: | ||
| Human/LLM-readable error message string. | ||
| """ | ||
| msg = PAYMENT_ERROR_MESSAGES.get(type(exception)) | ||
| if msg is not None: | ||
| return msg | ||
| if isinstance(exception, PaymentError): | ||
| return ( | ||
| f"Payment processing failed ({exception}). " | ||
| "Do not retry this call. Inform the user that payment could not be completed." | ||
| ) | ||
| return ( | ||
| f"An unexpected error occurred during payment processing ({exception}). " | ||
| "Do not retry this call. Inform the user that payment could not be completed." | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this a new attribute? A manager can have many connectiors. What use case this attribute is going to solve?