Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
462 changes: 462 additions & 0 deletions src/bedrock_agentcore/payments/integrations/langgraph/README.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/bedrock_agentcore/payments/integrations/langgraph/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""LangGraph integration for AgentCore Payments."""

from .config import AgentCorePaymentsConfig
from .errors import ErrorResolution, PaymentErrorContext
from .middleware import AgentCorePaymentsMiddleware

__all__ = [
"AgentCorePaymentsConfig",
"AgentCorePaymentsMiddleware",
"ErrorResolution",
"PaymentErrorContext",
]
151 changes: 151 additions & 0 deletions src/bedrock_agentcore/payments/integrations/langgraph/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Configuration for AgentCorePaymentsMiddleware (LangGraph integration)."""

from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional

from ..handlers import PaymentResponseHandler


@dataclass
class AgentCorePaymentsConfig:
Comment thread
ragsu43 marked this conversation as resolved.
Outdated
"""Configuration for AgentCorePaymentsMiddleware.

Attributes:
payment_manager_arn: ARN of the payment manager resource.
user_id: User ID for payment processing. Required for SigV4 auth.
payment_instrument_id: Payment instrument ID for x402 signing.
payment_session_id: Payment session ID for budget enforcement.
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 middleware registers its built-in http_request tool.
post_payment_retry_delay_seconds: Delay after signing before retry. Default 3.0s.
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
user_id: Optional[str] = None
payment_instrument_id: Optional[str] = None
payment_session_id: Optional[str] = None
payment_connector_id: Optional[str] = None
region: Optional[str] = None
network_preferences_config: Optional[List[str]] = None
auto_payment: bool = True
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
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 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 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.post_payment_retry_delay_seconds, (int, float)) or isinstance(
self.post_payment_retry_delay_seconds, bool
):
raise ValueError(
f"post_payment_retry_delay_seconds must be a number, got "
f"{type(self.post_payment_retry_delay_seconds).__name__}"
)
if self.post_payment_retry_delay_seconds < 0:
raise ValueError(
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 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
45 changes: 45 additions & 0 deletions src/bedrock_agentcore/payments/integrations/langgraph/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Error callback types for AgentCorePaymentsMiddleware."""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Optional

if TYPE_CHECKING:
from .config import AgentCorePaymentsConfig

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong import path under TYPE_CHECKING

This imports from .config (i.e. langgraph/config.py) but the config class lives at integrations/config.py. Should be:

from ..config import AgentCorePaymentsConfig

Won't crash at runtime (guarded by TYPE_CHECKING), but will break mypy/pyright.


class ErrorResolution(Enum):
"""Return value from on_payment_error callback."""

RETRY = "retry"
PROPAGATE = "propagate"


@dataclass
class PaymentErrorContext:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have error defined in the common package. Let's use the existing error class.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given how the callback function would be implemented by developers without PaymentErrorContext, it seems to me that passing in a context object with the exception info and all necessary state/context fields for error handling gives the simplest dev experience. Based on what I have found, there are some big blockers against deprecating PaymentErrorContext in favor of just passing in the exception:

there are 4 main fields that langgraph devs cannot access from the callback unless it is passed into the callback:

  1. Retry state (number of retries on this tool call):
  • can't track it directly in the config class because dev does not have access to "request" (field from wrap_tool_call), so no way to know what tool-name the retry is for unless we pass that in, so either way we would need to pass something into the callback.
  • As mentioned above, middleware in langgraph doesn't receive graph state at all (which is the only equivalent of agent.state in strands), so even if we could add a custom field to the graph's State dict, the middleware can't read/write to it like "agent.state" in Strands.
  • Only option is developer tracks it in some closure dict, but again we would need to pass in tool_call_id, in which case just having retry_count in the context object is much easier for the dev.
  1. tool_name:
  • as mentioned above can't be accessed by dev if we deprecate PaymentErrorContext unless we pass it in anyway, since the developer has no access to the tool call "request" itself.
  1. tool_args: same problem as tool_name... lives in ToolCallRequest inside wrap_tool_call with no external visibility WITHOUT some arg passing into the callback

  2. payment_required_request (the parsed 402 body):

  • only exists as a local var in wrap_tool_call, developer doesn't see the 402 response from callback function so they can't parse it manually either so we would have to pass this in.

Based on these constraints, it seems to me that keeping the context object and passing it into the callback is the best option (developer just accesses everything needed for state in one object), but if anything seems off or something I haven't considered please let me know!

"""Context passed to the on_payment_error callback.

The developer can inspect the exception, mutate `config` to fix the issue
(e.g., set payment_instrument_id), and return ErrorResolution.RETRY.

Attributes:
exception: The exception instance that triggered the callback.
exception_type: String name of the exception class.
exception_message: str(exception).
tool_name: Name of the tool that triggered the 402.
tool_args: The tool call arguments dict.
payment_required_request: The 402 payload dict (may be None if error before extraction).
config: Mutable reference to AgentCorePaymentsConfig.
retry_count: How many times we've already retried via the callback.
"""

exception: Exception
exception_type: str
exception_message: str
tool_name: str
tool_args: Dict[str, Any]
payment_required_request: Optional[Dict[str, Any]]
config: "AgentCorePaymentsConfig"
retry_count: int
Loading
Loading