Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
35 changes: 35 additions & 0 deletions src/bedrock_agentcore/payments/integrations/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ class AgentCorePaymentsPluginConfig:
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_manager_arn: str
Expand All @@ -54,6 +72,8 @@ class AgentCorePaymentsPluginConfig:
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

def __post_init__(self) -> None:
"""Validate configuration after initialization."""
Expand Down Expand Up @@ -87,6 +107,21 @@ def __post_init__(self) -> None:
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
):
raise ValueError(
"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}"
)

def update_payment_session_id(self, payment_session_id: str) -> None:
"""Update the payment session ID.

Expand Down
118 changes: 116 additions & 2 deletions src/bedrock_agentcore/payments/integrations/strands/plugin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""AgentCorePaymentsPlugin for Strands Agents framework."""

import json
import logging
import time
import uuid
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union

import httpx
from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent
from strands.plugins import Plugin, hook
from strands.tools import tool
Expand All @@ -25,7 +28,8 @@
class AgentCorePaymentsPlugin(Plugin):
"""Plugin for handling X402 payment requirements and providing payment tools in Strands Agents.

This plugin provides three tools for querying payment information:
This plugin provides tools for querying payment information and making paid HTTP calls:
- http_request: Call a (paid) HTTP endpoint; 402 responses are settled automatically
- getPaymentInstrument: Retrieve details about a specific payment instrument
- listPaymentInstruments: List all payment instruments for a user
- getPaymentSession: Retrieve details about a specific payment session
Expand Down Expand Up @@ -55,6 +59,18 @@ def __init__(self, config: AgentCorePaymentsPluginConfig):
super().__init__()
self.config = config
self.payment_manager: Optional[PaymentManager] = None

# Honor the provide_http_request opt-out: Strands' Plugin base auto-discovers
# every @tool method into self._tools at super().__init__(). If the caller
# wants to ship their own http_request, drop ours so Strands' tool registry
# doesn't raise ValueError on duplicate tool name.
if not self.config.provide_http_request:
self._tools = [t for t in self._tools if t.tool_name != "http_request"]
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.

nit: self._tools is a private attr on the Strands Plugin base class — if they rename or restructure it in a future version, this opt-out breaks silently (http_request gets registered even when provide_http_request=False). might be worth a follow up to see if strands exposes a public API for deregistering tools, or at least pin a comment here noting the coupling so we catch it if strands changes.

logger.info(
"provide_http_request=False — plugin's http_request tool will not be registered. "
"Auto-payment still triggers on any tool emitting a PAYMENT_REQUIRED: marker."
)

logger.info("Initialized AgentCorePaymentsPlugin")

def init_agent(self, agent) -> None:
Expand Down Expand Up @@ -236,6 +252,20 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
# after this retry, we know it's a server-side rejection, not a signing failure.
self._mark_successful_signing(event)

# Wait one chain-block before letting the tool retry, so the merchant's
# facilitator has time to see block.timestamp > validAfter when it submits
# transferWithAuthorization to USDC. Without this delay, fast facilitators
# can submit in the same second the signature was minted, hitting the
# contract's strict ``block.timestamp > validAfter`` check and producing
# a misleading "invalid_payload" 402 from the seller.
delay = self.config.post_payment_retry_delay_seconds
if delay > 0:
logger.info(
"Waiting %.1fs before retry to allow chain to advance past validAfter",
delay,
)
time.sleep(delay)

# Set retry flag to re-execute the tool with payment credentials.
event.retry = True
self._reset_interrupt_retry_count(event)
Expand Down Expand Up @@ -726,3 +756,87 @@ def get_payment_session(
str(e),
)
raise

@tool
def http_request(
self,
url: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
body: Optional[Union[Dict[str, Any], str]] = None,
) -> Dict[str, Any]:
"""Call an HTTP endpoint. 402 Payment Required responses are settled automatically.

When the endpoint responds with HTTP 402, this plugin's after_tool_call hook
intercepts the result, generates an x402 payment header via the configured
PaymentManager, mutates ``headers`` with the X-PAYMENT (v1) or
PAYMENT-SIGNATURE (v2) header, and Strands re-invokes this tool — yielding
the final 200 response and (when applicable) a settle hash in the
PAYMENT-RESPONSE header.

Returns a Strands ToolResult dict: ``status`` is always ``success`` (HTTP
errors are returned in the body, not raised), and ``content`` is a single
text block. On 402 the text is prefixed with ``PAYMENT_REQUIRED:`` so the
SDK's payment handlers can extract the x402 payload.

