diff --git a/src/bedrock_agentcore/payments/integrations/config.py b/src/bedrock_agentcore/payments/integrations/config.py index 86e05898..5456a1e7 100644 --- a/src/bedrock_agentcore/payments/integrations/config.py +++ b/src/bedrock_agentcore/payments/integrations/config.py @@ -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 @@ -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.""" @@ -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. diff --git a/src/bedrock_agentcore/payments/integrations/strands/plugin.py b/src/bedrock_agentcore/payments/integrations/strands/plugin.py index 041b53ed..aada8cc0 100644 --- a/src/bedrock_agentcore/payments/integrations/strands/plugin.py +++ b/src/bedrock_agentcore/payments/integrations/strands/plugin.py @@ -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 @@ -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 @@ -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"] + 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: @@ -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) @@ -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", + "content": [{"text": json.dumps(payload)}], + } diff --git a/src/bedrock_agentcore/payments/manager.py b/src/bedrock_agentcore/payments/manager.py index 0b968b55..47b79702 100644 --- a/src/bedrock_agentcore/payments/manager.py +++ b/src/bedrock_agentcore/payments/manager.py @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py b/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py index f110bc7b..c77c7209 100644 --- a/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py @@ -1800,3 +1800,168 @@ def test_allowlist_none_allows_all_tools(self): mock_handler.extract_status_code.assert_called_once() mock_pm_instance.generate_payment_header.assert_called_once() + + +class TestPostPaymentRetryDelay: + """Tests for post_payment_retry_delay_seconds backoff before tool retry. + + The x402 EIP-3009 transferWithAuthorization contract requires + block.timestamp > validAfter (strict greater-than). When the signing + service sets validAfter near the current time, fast facilitators submit + in the same second the signature was minted and hit a deterministic + revert. The plugin sleeps post_payment_retry_delay_seconds between + signing and re-invoking the tool to let the chain advance. + """ + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep") + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_default_delay_is_three_seconds(self, mock_payment_manager_class, mock_sleep): + """Default post_payment_retry_delay_seconds is 3.0 — sleep is called with 3.0.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + assert config.post_payment_retry_delay_seconds == 3.0 + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + plugin.after_tool_call(event) + + assert event.retry is True + mock_sleep.assert_called_once_with(3.0) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep") + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_custom_delay_value(self, mock_payment_manager_class, mock_sleep): + """A custom positive delay is honored verbatim.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + post_payment_retry_delay_seconds=1.5, + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + plugin.after_tool_call(event) + + mock_sleep.assert_called_once_with(1.5) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep") + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_zero_delay_skips_sleep(self, mock_payment_manager_class, mock_sleep): + """post_payment_retry_delay_seconds=0 skips the sleep entirely.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + post_payment_retry_delay_seconds=0, + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + plugin.after_tool_call(event) + + # Tool should still be set to retry — sleep is the only thing skipped. + assert event.retry is True + mock_sleep.assert_not_called() + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep") + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_sleep_does_not_fire_when_payment_fails(self, mock_payment_manager_class, mock_sleep): + """If signing fails, no sleep should run — we never reach the retry path.""" + from bedrock_agentcore.payments.manager import PaymentInstrumentNotFound + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.side_effect = PaymentInstrumentNotFound("missing") + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + plugin.after_tool_call(event) + + mock_sleep.assert_not_called() + + def test_validator_rejects_negative_delay(self): + """post_payment_retry_delay_seconds cannot be negative.""" + import pytest + + with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be >= 0"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + post_payment_retry_delay_seconds=-1.0, + ) + + def test_validator_rejects_non_numeric_delay(self): + """post_payment_retry_delay_seconds must be int/float, not bool/str.""" + import pytest + + with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be a number"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + post_payment_retry_delay_seconds="3", # type: ignore[arg-type] + ) + + def test_validator_rejects_bool_delay(self): + """bool sneaks through isinstance(int) — explicitly reject.""" + import pytest + + with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be a number"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + post_payment_retry_delay_seconds=True, # type: ignore[arg-type] + ) diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py index 9de2fc57..06d02d11 100644 --- a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py @@ -1681,3 +1681,329 @@ def test_bearer_auth_mode_user_id_none_passes_none_to_manager(self): payment_instrument_id="instr-123", payment_connector_id=None, ) + + +class TestHttpRequestTool: + """Tests for the http_request tool method on AgentCorePaymentsPlugin. + + The tool returns a Strands ToolResult dict so the AgentCorePaymentsPlugin's + after_tool_call hook can inspect the result and trigger payment retries on 402. + """ + + def _make_plugin(self): + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + return AgentCorePaymentsPlugin(config) + + @staticmethod + def _mock_response(status_code: int, headers: dict | None = None, json_body=None, text: str | None = None): + from unittest.mock import MagicMock as _MagicMock + + resp = _MagicMock() + resp.status_code = status_code + resp.headers = headers or {} + if json_body is not None: + resp.json.return_value = json_body + resp.text = json.dumps(json_body) + else: + resp.json.side_effect = ValueError("not json") + resp.text = text or "" + return resp + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_returns_tool_result_envelope_on_200(self, mock_client_cls): + """200 OK responses are wrapped in a Strands ToolResult dict.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response( + 200, headers={"content-type": "application/json"}, json_body={"weather": "sunny", "temp": 70} + ) + + result = plugin.http_request(url="https://example.com/weather") + + assert result["status"] == "success" + assert isinstance(result["content"], list) and len(result["content"]) == 1 + text = result["content"][0]["text"] + assert not text.startswith("PAYMENT_REQUIRED:") + body = json.loads(text) + assert body["statusCode"] == 200 + assert body["body"] == {"weather": "sunny", "temp": 70} + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_402_uses_payment_required_marker(self, mock_client_cls): + """402 responses are tagged with PAYMENT_REQUIRED: so the SDK handler can parse them.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + + x402_body = { + "x402Version": 2, + "error": "Payment required", + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036C", + "payTo": "0xabc", + "maxTimeoutSeconds": 300, + "extra": {"name": "USDC", "version": "2"}, + } + ], + } + mock_client.request.return_value = self._mock_response( + 402, headers={"PAYMENT-REQUIRED": "base64..."}, json_body=x402_body + ) + + result = plugin.http_request(url="https://example.com/paid") + + assert result["status"] == "success" + text = result["content"][0]["text"] + assert text.startswith("PAYMENT_REQUIRED: ") + payload = json.loads(text[len("PAYMENT_REQUIRED: ") :]) + assert payload["statusCode"] == 402 + assert payload["body"]["x402Version"] == 2 + assert payload["body"]["accepts"][0]["network"] == "eip155:84532" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_402_marker_is_parsed_by_sdk_handler(self, mock_client_cls): + """The 402 envelope this tool emits is recognized by the SDK's handler dispatcher. + + End-to-end contract test: tool output -> get_payment_handler -> 402 status code. + """ + from bedrock_agentcore.payments.integrations.handlers import get_payment_handler + + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response( + 402, + headers={"x-foo": "bar"}, + json_body={"x402Version": 1, "accepts": [{"scheme": "exact", "network": "eip155:84532"}]}, + ) + + tool_result = plugin.http_request(url="https://example.com/paid") + # event.result for AfterToolCallEvent is the content list + handler = get_payment_handler("http_request", {"url": "https://example.com/paid", "headers": {}}) + assert handler.extract_status_code(tool_result["content"]) == 402 + body = handler.extract_body(tool_result["content"]) + assert body["x402Version"] == 1 + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_get_request_does_not_send_body(self, mock_client_cls): + """GET requests must never send a body, even if the agent supplies one.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={"ok": True}) + + plugin.http_request(url="https://example.com", method="GET", body={"ignored": "value"}) + + call_kwargs = mock_client.request.call_args.kwargs + assert "json" not in call_kwargs + assert "content" not in call_kwargs + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_post_with_dict_body_serializes_as_json(self, mock_client_cls): + """dict body is sent via httpx's json= kwarg (sets Content-Type).""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={"ok": True}) + + plugin.http_request(url="https://example.com/api", method="POST", body={"name": "test"}) + + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs["json"] == {"name": "test"} + assert "content" not in call_kwargs + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_post_with_str_body_sent_as_raw_content(self, mock_client_cls): + """str body is sent verbatim via content=, not re-serialized.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={"ok": True}) + + plugin.http_request(url="https://example.com/api", method="POST", body="raw=payload&x=1") + + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs["content"] == "raw=payload&x=1" + assert "json" not in call_kwargs + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_caller_headers_are_forwarded(self, mock_client_cls): + """User-supplied headers are passed through to the request (and copied, not aliased).""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={"ok": True}) + + caller_headers = {"X-Custom": "v1", "Authorization": "Bearer xyz"} + plugin.http_request(url="https://example.com", headers=caller_headers) + + sent = mock_client.request.call_args.kwargs["headers"] + assert sent["X-Custom"] == "v1" + assert sent["Authorization"] == "Bearer xyz" + # Should be a new dict — mutating the request copy must not bleed back. + sent["X-Plugin-Mutation"] = "ok" + assert "X-Plugin-Mutation" not in caller_headers + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_non_json_response_body_falls_back_to_text(self, mock_client_cls): + """Endpoints that return non-JSON bodies are wrapped in {"text": ...}.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response( + 200, headers={"content-type": "text/plain"}, text="hello world" + ) + + result = plugin.http_request(url="https://example.com") + + body = json.loads(result["content"][0]["text"]) + assert body["statusCode"] == 200 + assert body["body"] == {"text": "hello world"} + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_request_error_returned_as_error_status(self, mock_client_cls): + """Network errors are returned as ToolResult status='error' rather than raised.""" + import httpx as _httpx + + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.side_effect = _httpx.ConnectError("dns failure") + + result = plugin.http_request(url="https://example.com") + + assert result["status"] == "error" + body = json.loads(result["content"][0]["text"]) + assert body["statusCode"] == 0 + assert "dns failure" in body["error"] + assert body["url"] == "https://example.com" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_method_is_uppercased(self, mock_client_cls): + """HTTP method is normalized to upper case so callers can pass 'get' or 'GET'.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={}) + + plugin.http_request(url="https://example.com", method="post", body={"a": 1}) + + # First positional arg to client.request is the method + assert mock_client.request.call_args.args[0] == "POST" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_default_headers_dict_when_caller_omits(self, mock_client_cls): + """When the caller does not pass headers, the request still gets a (mutable) dict.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={}) + + plugin.http_request(url="https://example.com") + + sent = mock_client.request.call_args.kwargs["headers"] + assert isinstance(sent, dict) + assert sent == {} + + +class TestProvideHttpRequestOptOut: + """Tests for the provide_http_request config flag. + + When True (default), AgentCorePaymentsPlugin registers its built-in + http_request @tool. When False, the tool is dropped from the plugin's + discovered tools so callers can ship their own http_request without + Strands raising ValueError on duplicate tool names. + """ + + @staticmethod + def _make_config(**overrides): + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + + kwargs = dict( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + kwargs.update(overrides) + return AgentCorePaymentsPluginConfig(**kwargs) + + def test_default_provides_http_request(self): + """By default the plugin registers http_request alongside its other tools.""" + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + plugin = AgentCorePaymentsPlugin(self._make_config()) + tool_names = {t.tool_name for t in plugin.tools} + assert "http_request" in tool_names + # Sanity: the rest of the plugin's tools are still there. + assert "get_payment_instrument" in tool_names + assert "get_payment_session" in tool_names + + def test_opt_out_drops_only_http_request(self): + """provide_http_request=False removes ONLY http_request from the registered tools.""" + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + plugin = AgentCorePaymentsPlugin(self._make_config(provide_http_request=False)) + tool_names = {t.tool_name for t in plugin.tools} + assert "http_request" not in tool_names + # Other plugin tools must still be present so payment-info queries still work. + assert "get_payment_instrument" in tool_names + assert "list_payment_instruments" in tool_names + assert "get_payment_instrument_balance" in tool_names + assert "get_payment_session" in tool_names + + def test_opt_out_does_not_disable_payment_hook(self): + """Disabling the tool must not disable the after_tool_call interceptor. + + A caller's own http_request that emits PAYMENT_REQUIRED-marked content + should still trigger the plugin's auto-pay path. + """ + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + plugin = AgentCorePaymentsPlugin(self._make_config(provide_http_request=False)) + hook_names = [getattr(h, "__name__", repr(h)) for h in plugin.hooks] + assert "after_tool_call" in hook_names + assert "before_tool_call" in hook_names + + def test_provide_http_request_validation_rejects_non_bool(self): + """Config validator rejects non-boolean values for the new flag.""" + import pytest + + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + + with pytest.raises(ValueError, match="provide_http_request must be a boolean"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + provide_http_request="yes", # type: ignore[arg-type] + ) + + def test_opt_out_preserves_payment_manager_init(self): + """The init flow (PaymentManager initialization) is unaffected by the flag.""" + from unittest.mock import MagicMock + + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + plugin = AgentCorePaymentsPlugin(self._make_config(provide_http_request=False)) + # init_agent should be callable and should not blow up just because we opted out. + try: + plugin.init_agent(agent=MagicMock()) + except RuntimeError: + # Real PaymentManager call against a fake ARN — tolerate the network/IAM + # failure; what we care about is that init_agent doesn't trip on the flag. + pass + # Plugin object itself must still be usable + assert plugin.config.provide_http_request is False diff --git a/tests/bedrock_agentcore/payments/test_payment_manager.py b/tests/bedrock_agentcore/payments/test_payment_manager.py index db3be636..e58d0d7f 100644 --- a/tests/bedrock_agentcore/payments/test_payment_manager.py +++ b/tests/bedrock_agentcore/payments/test_payment_manager.py @@ -943,6 +943,38 @@ def test_payment_manager_arn_injected_in_payment(self, mock_session_class): call_kwargs = mock_client.process_payment.call_args[1] assert call_kwargs["paymentManagerArn"] == arn + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_process_payment_does_not_forward_connector_id(self, mock_session_class): + """ProcessPayment must NOT include paymentConnectorId — the live API rejects it. + + Regression: an earlier change forwarded payment_connector_id into the API + call, which botocore rejected with "Unknown parameter in input: + paymentConnectorId". The connector is resolved server-side from the + payment instrument, so the SDK now drops the kwarg before calling boto3. + """ + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.process_payment.return_value = {"processPaymentId": "payment-123"} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + manager.process_payment( + user_id="user-123", + payment_session_id="session-123", + payment_instrument_id="instrument-123", + payment_type="CRYPTO_X402", + payment_input={"cryptoX402": {"version": "2", "payload": {}}}, + payment_connector_id="connector-456", + ) + + call_kwargs = mock_client.process_payment.call_args[1] + assert "paymentConnectorId" not in call_kwargs + @patch("bedrock_agentcore.payments.manager.boto3.Session") def test_process_payment_insufficient_budget(self, mock_session_class): """Test InsufficientBudget error during payment processing."""