Args:
url: The full URL to request.
method: HTTP method. Defaults to ``GET``.
headers: Optional request headers. The plugin mutates this dict to add
the payment header on retry.
body: Optional request body. ``dict`` is sent as JSON; ``str`` is sent
as-is. Ignored for ``GET``/``HEAD``.

Returns:
Strands ToolResult dict with ``status`` and ``content``.
"""
request_headers = dict(headers) if headers else {}
method_upper = method.upper()

try:
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
if body is None or method_upper in ("GET", "HEAD"):
resp = client.request(method_upper, url, headers=request_headers)
elif isinstance(body, str):
resp = client.request(method_upper, url, headers=request_headers, content=body)
else:
resp = client.request(method_upper, url, headers=request_headers, json=body)
except httpx.RequestError as exc:
logger.error("http_request failed for %s: %s", url, exc)
return {
"status": "error",
"content": [
{
"text": json.dumps(
{
"statusCode": 0,
"error": f"Request failed: {exc}",
"url": url,
}
)
}
],
}

response_headers = dict(resp.headers)
try:
response_body: Any = resp.json()
except Exception:
response_body = {"text": resp.text}

payload = {
"statusCode": resp.status_code,
"headers": response_headers,
"body": response_body,
}

if resp.status_code == 402:
return {
"status": "success",
"content": [{"text": f"PAYMENT_REQUIRED: {json.dumps(payload)}"}],
}

return {
"status": "success",
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.

nit: non-402 HTTP errors (500, 503, 429) come back as status: "success" which is kinda misleading for the LLM — it sees "success" and may not dig into the nested JSON to find the actual status code. could be a follow up to either return "error" for 4xx/5xx or at least surface the status code more prominently in the result shape.

"content": [{"text": json.dumps(payload)}],
}
39 changes: 23 additions & 16 deletions src/bedrock_agentcore/payments/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,10 @@ def process_payment(
payment_input: Payment input details specific to the payment type
user_id: Unique identifier for the user (optional, omitted for bearer auth)
client_token: Optional idempotency token for request uniqueness
payment_connector_id: Optional payment connector ID to route the payment
payment_connector_id: Accepted for backward compatibility but no longer
forwarded to the service. ProcessPayment derives the connector from
the payment instrument; sending paymentConnectorId on this call was
rejected by the API as an unknown parameter.

Returns:
Dictionary containing processPaymentId and transaction details
Expand Down Expand Up @@ -873,8 +876,9 @@ def process_payment(
"paymentInput": payment_input,
"clientToken": client_token,
}
if payment_connector_id is not None:
params["paymentConnectorId"] = payment_connector_id
# paymentConnectorId is intentionally NOT included — the ProcessPayment
# API does not accept it and rejects requests that contain it. The
# connector is resolved server-side from the payment instrument.

result = self._payment_client.process_payment(**params)
logger.info("Successfully processed payment for user %s", user_id)
Expand Down Expand Up @@ -935,7 +939,10 @@ def generate_payment_header(
network_preferences: Optional list of network identifiers in order of preference.
If not provided, defaults to NETWORK_PREFERENCES from constants.
client_token: Optional unique token for idempotency. If not provided, a new one is generated.
payment_connector_id: Optional payment connector ID to pass to process_payment.
payment_connector_id: Accepted for backward compatibility but no longer
forwarded to process_payment. ProcessPayment derives the connector
from the payment instrument; sending paymentConnectorId on that call
was rejected by the API as an unknown parameter.

Returns:
Dictionary with header name and value (e.g., {"X-PAYMENT": "base64..."} or
Expand Down Expand Up @@ -1045,18 +1052,18 @@ def generate_payment_header(
}
}

process_payment_params = {
"user_id": user_id,
"payment_session_id": payment_session_id,
"payment_instrument_id": payment_instrument_id,
"payment_type": "CRYPTO_X402",
"payment_input": payment_input,
"client_token": client_token,
}
if payment_connector_id is not None:
process_payment_params["payment_connector_id"] = payment_connector_id

payment_result = self.process_payment(**process_payment_params)
# ProcessPayment does not accept paymentConnectorId — the connector is
# resolved server-side from the payment instrument. The argument is
# intentionally not forwarded, even when callers (e.g. plugins) supply
# it via plugin config.
payment_result = self.process_payment(
user_id=user_id,
payment_session_id=payment_session_id,
payment_instrument_id=payment_instrument_id,
payment_type="CRYPTO_X402",
payment_input=payment_input,
client_token=client_token,
)
logger.debug("Payment processed successfully")

# Extract cryptoX402 proof from payment result
Expand Down
Loading
Loading