pi-ai-python 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,1598 @@
1
+ """Amazon Bedrock Converse streaming (``bedrock-converse-stream`` API).
2
+
3
+ Port of pi's ``api/bedrock-converse-stream.ts``, which drives the AWS SDK's
4
+ ``ConverseStreamCommand``. This module uses the AWS SDK for Python: a boto3
5
+ ``bedrock-runtime`` client and ``converse_stream``. boto3 is an optional extra
6
+ (``pip install "pi-ai-python[bedrock]"``).
7
+
8
+ boto3 is synchronous, so each request runs the call and iterates its
9
+ ``EventStream`` in a worker thread; events reach the event loop in batches.
10
+
11
+ The payload passed to ``on_payload`` is the ``converse_stream`` input: ``modelId``
12
+ plus the request members, with binary members (image bytes, redacted reasoning)
13
+ as ``bytes``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import base64
20
+ import os
21
+ import re
22
+ import threading
23
+ from collections import OrderedDict
24
+ from collections.abc import Mapping
25
+ from dataclasses import dataclass
26
+ from typing import Any, Literal, TypedDict
27
+
28
+ import httpx
29
+
30
+ from ..abort import abort_scope
31
+ from ..event_stream import AssistantMessageEventStream
32
+ from ..models import calculate_cost
33
+ from ..types import (
34
+ AssistantMessage,
35
+ DoneEvent,
36
+ ErrorEvent,
37
+ Model,
38
+ ProviderResponse,
39
+ StartEvent,
40
+ StopReason,
41
+ StreamOptions,
42
+ TextContent,
43
+ TextDeltaEvent,
44
+ TextEndEvent,
45
+ TextStartEvent,
46
+ ThinkingBudgets,
47
+ ThinkingContent,
48
+ ThinkingDeltaEvent,
49
+ ThinkingEndEvent,
50
+ ThinkingLevel,
51
+ ThinkingStartEvent,
52
+ Tool,
53
+ ToolCall,
54
+ ToolCallDeltaEvent,
55
+ ToolCallEndEvent,
56
+ ToolCallStartEvent,
57
+ TranscriptContext,
58
+ Usage,
59
+ now_ms,
60
+ )
61
+ from ..utils import fastjson
62
+ from ..utils.callbacks import maybe_await
63
+ from ..utils.diagnostics import AssistantMessageDiagnostic, append_assistant_message_diagnostic
64
+ from ..utils.error_body import normalize_provider_error
65
+ from ..utils.http import DEFAULT_TIMEOUT_MS, _proxy_for
66
+ from ..utils.json_parse import StreamingJsonParser, parse_tool_call_arguments
67
+ from ..utils.misc import get_provider_env_value, provider_headers_to_record, sanitize_surrogates
68
+ from ..utils.text import append_text, append_thinking, get_system_message_text
69
+ from ..utils.transcript import (
70
+ collapse_system_messages,
71
+ get_current_tools,
72
+ get_initial_system_message,
73
+ without_initial_system_message,
74
+ )
75
+ from .constrained_sampling import get_json_schema_tool_parameters, resolve_json_schema_strict_sampling
76
+ from .simple_options import (
77
+ adjust_max_tokens_for_thinking,
78
+ build_base_options,
79
+ clamp_max_tokens_to_context,
80
+ clamp_reasoning,
81
+ )
82
+ from .transform_messages import split_error_tool_result_images, transform_messages
83
+
84
+ __all__ = [
85
+ "BedrockThinkingDisplay",
86
+ "BedrockOptions",
87
+ "BedrockClientConfig",
88
+ "BedrockCredentials",
89
+ "BedrockServiceError",
90
+ "BedrockStreamError",
91
+ "BOTO3_INSTALL_HINT",
92
+ "resolve_bedrock_client_config",
93
+ "stream",
94
+ "stream_simple",
95
+ ]
96
+
97
+ BedrockThinkingDisplay = Literal["summarized", "omitted"]
98
+
99
+
100
+ class _ToolChoiceTool(TypedDict):
101
+ type: Literal["tool"]
102
+ name: str
103
+
104
+
105
+ class BedrockOptions(StreamOptions, total=False):
106
+ region: str
107
+ profile: str
108
+ tool_choice: Literal["auto", "any", "none"] | _ToolChoiceTool
109
+ #: See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-reasoning.html for supported models.
110
+ reasoning: ThinkingLevel
111
+ #: Custom token budgets per thinking level. Overrides default budgets.
112
+ thinking_budgets: ThinkingBudgets
113
+ #: Only supported by Claude 4.x models (interleaved thinking beta).
114
+ interleaved_thinking: bool
115
+ #: How Claude's thinking is returned: "summarized" (default here) or "omitted" (redacted but the
116
+ #: signature still travels back). Anthropic's own default for Opus 4.8 is "omitted"; pi defaults to
117
+ #: "summarized" to keep behavior consistent with older Claude 4 models. Claude models only.
118
+ thinking_display: BedrockThinkingDisplay
119
+ #: Key-value pairs attached to the request for cost allocation tagging.
120
+ request_metadata: dict[str, str]
121
+ #: Bedrock API key. When set, SigV4 signing is skipped and ``Authorization: Bearer`` is sent.
122
+ #: Also read from ``AWS_BEARER_TOKEN_BEDROCK``.
123
+ bearer_token: str
124
+ #: Python extension: explicit SigV4 credentials (a :class:`BedrockCredentials`, a mapping with
125
+ #: ``access_key_id``/``secret_access_key``/``session_token`` or the SDK's camelCase keys, a botocore
126
+ #: ``Credentials`` object, or a sync/async callable returning one). Takes precedence over the
127
+ #: credential chain.
128
+ credentials: Any
129
+
130
+
131
+ EMPTY_TEXT_PLACEHOLDER = "<empty>"
132
+ THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01"
133
+ #: Matches the placeholder the Anthropic API path uses for redacted thinking.
134
+ REDACTED_THINKING_PLACEHOLDER = "[Reasoning redacted]"
135
+ _AMBIENT_AUTH_MARKER = "<authenticated>"
136
+ BOTO3_INSTALL_HINT = (
137
+ 'Amazon Bedrock requires the AWS SDK for Python (boto3). Install it with: pip install "pi-ai-python[bedrock]"'
138
+ )
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Errors
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ class BedrockServiceError(Exception):
147
+ """A modeled Bedrock Runtime error (the SDK's ``BedrockRuntimeServiceException``).
148
+
149
+ ``name`` is the AWS error code (``ThrottlingException``, ...). ``status``/``request_id``
150
+ carry the HTTP metadata of a failed request (``None`` for errors inside the stream).
151
+ """
152
+
153
+ def __init__(
154
+ self,
155
+ name: str,
156
+ message: str,
157
+ *,
158
+ status: int | None = None,
159
+ request_id: str | None = None,
160
+ body: str | None = None,
161
+ fields: dict[str, Any] | None = None,
162
+ ) -> None:
163
+ super().__init__(message)
164
+ self.name = name
165
+ self.message = message
166
+ self.status = status
167
+ self.request_id = request_id
168
+ #: Raw response body, only when the message could not be taken from it.
169
+ self.body = body
170
+ self.fields = fields or {}
171
+
172
+
173
+ class BedrockStreamError(Exception):
174
+ """An unmodeled error frame in the response stream, named after its error code."""
175
+
176
+ def __init__(self, name: str | None, message: str) -> None:
177
+ super().__init__(message)
178
+ self.name = name
179
+
180
+
181
+ #: Human-readable prefixes for Bedrock exception names. Retry logic matches patterns like
182
+ #: `server.?error` and `service.?unavailable`, and overflow detection excludes the throttling and
183
+ #: service-unavailable prefixes, so keep this format rather than the raw exception name.
184
+ BEDROCK_ERROR_PREFIXES: dict[str, str] = {
185
+ "InternalServerException": "Internal server error",
186
+ "ModelStreamErrorException": "Model stream error",
187
+ "ValidationException": "Validation error",
188
+ "ThrottlingException": "Throttling error",
189
+ "ServiceUnavailableException": "Service unavailable",
190
+ }
191
+
192
+ #: Some models reject the account's configured data retention mode; point users at the AWS docs.
193
+ BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html"
194
+ _DATA_RETENTION = re.compile(r"data retention mode", re.IGNORECASE)
195
+
196
+ #: Modeled exception members of the ConverseStream output union.
197
+ _STREAM_EXCEPTIONS = frozenset(
198
+ {
199
+ "internalServerException",
200
+ "modelStreamErrorException",
201
+ "validationException",
202
+ "throttlingException",
203
+ "serviceUnavailableException",
204
+ }
205
+ )
206
+
207
+
208
+ def _format_bedrock_error(error: BaseException) -> str:
209
+ norm = normalize_provider_error(error)
210
+ # Surface the raw HTTP body (with status) when it is not folded into the message; this stops a
211
+ # gateway 403 from collapsing to `Unknown: UnknownError`.
212
+ if not norm.message_carries_body and norm.status is not None and norm.body is not None:
213
+ core = f"{norm.status}: {norm.body}"
214
+ else:
215
+ core = norm.message
216
+ hint = (
217
+ f" See {BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes."
218
+ if _DATA_RETENTION.search(core)
219
+ else ""
220
+ )
221
+ if isinstance(error, BedrockServiceError):
222
+ prefix = BEDROCK_ERROR_PREFIXES.get(error.name, error.name)
223
+ return f"{prefix}: {core}{hint}"
224
+ return f"{core}{hint}"
225
+
226
+
227
+ #: Over-long header values are dropped rather than truncated: a truncated request id is not a request id.
228
+ _MAX_DIAGNOSTIC_VALUE_CHARS = 200
229
+
230
+
231
+ def _normalize_diagnostic_value(value: Any) -> str | None:
232
+ if not isinstance(value, str):
233
+ return None
234
+ trimmed = value.strip()
235
+ if not trimmed or len(trimmed) > _MAX_DIAGNOSTIC_VALUE_CHARS:
236
+ return None
237
+ return trimmed
238
+
239
+
240
+ def _extract_error_code(error: BaseException) -> str | None:
241
+ # Modeled Bedrock errors all end in `Exception`, unlike transport names such as `TimeoutError`.
242
+ name = getattr(error, "name", None)
243
+ if not isinstance(name, str):
244
+ name = type(error).__name__
245
+ if not name.endswith("Exception"):
246
+ return None
247
+ return _normalize_diagnostic_value(name)
248
+
249
+
250
+ def _append_failure_diagnostic(output: AssistantMessage, error: BaseException, fallback_request_id: str | None) -> None:
251
+ """Structured metadata alongside ``error_message``, which stays untouched for retry classification."""
252
+ details: dict[str, Any] = {}
253
+ status = getattr(error, "status", None)
254
+ if isinstance(status, int) and not isinstance(status, bool):
255
+ details["status"] = status
256
+ error_code = _extract_error_code(error)
257
+ if error_code is not None:
258
+ details["errorCode"] = error_code
259
+ request_id = _normalize_diagnostic_value(getattr(error, "request_id", None)) or fallback_request_id
260
+ if request_id is not None:
261
+ details["requestId"] = request_id
262
+ if not details:
263
+ return
264
+ append_assistant_message_diagnostic(
265
+ output, AssistantMessageDiagnostic(type="bedrock_response_failure", timestamp=now_ms(), details=details)
266
+ )
267
+
268
+
269
+ def _sanitize_error_code(raw: Any) -> str | None:
270
+ if isinstance(raw, (int, float)) and not isinstance(raw, bool):
271
+ raw = str(raw)
272
+ if not isinstance(raw, str) or not raw:
273
+ return None
274
+ value = raw.split(",")[0].split(":")[0]
275
+ if "#" in value:
276
+ value = value.split("#")[1]
277
+ return value or None
278
+
279
+
280
+ def _request_id(headers: Mapping[str, Any] | None) -> str | None:
281
+ if headers is None:
282
+ return None
283
+ for name in ("x-amzn-requestid", "x-amzn-request-id", "x-amz-request-id"):
284
+ value = headers.get(name)
285
+ if value:
286
+ return value
287
+ return None
288
+
289
+
290
+ def _http_error(status: int, headers: Mapping[str, str], text: str) -> BedrockServiceError:
291
+ """Deserialize a non-2xx response like the JS SDK's REST-JSON error parser.
292
+
293
+ botocore's own parser turns unrecognized bodies into ``"403"``/``"Forbidden"``; pi's
294
+ error messages (and retry/overflow detection) follow the JS SDK, so the raw body is used.
295
+ """
296
+ parsed: Any = None
297
+ if text.strip():
298
+ try:
299
+ parsed = fastjson.loads(text)
300
+ except ValueError:
301
+ parsed = None
302
+ body = parsed if isinstance(parsed, dict) else {}
303
+ code = _sanitize_error_code(headers.get("x-amzn-errortype"))
304
+ if code is None:
305
+ code = _sanitize_error_code(body.get("code")) or _sanitize_error_code(body.get("__type"))
306
+ message = body.get("message") or body.get("Message")
307
+ if not isinstance(message, str) or not message:
308
+ message = None
309
+ return BedrockServiceError(
310
+ code or "Unknown",
311
+ message or "UnknownError",
312
+ status=status,
313
+ request_id=_request_id(headers),
314
+ body=None if message else (text or None),
315
+ fields={key: value for key, value in body.items() if key not in ("message", "Message")},
316
+ )
317
+
318
+
319
+ #: Key under which the ``after-call`` hook stores the raw error response on botocore's parsed dict.
320
+ _RAW_ERROR_KEY = "PiRawErrorResponse"
321
+
322
+
323
+ def _client_error(error: Any) -> BedrockServiceError:
324
+ """Map a botocore ``ClientError`` raised by ``converse_stream``."""
325
+ response: Mapping[str, Any] = getattr(error, "response", None) or {}
326
+ raw = response.get(_RAW_ERROR_KEY)
327
+ if raw is not None:
328
+ status, headers, content = raw
329
+ return _http_error(status, headers, content.decode("utf-8", "replace"))
330
+ metadata = response.get("ResponseMetadata") or {}
331
+ info = response.get("Error") or {}
332
+ status = metadata.get("HTTPStatusCode")
333
+ return BedrockServiceError(
334
+ str(info.get("Code") or "Unknown"),
335
+ str(info.get("Message") or "UnknownError"),
336
+ status=status if isinstance(status, int) else None,
337
+ request_id=metadata.get("RequestId") or None,
338
+ )
339
+
340
+
341
+ def _convert_sdk_error(error: Exception) -> tuple[Exception, str | None]:
342
+ """Turn a botocore exception into pi's error types.
343
+
344
+ Returns the error and, for modeled stream exceptions, the stream member name
345
+ (``throttlingException``) under which it is reported to ``on_provider_stream_event``.
346
+ """
347
+ try:
348
+ from botocore.exceptions import ClientError, EventStreamError, NoCredentialsError, NoRegionError
349
+ except ImportError:
350
+ return error, None
351
+ if isinstance(error, EventStreamError):
352
+ info = error.response.get("Error") or {}
353
+ code = info.get("Code") or None
354
+ message = info.get("Message") or "UnknownError"
355
+ if code in _STREAM_EXCEPTIONS:
356
+ return BedrockServiceError(code[0].upper() + code[1:], message), code
357
+ return BedrockStreamError(code, message), None
358
+ if isinstance(error, ClientError):
359
+ return _client_error(error), None
360
+ # Same wording as the JS SDK.
361
+ if isinstance(error, NoRegionError):
362
+ return RuntimeError("Region is missing"), None
363
+ if isinstance(error, NoCredentialsError):
364
+ return RuntimeError("Could not load credentials from any providers"), None
365
+ return error, None
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Client configuration (region, endpoint, auth)
370
+ # ---------------------------------------------------------------------------
371
+
372
+
373
+ @dataclass(frozen=True, slots=True)
374
+ class BedrockCredentials:
375
+ """Static SigV4 credentials."""
376
+
377
+ access_key_id: str
378
+ secret_access_key: str
379
+ session_token: str | None = None
380
+
381
+
382
+ @dataclass(slots=True)
383
+ class BedrockClientConfig:
384
+ """What pi would pass to ``new BedrockRuntimeClient(config)``."""
385
+
386
+ region: str | None = None
387
+ #: Explicit endpoint; ``None`` lets the SDK derive it from the region.
388
+ endpoint: str | None = None
389
+ profile: str | None = None
390
+ #: Static SigV4 credentials (ambient access keys, explicit option, or skip-auth dummies).
391
+ credentials: Any = None
392
+ #: Bearer token (Bedrock API key); takes precedence over SigV4.
393
+ token: str | None = None
394
+ #: ``AWS_BEDROCK_SKIP_AUTH=1``: sign with dummy credentials, never with a bearer token.
395
+ skip_auth: bool = False
396
+
397
+
398
+ _ARN_REGION = re.compile(r"^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):")
399
+ _STANDARD_ENDPOINT = re.compile(r"^bedrock-runtime(?:-fips)?\.([a-z0-9-]+)\.amazonaws\.com(?:\.cn)?$")
400
+
401
+
402
+ def _configured_region(options: Mapping[str, Any]) -> str | None:
403
+ env = options.get("env")
404
+ return (
405
+ options.get("region")
406
+ or get_provider_env_value("AWS_REGION", env)
407
+ or get_provider_env_value("AWS_DEFAULT_REGION", env)
408
+ or None
409
+ )
410
+
411
+
412
+ def _configured_credentials(env: Mapping[str, str] | None) -> BedrockCredentials | None:
413
+ access_key_id = get_provider_env_value("AWS_ACCESS_KEY_ID", env)
414
+ secret_access_key = get_provider_env_value("AWS_SECRET_ACCESS_KEY", env)
415
+ if not access_key_id or not secret_access_key:
416
+ return None
417
+ return BedrockCredentials(access_key_id, secret_access_key, get_provider_env_value("AWS_SESSION_TOKEN", env))
418
+
419
+
420
+ def _standard_endpoint_region(base_url: str | None) -> str | None:
421
+ if not base_url:
422
+ return None
423
+ try:
424
+ host = httpx.URL(base_url).host.lower()
425
+ except (httpx.InvalidURL, ValueError):
426
+ return None
427
+ match = _STANDARD_ENDPOINT.match(host)
428
+ return match.group(1) if match else None
429
+
430
+
431
+ def _should_use_explicit_endpoint(base_url: str, configured_region: str | None, has_ambient_profile: bool) -> bool:
432
+ if not _standard_endpoint_region(base_url):
433
+ return True
434
+ return not configured_region and not has_ambient_profile
435
+
436
+
437
+ def resolve_bedrock_client_config(model: Model, options: Mapping[str, Any] | None = None) -> BedrockClientConfig:
438
+ """Resolve region, endpoint, profile and auth exactly like pi configures the SDK client."""
439
+ options = options or {}
440
+ env = options.get("env")
441
+ # A profile explicitly configured through pi's auth flow (the `profile` option or scoped
442
+ # `AWS_PROFILE` on the stored credential's env) must win over ambient access keys (#6957).
443
+ options_profile = options.get("profile") or (env or {}).get("AWS_PROFILE") or None
444
+ config = BedrockClientConfig(profile=options_profile or get_provider_env_value("AWS_PROFILE", env))
445
+ configured_region = _configured_region(options)
446
+ has_ambient_profile = bool(get_provider_env_value("AWS_PROFILE"))
447
+ endpoint_region = _standard_endpoint_region(model.base_url)
448
+ use_explicit_endpoint = _should_use_explicit_endpoint(model.base_url, configured_region, has_ambient_profile)
449
+
450
+ # Only pin standard AWS Bedrock runtime endpoints when no region or ambient AWS_PROFILE is configured.
451
+ # This preserves custom endpoints (VPC/proxy) without forcing built-in catalog defaults such as
452
+ # us-east-1 to override AWS_REGION/AWS_PROFILE.
453
+ if use_explicit_endpoint and model.base_url:
454
+ config.endpoint = model.base_url
455
+
456
+ skip_auth = get_provider_env_value("AWS_BEDROCK_SKIP_AUTH", env) == "1"
457
+ config.skip_auth = skip_auth
458
+ api_key = options.get("api_key")
459
+ if api_key == _AMBIENT_AUTH_MARKER:
460
+ api_key = None
461
+ bearer_token = (
462
+ options.get("bearer_token") or api_key or get_provider_env_value("AWS_BEARER_TOKEN_BEDROCK", env) or None
463
+ )
464
+
465
+ # Region resolution: ARN-embedded > explicit option > env vars > endpoint > profile config.
466
+ # An inference profile ARN carries its region, which avoids conflicts with AWS_REGION set for other services.
467
+ arn_match = _ARN_REGION.match(model.id)
468
+ if arn_match:
469
+ config.region = arn_match.group(1)
470
+ elif configured_region:
471
+ config.region = configured_region
472
+ elif endpoint_region and use_explicit_endpoint:
473
+ config.region = endpoint_region
474
+ elif not has_ambient_profile:
475
+ config.region = "us-east-1"
476
+
477
+ if skip_auth:
478
+ # Support proxies that don't need authentication.
479
+ config.credentials = BedrockCredentials("dummy-access-key", "dummy-secret-key")
480
+ else:
481
+ explicit = options.get("credentials")
482
+ if explicit is not None:
483
+ config.credentials = explicit
484
+ else:
485
+ credentials = _configured_credentials(env)
486
+ if credentials is not None and not options_profile:
487
+ config.credentials = credentials
488
+ if bearer_token is not None:
489
+ config.token = bearer_token
490
+ return config
491
+
492
+
493
+ def _coerce_credentials(value: Any) -> tuple[str, str, str | None]:
494
+ if isinstance(value, BedrockCredentials):
495
+ return value.access_key_id, value.secret_access_key, value.session_token
496
+ if isinstance(value, Mapping):
497
+ access_key_id = value.get("access_key_id") or value.get("accessKeyId")
498
+ secret_access_key = value.get("secret_access_key") or value.get("secretAccessKey")
499
+ if access_key_id and secret_access_key:
500
+ return access_key_id, secret_access_key, value.get("session_token") or value.get("sessionToken")
501
+ else:
502
+ # botocore `Credentials` (refreshable ones are frozen per request).
503
+ frozen = getattr(value, "get_frozen_credentials", None)
504
+ if callable(frozen):
505
+ value = frozen()
506
+ access_key = getattr(value, "access_key", None) or getattr(value, "access_key_id", None)
507
+ secret_key = getattr(value, "secret_key", None) or getattr(value, "secret_access_key", None)
508
+ if access_key and secret_key:
509
+ token = getattr(value, "token", None) or getattr(value, "session_token", None)
510
+ return access_key, secret_key, token
511
+ raise TypeError("Bedrock credentials must provide access_key_id and secret_access_key")
512
+
513
+
514
+ @dataclass(frozen=True, slots=True)
515
+ class _ClientKey:
516
+ """Everything a cached boto3 client is configured with."""
517
+
518
+ region: str | None
519
+ endpoint: str | None
520
+ profile: str | None
521
+ credentials: tuple[str, str, str | None] | None
522
+ token: str | None
523
+ #: Prefer SigV4 even when ``AWS_BEARER_TOKEN_BEDROCK`` is set (skip-auth proxies).
524
+ force_sigv4: bool
525
+ #: Total attempts; ``None`` keeps the SDK default (standard mode: 3) or ``AWS_MAX_ATTEMPTS``.
526
+ max_attempts: int | None
527
+ timeout: float
528
+ proxy: str | None
529
+
530
+
531
+ async def _client_key(model: Model, config: BedrockClientConfig, options: Mapping[str, Any]) -> _ClientKey:
532
+ credentials = config.credentials
533
+ if callable(credentials):
534
+ credentials = await maybe_await(credentials())
535
+ max_retries = options.get("max_retries")
536
+ timeout_ms = options.get("timeout_ms")
537
+ return _ClientKey(
538
+ region=config.region,
539
+ endpoint=config.endpoint,
540
+ profile=config.profile,
541
+ credentials=_coerce_credentials(credentials) if credentials is not None else None,
542
+ token=config.token,
543
+ force_sigv4=config.skip_auth,
544
+ max_attempts=None if max_retries is None else max(0, int(max_retries)) + 1,
545
+ timeout=(timeout_ms if timeout_ms is not None else DEFAULT_TIMEOUT_MS) / 1000,
546
+ proxy=_proxy_for(config.endpoint or model.base_url or "https://", options.get("env")),
547
+ )
548
+
549
+
550
+ # ---------------------------------------------------------------------------
551
+ # boto3 clients
552
+ # ---------------------------------------------------------------------------
553
+
554
+ #: Client creation loads the service model and resolves credentials, which is slow; clients are
555
+ #: thread-safe, so they are shared per configuration.
556
+ _MAX_CACHED_CLIENTS = 16
557
+ _client_cache: OrderedDict[_ClientKey, Any] = OrderedDict()
558
+ _client_cache_lock = threading.Lock()
559
+ #: Per-call state for the botocore event hooks, which run in the calling (worker) thread.
560
+ _call_state = threading.local()
561
+
562
+
563
+ def _load_boto3() -> Any:
564
+ try:
565
+ import boto3
566
+ except ImportError as error:
567
+ raise RuntimeError(BOTO3_INSTALL_HINT) from error
568
+ return boto3
569
+
570
+
571
+ class _StaticTokenProvider:
572
+ """botocore token provider for an explicit Bedrock API key."""
573
+
574
+ def __init__(self, token: Any) -> None:
575
+ self._token = token
576
+
577
+ def load_token(self, **_kwargs: Any) -> Any:
578
+ return self._token
579
+
580
+
581
+ #: `host` and `x-amz-*` participate in the SigV4 canonical request; `authorization` is owned by
582
+ #: SigV4 or the bearer-token path. Compared case-insensitively.
583
+ _RESERVED_HEADER_EXACT = frozenset({"authorization", "host"})
584
+
585
+
586
+ def _is_reserved_header(name: str) -> bool:
587
+ lower = name.lower()
588
+ return lower.startswith("x-amz-") or lower in _RESERVED_HEADER_EXACT
589
+
590
+
591
+ def _apply_custom_headers(request: Any, **_kwargs: Any) -> None:
592
+ """``before-sign`` hook: caller headers override same-named request headers, covered by the signature."""
593
+ headers: Mapping[str, str] | None = getattr(_call_state, "headers", None)
594
+ if not headers:
595
+ return
596
+ for name, value in headers.items():
597
+ if name in request.headers:
598
+ del request.headers[name]
599
+ request.headers[name] = value
600
+
601
+
602
+ def _apply_custom_user_agent(request: Any, **_kwargs: Any) -> None:
603
+ """``request-created`` hook (last): botocore rebuilds ``User-Agent`` after signing, so reapply it."""
604
+ headers: Mapping[str, str] | None = getattr(_call_state, "headers", None)
605
+ if not headers:
606
+ return
607
+ for name, value in headers.items():
608
+ if name.lower() == "user-agent":
609
+ if name in request.headers:
610
+ del request.headers[name]
611
+ request.headers[name] = value
612
+
613
+
614
+ def _capture_error_response(http_response: Any, parsed: Any, **_kwargs: Any) -> None:
615
+ """``after-call`` hook: keep the raw error response, which botocore's parser discards."""
616
+ if http_response.status_code >= 300 and isinstance(parsed, dict):
617
+ headers = {str(name).lower(): str(value) for name, value in http_response.headers.items()}
618
+ parsed[_RAW_ERROR_KEY] = (http_response.status_code, headers, http_response.content or b"")
619
+
620
+
621
+ def _new_client(key: _ClientKey) -> Any:
622
+ boto3 = _load_boto3()
623
+ import botocore.session
624
+ from botocore.config import Config
625
+ from botocore.tokens import FrozenAuthToken
626
+
627
+ core = botocore.session.Session()
628
+ if key.token is not None:
629
+ core.register_component("token_provider", _StaticTokenProvider(FrozenAuthToken(key.token)))
630
+ session = boto3.session.Session(botocore_session=core, profile_name=key.profile)
631
+ # The JS SDK defaults to the standard retry mode (3 attempts); botocore defaults to legacy.
632
+ retries: dict[str, Any] = {}
633
+ if key.max_attempts is not None:
634
+ retries["total_max_attempts"] = key.max_attempts
635
+ if not os.environ.get("AWS_RETRY_MODE") and not core.get_scoped_config().get("retry_mode"):
636
+ retries["mode"] = "standard"
637
+ config: dict[str, Any] = {"retries": retries, "connect_timeout": key.timeout, "read_timeout": key.timeout}
638
+ if key.token is not None:
639
+ config["auth_scheme_preference"] = "httpBearerAuth"
640
+ elif key.force_sigv4:
641
+ config["auth_scheme_preference"] = "sigv4"
642
+ if key.proxy is not None:
643
+ config["proxies"] = {"http": key.proxy, "https": key.proxy}
644
+ access_key, secret_key, session_token = key.credentials or (None, None, None)
645
+ client = session.client(
646
+ "bedrock-runtime",
647
+ region_name=key.region,
648
+ endpoint_url=key.endpoint,
649
+ aws_access_key_id=access_key,
650
+ aws_secret_access_key=secret_key,
651
+ aws_session_token=session_token,
652
+ config=Config(**config),
653
+ )
654
+ client.meta.events.register("before-sign.bedrock-runtime.ConverseStream", _apply_custom_headers)
655
+ client.meta.events.register("after-call.bedrock-runtime.ConverseStream", _capture_error_response)
656
+ client.meta.events.register_last("request-created.bedrock-runtime", _apply_custom_user_agent)
657
+ return client
658
+
659
+
660
+ def _get_client(key: _ClientKey) -> Any:
661
+ with _client_cache_lock:
662
+ client = _client_cache.get(key)
663
+ if client is not None:
664
+ _client_cache.move_to_end(key)
665
+ return client
666
+ client = _new_client(key)
667
+ _client_cache[key] = client
668
+ if len(_client_cache) > _MAX_CACHED_CLIENTS:
669
+ _client_cache.popitem(last=False)
670
+ return client
671
+
672
+
673
+ # ---------------------------------------------------------------------------
674
+ # Worker thread <-> event loop
675
+ # ---------------------------------------------------------------------------
676
+
677
+
678
+ @dataclass(slots=True)
679
+ class _Response:
680
+ metadata: Mapping[str, Any]
681
+
682
+
683
+ @dataclass(slots=True)
684
+ class _Failure:
685
+ error: Exception
686
+ #: Stream member name of a modeled stream exception (``throttlingException``).
687
+ stream_item: str | None
688
+
689
+
690
+ _END = object()
691
+
692
+
693
+ def _interrupt_stream(event_stream: Any) -> None:
694
+ """Unblock a worker reading ``event_stream`` from another thread."""
695
+ raw = getattr(event_stream, "_raw_stream", None)
696
+ shutdown = getattr(raw, "shutdown", None) # urllib3 >= 2.3
697
+ try:
698
+ if callable(shutdown):
699
+ shutdown()
700
+ else:
701
+ event_stream.close()
702
+ except Exception:
703
+ pass
704
+
705
+
706
+ class _StreamBridge:
707
+ """Hands items from the worker thread to the event loop.
708
+
709
+ Items accumulate in a list; the worker schedules at most one wake-up per batch, so a
710
+ fast stream costs one ``call_soon_threadsafe`` per batch rather than per event.
711
+ """
712
+
713
+ __slots__ = ("_items", "_lock", "_loop", "_scheduled", "_stream", "_waiter", "closed")
714
+
715
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
716
+ self._loop = loop
717
+ self._lock = threading.Lock()
718
+ self._items: list[Any] = []
719
+ self._scheduled = False
720
+ self._waiter: asyncio.Future[None] | None = None
721
+ self._stream: Any = None
722
+ self.closed = False
723
+
724
+ # -- worker side -----------------------------------------------------------------------
725
+
726
+ def attach(self, event_stream: Any) -> bool:
727
+ """Register the response stream; ``False`` once the consumer has gone away."""
728
+ with self._lock:
729
+ if self.closed:
730
+ return False
731
+ self._stream = event_stream
732
+ return True
733
+
734
+ def put(self, item: Any) -> None:
735
+ with self._lock:
736
+ if self.closed:
737
+ return
738
+ self._items.append(item)
739
+ if self._scheduled:
740
+ return
741
+ self._scheduled = True
742
+ try:
743
+ self._loop.call_soon_threadsafe(self._wake)
744
+ except RuntimeError: # the event loop is closed
745
+ self.closed = True
746
+
747
+ # -- event loop side -------------------------------------------------------------------
748
+
749
+ def _wake(self) -> None:
750
+ waiter = self._waiter
751
+ if waiter is not None and not waiter.done():
752
+ waiter.set_result(None)
753
+
754
+ async def get(self) -> list[Any]:
755
+ while True:
756
+ with self._lock:
757
+ if self._items:
758
+ items, self._items = self._items, []
759
+ self._scheduled = False
760
+ return items
761
+ waiter = self._loop.create_future()
762
+ self._waiter = waiter
763
+ try:
764
+ await waiter
765
+ finally:
766
+ self._waiter = None
767
+
768
+ def close(self) -> None:
769
+ with self._lock:
770
+ if self.closed:
771
+ return
772
+ self.closed = True
773
+ self._items = []
774
+ event_stream = self._stream
775
+ if event_stream is not None:
776
+ _interrupt_stream(event_stream)
777
+
778
+
779
+ def _stream_worker(bridge: _StreamBridge, key: _ClientKey, params: dict[str, Any], headers: Any) -> None:
780
+ event_stream: Any = None
781
+ try:
782
+ client = _get_client(key)
783
+ _call_state.headers = headers
784
+ try:
785
+ response = client.converse_stream(**params)
786
+ finally:
787
+ _call_state.headers = None
788
+ event_stream = response["stream"]
789
+ if not bridge.attach(event_stream):
790
+ return
791
+ bridge.put(_Response(response.get("ResponseMetadata") or {}))
792
+ for event in event_stream:
793
+ if bridge.closed:
794
+ return
795
+ bridge.put(event)
796
+ bridge.put(_END)
797
+ except Exception as error:
798
+ if not bridge.closed:
799
+ bridge.put(_Failure(*_convert_sdk_error(error)))
800
+ finally:
801
+ if event_stream is not None:
802
+ try:
803
+ event_stream.close()
804
+ except Exception:
805
+ pass
806
+
807
+
808
+ # ---------------------------------------------------------------------------
809
+ # stream / stream_simple
810
+ # ---------------------------------------------------------------------------
811
+
812
+
813
+ def stream(
814
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
815
+ ) -> AssistantMessageEventStream:
816
+ """Stream a Bedrock ConverseStream response."""
817
+ opts: dict[str, Any] = dict(options or {})
818
+ out = AssistantMessageEventStream()
819
+ # Bedrock has no mid-conversation system messages; fold them into the leading prompt.
820
+ normalized = collapse_system_messages(context)
821
+ out.start(_run(model, normalized, opts, out))
822
+ return out
823
+
824
+
825
+ def stream_simple(
826
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
827
+ ) -> AssistantMessageEventStream:
828
+ """Map provider-agnostic simple options to Bedrock options."""
829
+ options = options or {}
830
+ base: dict[str, Any] = {**build_base_options(model, context, options, None)}
831
+ if options.get("tool_choice") is not None:
832
+ base["tool_choice"] = options["tool_choice"]
833
+ reasoning = options.get("reasoning")
834
+ budgets = options.get("thinking_budgets")
835
+ if not reasoning:
836
+ return stream(model, context, base)
837
+
838
+ if _is_anthropic_claude_model(model):
839
+ if _supports_adaptive_thinking(model.id, model.name):
840
+ return stream(model, context, {**base, "reasoning": reasoning, "thinking_budgets": budgets})
841
+ # None would mean no caller cap; build_base_options always sets one, so never coerce to 0
842
+ # (that would make the thinking budget the entire max_tokens value).
843
+ adjusted_max_tokens, thinking_budget = adjust_max_tokens_for_thinking(
844
+ base.get("max_tokens"), model.max_tokens, reasoning, budgets
845
+ )
846
+ max_tokens = clamp_max_tokens_to_context(model, context, adjusted_max_tokens)
847
+ level = clamp_reasoning(reasoning) or "medium"
848
+ return stream(
849
+ model,
850
+ context,
851
+ {
852
+ **base,
853
+ "max_tokens": max_tokens,
854
+ "reasoning": reasoning,
855
+ "thinking_budgets": {**(budgets or {}), level: min(thinking_budget, max(0, max_tokens - 1024))},
856
+ },
857
+ )
858
+
859
+ return stream(model, context, {**base, "reasoning": reasoning, "thinking_budgets": budgets})
860
+
861
+
862
+ @dataclass(slots=True)
863
+ class _BlockState:
864
+ """Streaming scratch state of one content block (never stored on the block)."""
865
+
866
+ #: Bedrock contentBlockIndex while the block is open.
867
+ index: int | None = None
868
+ parser: StreamingJsonParser | None = None
869
+ #: Encrypted reasoning deltas, joined into `thinking_signature` when the block ends.
870
+ redacted_chunks: list[bytes] | None = None
871
+
872
+
873
+ class _Blocks:
874
+ __slots__ = ("output", "states")
875
+
876
+ def __init__(self, output: AssistantMessage) -> None:
877
+ self.output = output
878
+ self.states: dict[int, _BlockState] = {}
879
+
880
+ def find(self, bedrock_index: Any) -> int:
881
+ for position, state in self.states.items():
882
+ if state.index is not None and state.index == bedrock_index:
883
+ return position
884
+ return -1
885
+
886
+ def add(self, block: Any, bedrock_index: Any, **state: Any) -> int:
887
+ self.output.content.append(block)
888
+ position = len(self.output.content) - 1
889
+ self.states[position] = _BlockState(index=bedrock_index, **state)
890
+ return position
891
+
892
+ def flush_redacted(self, position: int) -> None:
893
+ state = self.states.get(position)
894
+ if state is None or state.redacted_chunks is None:
895
+ return
896
+ block = self.output.content[position]
897
+ if block.type == "thinking":
898
+ block.thinking_signature = base64.b64encode(b"".join(state.redacted_chunks)).decode("ascii")
899
+ state.redacted_chunks = None
900
+
901
+ def finalize_all(self) -> None:
902
+ """Strip every scratch field; a stream can settle without stopping each block."""
903
+ for position in list(self.states):
904
+ self.flush_redacted(position)
905
+ self.states.clear()
906
+
907
+
908
+ async def _run(
909
+ model: Model, context: TranscriptContext, options: dict[str, Any], out: AssistantMessageEventStream
910
+ ) -> None:
911
+ output = AssistantMessage(
912
+ [],
913
+ api="bedrock-converse-stream",
914
+ provider=model.provider,
915
+ model=model.id,
916
+ usage=Usage(),
917
+ stop_reason="pending",
918
+ )
919
+ signal = options.get("signal")
920
+ env = options.get("env")
921
+ blocks = _Blocks(output)
922
+ # Kept outside the try so the handler can still correlate a mid-stream failure:
923
+ # exceptions delivered as stream events carry no HTTP metadata of their own.
924
+ response_request_id: str | None = None
925
+ try:
926
+ config = resolve_bedrock_client_config(model, options)
927
+ supports_strict_mode = bool((model.compat or {}).get("supportsStrictMode", False))
928
+ cache_retention = _resolve_cache_retention(options.get("cache_retention"), env)
929
+ max_tokens = options.get("max_tokens")
930
+ if max_tokens is None and _is_anthropic_claude_model(model):
931
+ max_tokens = model.max_tokens
932
+ initial = get_initial_system_message(context.messages)
933
+ system_prompt = get_system_message_text(initial) if initial else None
934
+
935
+ payload: Any = {
936
+ "modelId": model.id,
937
+ "messages": _convert_messages(context, model, cache_retention, env),
938
+ }
939
+ system = _build_system_prompt(system_prompt, model, cache_retention, env)
940
+ if system is not None:
941
+ payload["system"] = system
942
+ inference_config: dict[str, Any] = {}
943
+ if max_tokens is not None:
944
+ inference_config["maxTokens"] = max_tokens
945
+ if options.get("temperature") is not None:
946
+ inference_config["temperature"] = options["temperature"]
947
+ payload["inferenceConfig"] = inference_config
948
+ tool_config = _convert_tool_config(
949
+ get_current_tools(context.messages), options.get("tool_choice"), supports_strict_mode
950
+ )
951
+ if tool_config is not None:
952
+ payload["toolConfig"] = tool_config
953
+ additional = _build_additional_model_request_fields(model, options)
954
+ if additional is not None:
955
+ payload["additionalModelRequestFields"] = additional
956
+ if options.get("request_metadata") is not None:
957
+ payload["requestMetadata"] = options["request_metadata"]
958
+ on_payload = options.get("on_payload")
959
+ if on_payload is not None:
960
+ replaced = await maybe_await(on_payload(payload, model))
961
+ if replaced is not None:
962
+ payload = replaced
963
+
964
+ async with abort_scope(signal):
965
+ key = await _client_key(model, config, options)
966
+ params = {name: value for name, value in payload.items() if value is not None}
967
+ custom_headers = provider_headers_to_record(options.get("headers"))
968
+ headers = (
969
+ {name: value for name, value in custom_headers.items() if not _is_reserved_header(name)}
970
+ if custom_headers
971
+ else None
972
+ )
973
+ on_response = options.get("on_response")
974
+ on_event = options.get("on_provider_stream_event")
975
+ bridge = _StreamBridge(asyncio.get_running_loop())
976
+ worker = threading.Thread(
977
+ target=_stream_worker, args=(bridge, key, params, headers), name="pi-ai-bedrock", daemon=True
978
+ )
979
+ worker.start()
980
+ try:
981
+ finished = False
982
+ while not finished:
983
+ for item in await bridge.get():
984
+ if type(item) is dict:
985
+ if on_event is not None:
986
+ await maybe_await(on_event(item, model))
987
+ _handle_item(item, model, output, blocks, out)
988
+ elif item is _END:
989
+ finished = True
990
+ elif isinstance(item, _Response):
991
+ metadata = item.metadata
992
+ response_request_id = _normalize_diagnostic_value(metadata.get("RequestId"))
993
+ status = metadata.get("HTTPStatusCode")
994
+ if on_response is not None and isinstance(status, int):
995
+ headers_record = dict(metadata.get("HTTPHeaders") or {})
996
+ await maybe_await(on_response(ProviderResponse(status, headers_record), model))
997
+ else:
998
+ # Modeled stream exceptions are stream items in the SDK: observe, then raise.
999
+ if item.stream_item is not None and on_event is not None:
1000
+ await maybe_await(on_event({item.stream_item: item.error}, model))
1001
+ raise item.error
1002
+ finally:
1003
+ bridge.close()
1004
+
1005
+ if signal is not None and signal.aborted:
1006
+ raise RuntimeError("Request was aborted")
1007
+ if output.stop_reason == "pending":
1008
+ raise RuntimeError("Bedrock stream ended without a stop reason")
1009
+ if output.stop_reason in ("error", "aborted"):
1010
+ raise RuntimeError(output.error_message or "An unknown error occurred")
1011
+
1012
+ blocks.finalize_all()
1013
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
1014
+ out.end()
1015
+ except Exception as error:
1016
+ blocks.finalize_all()
1017
+ output.stop_reason = "aborted" if signal is not None and signal.aborted else "error"
1018
+ output.error_message = _format_bedrock_error(error)
1019
+ if output.stop_reason == "error":
1020
+ _append_failure_diagnostic(output, error, response_request_id)
1021
+ out.push(ErrorEvent(output.stop_reason, output))
1022
+ out.end()
1023
+
1024
+
1025
+ # ---------------------------------------------------------------------------
1026
+ # Response stream
1027
+ # ---------------------------------------------------------------------------
1028
+
1029
+
1030
+ def _handle_item(
1031
+ item: Mapping[str, Any],
1032
+ model: Model,
1033
+ output: AssistantMessage,
1034
+ blocks: _Blocks,
1035
+ out: AssistantMessageEventStream,
1036
+ ) -> None:
1037
+ if (start := item.get("messageStart")) is not None:
1038
+ if start.get("role") != "assistant":
1039
+ raise RuntimeError("Unexpected assistant message start but got user message start instead")
1040
+ out.push(StartEvent(output))
1041
+ elif (event := item.get("contentBlockStart")) is not None:
1042
+ _handle_content_block_start(event, blocks, out)
1043
+ elif (event := item.get("contentBlockDelta")) is not None:
1044
+ _handle_content_block_delta(event, blocks, out)
1045
+ elif (event := item.get("contentBlockStop")) is not None:
1046
+ _handle_content_block_stop(event, blocks, out)
1047
+ elif (stop := item.get("messageStop")) is not None:
1048
+ raw = stop.get("stopReason")
1049
+ output.raw_stop_reason = raw
1050
+ stop_reason, error_message = _map_stop_reason(raw)
1051
+ output.stop_reason = stop_reason
1052
+ if error_message:
1053
+ output.error_message = error_message
1054
+ elif (metadata := item.get("metadata")) is not None:
1055
+ _handle_metadata(metadata, model, output)
1056
+
1057
+
1058
+ def _handle_content_block_start(event: Mapping[str, Any], blocks: _Blocks, out: AssistantMessageEventStream) -> None:
1059
+ tool_use = (event.get("start") or {}).get("toolUse")
1060
+ if tool_use:
1061
+ block = ToolCall(tool_use.get("toolUseId") or "", tool_use.get("name") or "", {})
1062
+ position = blocks.add(block, event.get("contentBlockIndex"), parser=StreamingJsonParser())
1063
+ out.push(ToolCallStartEvent(position, blocks.output))
1064
+
1065
+
1066
+ def _handle_content_block_delta(event: Mapping[str, Any], blocks: _Blocks, out: AssistantMessageEventStream) -> None:
1067
+ output = blocks.output
1068
+ bedrock_index = event.get("contentBlockIndex")
1069
+ delta = event.get("delta") or {}
1070
+ position = blocks.find(bedrock_index)
1071
+ block: Any = output.content[position] if position >= 0 else None
1072
+
1073
+ if delta.get("text") is not None:
1074
+ # handleContentBlockStart is not sent for text blocks, so create one on the first delta.
1075
+ if block is None:
1076
+ block = TextContent("")
1077
+ position = blocks.add(block, bedrock_index)
1078
+ out.push(TextStartEvent(position, output))
1079
+ if block.type == "text":
1080
+ append_text(block, delta["text"])
1081
+ out.push(TextDeltaEvent(position, delta["text"], output))
1082
+ elif delta.get("toolUse") and block is not None and block.type == "toolCall":
1083
+ partial = delta["toolUse"].get("input") or ""
1084
+ parser = blocks.states[position].parser
1085
+ assert parser is not None
1086
+ parser.feed(partial)
1087
+ block.arguments = parser.value()
1088
+ out.push(ToolCallDeltaEvent(position, partial, output))
1089
+ elif delta.get("reasoningContent"):
1090
+ reasoning = delta["reasoningContent"]
1091
+ if block is None:
1092
+ block = ThinkingContent("", thinking_signature="")
1093
+ position = blocks.add(block, bedrock_index)
1094
+ out.push(ThinkingStartEvent(position, output))
1095
+ if block.type != "thinking":
1096
+ return
1097
+ text = reasoning.get("text")
1098
+ if text:
1099
+ append_thinking(block, text)
1100
+ out.push(ThinkingDeltaEvent(position, text, output))
1101
+ # `thinking_signature` holds either an Anthropic signature or an opaque redacted payload,
1102
+ # never both: mixing them would corrupt whichever arrived first.
1103
+ signature = reasoning.get("signature")
1104
+ if signature and not block.redacted:
1105
+ block.thinking_signature = (block.thinking_signature or "") + signature
1106
+ redacted = reasoning.get("redactedContent")
1107
+ if redacted:
1108
+ # Encrypted reasoning from non-Anthropic models (e.g. OpenAI GPT-5.6). The payload is opaque,
1109
+ # so keep it verbatim in `thinking_signature` like Anthropic redacted thinking, and replay it.
1110
+ if not block.redacted:
1111
+ block.redacted = True
1112
+ block.thinking_signature = ""
1113
+ append_thinking(block, REDACTED_THINKING_PLACEHOLDER)
1114
+ out.push(ThinkingDeltaEvent(position, REDACTED_THINKING_PLACEHOLDER, output))
1115
+ state = blocks.states[position]
1116
+ if state.redacted_chunks is None:
1117
+ state.redacted_chunks = []
1118
+ state.redacted_chunks.append(bytes(redacted))
1119
+
1120
+
1121
+ def _handle_content_block_stop(event: Mapping[str, Any], blocks: _Blocks, out: AssistantMessageEventStream) -> None:
1122
+ output = blocks.output
1123
+ position = blocks.find(event.get("contentBlockIndex"))
1124
+ if position < 0:
1125
+ return
1126
+ block: Any = output.content[position]
1127
+ state = blocks.states[position]
1128
+ state.index = None
1129
+ kind = block.type
1130
+ if kind == "text":
1131
+ out.push(TextEndEvent(position, block.text, output))
1132
+ elif kind == "thinking":
1133
+ blocks.flush_redacted(position)
1134
+ out.push(ThinkingEndEvent(position, block.thinking, output))
1135
+ elif kind == "toolCall":
1136
+ arguments, arguments_error = parse_tool_call_arguments(state.parser.text if state.parser else "")
1137
+ block.arguments = arguments
1138
+ if arguments_error is not None:
1139
+ block.arguments_error = arguments_error
1140
+ state.parser = None
1141
+ out.push(ToolCallEndEvent(position, block, output))
1142
+
1143
+
1144
+ def _handle_metadata(event: Mapping[str, Any], model: Model, output: AssistantMessage) -> None:
1145
+ usage = event.get("usage")
1146
+ if not usage:
1147
+ return
1148
+ output.usage.input = usage.get("inputTokens") or 0
1149
+ output.usage.output = usage.get("outputTokens") or 0
1150
+ output.usage.cache_read = usage.get("cacheReadInputTokens") or 0
1151
+ output.usage.cache_write = usage.get("cacheWriteInputTokens") or 0
1152
+ details = usage.get("cacheDetails")
1153
+ output.usage.cache_write_1h = (
1154
+ sum((detail.get("inputTokens") or 0) for detail in details if detail.get("ttl") == "1h")
1155
+ if isinstance(details, list)
1156
+ else None
1157
+ )
1158
+ output.usage.total_tokens = usage.get("totalTokens") or (output.usage.input + output.usage.output)
1159
+ calculate_cost(model, output.usage)
1160
+
1161
+
1162
+ def _map_stop_reason(reason: str | None) -> tuple[StopReason, str | None]:
1163
+ if reason in ("end_turn", "stop_sequence"):
1164
+ return "stop", None
1165
+ if reason in ("max_tokens", "model_context_window_exceeded"):
1166
+ return "length", None
1167
+ if reason == "tool_use":
1168
+ return "toolUse", None
1169
+ return "error", (f"Provider stopped with: {reason}" if reason else None)
1170
+
1171
+
1172
+ # ---------------------------------------------------------------------------
1173
+ # Model capabilities
1174
+ # ---------------------------------------------------------------------------
1175
+
1176
+ _MATCH_SEPARATORS = re.compile(r"[\s_.:]+")
1177
+
1178
+
1179
+ def _model_match_candidates(model_id: str, model_name: str | None = None) -> list[str]:
1180
+ """Model id and name variants; names cover application inference profile ARNs."""
1181
+ values = [model_id, model_name] if model_name else [model_id]
1182
+ candidates: list[str] = []
1183
+ for value in values:
1184
+ lower = value.lower()
1185
+ candidates.append(lower)
1186
+ candidates.append(_MATCH_SEPARATORS.sub("-", lower))
1187
+ return candidates
1188
+
1189
+
1190
+ def _matches_any(candidates: list[str], needles: tuple[str, ...]) -> bool:
1191
+ return any(needle in candidate for candidate in candidates for needle in needles)
1192
+
1193
+
1194
+ _ADAPTIVE_FAMILIES = ("opus-4-6", "opus-4-7", "opus-4-8", "opus-5", "sonnet-4-6", "sonnet-5", "haiku-5", "fable-5")
1195
+ _XHIGH_FAMILIES = ("opus-4-7", "opus-4-8", "opus-5", "sonnet-5", "haiku-5", "fable-5")
1196
+
1197
+
1198
+ def _supports_adaptive_thinking(model_id: str, model_name: str | None = None) -> bool:
1199
+ """Opus 4.6+, Sonnet 4.6 and Claude 5 models."""
1200
+ return _matches_any(_model_match_candidates(model_id, model_name), _ADAPTIVE_FAMILIES)
1201
+
1202
+
1203
+ def _supports_native_xhigh_effort(model: Model) -> bool:
1204
+ return _matches_any(_model_match_candidates(model.id, model.name), _XHIGH_FAMILIES)
1205
+
1206
+
1207
+ def _supports_thinking_block_binding(model: Model) -> bool:
1208
+ """Opus 4.6 and Sonnet 4.6 reject `thinking.block_binding` ("Extra inputs are not permitted")."""
1209
+ return _matches_any(_model_match_candidates(model.id, model.name), _XHIGH_FAMILIES)
1210
+
1211
+
1212
+ def _map_thinking_level_to_effort(model: Model, level: str | None) -> str:
1213
+ if level == "xhigh" and _supports_native_xhigh_effort(model):
1214
+ return "xhigh"
1215
+ mapped = (model.thinking_level_map or {}).get(level) if level else None
1216
+ if isinstance(mapped, str):
1217
+ return mapped
1218
+ if level in ("minimal", "low"):
1219
+ return "low"
1220
+ if level == "medium":
1221
+ return "medium"
1222
+ return "high"
1223
+
1224
+
1225
+ def _resolve_cache_retention(cache_retention: str | None, env: Mapping[str, str] | None) -> str:
1226
+ """Defaults to "short"; PI_CACHE_RETENTION=long is honored for backward compatibility."""
1227
+ if cache_retention:
1228
+ return cache_retention
1229
+ if get_provider_env_value("PI_CACHE_RETENTION", env) == "long":
1230
+ return "long"
1231
+ return "short"
1232
+
1233
+
1234
+ def _is_anthropic_claude_model(model: Model) -> bool:
1235
+ model_id = model.id.lower()
1236
+ name = (model.name or "").lower()
1237
+ return (
1238
+ "anthropic.claude" in model_id
1239
+ or "anthropic/claude" in model_id
1240
+ or "anthropic.claude" in name
1241
+ or "anthropic/claude" in name
1242
+ or "claude" in name
1243
+ )
1244
+
1245
+
1246
+ def _supports_prompt_caching(model: Model, env: Mapping[str, str] | None) -> bool:
1247
+ """Claude 3.5 Haiku, 3.7 Sonnet, 4.x and 5 models.
1248
+
1249
+ Application inference profiles may only carry the model in ``model.name``; as a last
1250
+ resort AWS_BEDROCK_FORCE_CACHE=1 enables cache points. Nova caches automatically.
1251
+ """
1252
+ candidates = _model_match_candidates(model.id, model.name)
1253
+ if not _matches_any(candidates, ("claude",)):
1254
+ return get_provider_env_value("AWS_BEDROCK_FORCE_CACHE", env) == "1"
1255
+ if _matches_any(candidates, ("fable-5", "opus-5", "sonnet-5", "haiku-5")):
1256
+ return True
1257
+ return _matches_any(candidates, ("-4-", "claude-3-7-sonnet", "claude-3-5-haiku"))
1258
+
1259
+
1260
+ def _supports_thinking_signature(model: Model) -> bool:
1261
+ """Only Anthropic models accept `reasoningContent.reasoningText.signature`."""
1262
+ return _is_anthropic_claude_model(model)
1263
+
1264
+
1265
+ def _is_gov_cloud_target(model: Model, options: Mapping[str, Any]) -> bool:
1266
+ region = _configured_region(options)
1267
+ if region and region.lower().startswith("us-gov-"):
1268
+ return True
1269
+ model_id = model.id.lower()
1270
+ return model_id.startswith("us-gov.") or model_id.startswith("arn:aws-us-gov:")
1271
+
1272
+
1273
+ _DEFAULT_THINKING_BUDGETS: dict[str, int] = {
1274
+ "minimal": 1024,
1275
+ "low": 2048,
1276
+ "medium": 8192,
1277
+ "high": 16384,
1278
+ "xhigh": 16384, # Budget-based Claude clamps extended levels to high
1279
+ "max": 16384,
1280
+ }
1281
+ #: OpenAI GPT models (GPT-5.x, GPT-6) take a nested `reasoning.effort` and reject `minimal`.
1282
+ _OPENAI_GPT_EFFORT = {
1283
+ "minimal": "low",
1284
+ "low": "low",
1285
+ "medium": "medium",
1286
+ "high": "high",
1287
+ "xhigh": "xhigh",
1288
+ "max": "max",
1289
+ }
1290
+ #: gpt-oss takes a flat `reasoning_effort` and only accepts low, medium and high.
1291
+ _OPENAI_GPT_OSS_EFFORT = {
1292
+ "minimal": "low",
1293
+ "low": "low",
1294
+ "medium": "medium",
1295
+ "high": "high",
1296
+ "xhigh": "high",
1297
+ "max": "high",
1298
+ }
1299
+
1300
+
1301
+ def _build_additional_model_request_fields(model: Model, options: Mapping[str, Any]) -> dict[str, Any] | None:
1302
+ reasoning = options.get("reasoning")
1303
+ if not reasoning or not model.reasoning:
1304
+ return None
1305
+
1306
+ if _is_anthropic_claude_model(model):
1307
+ # GovCloud Bedrock rejects the Claude thinking.display field; omit it there.
1308
+ gov_cloud = _is_gov_cloud_target(model, options)
1309
+ display = None
1310
+ if not gov_cloud:
1311
+ display = options.get("thinking_display")
1312
+ if display is None:
1313
+ display = "summarized"
1314
+ # Replayed signed thinking blocks are bound to the system prompt and tools they were created
1315
+ # with. Bedrock 400s on replay after either changes unless stale blocks are dropped, matching
1316
+ # the Anthropic provider. Skipped on GovCloud like display.
1317
+ use_block_binding = not gov_cloud and _supports_thinking_block_binding(model)
1318
+ adaptive = _supports_adaptive_thinking(model.id, model.name)
1319
+ result: dict[str, Any]
1320
+ if adaptive:
1321
+ thinking: dict[str, Any] = {"type": "adaptive"}
1322
+ if display is not None:
1323
+ thinking["display"] = display
1324
+ if use_block_binding:
1325
+ thinking["block_binding"] = {"prefix_mismatch_behavior": "drop_block"}
1326
+ result = {
1327
+ "thinking": thinking,
1328
+ "output_config": {"effort": _map_thinking_level_to_effort(model, reasoning)},
1329
+ }
1330
+ if use_block_binding:
1331
+ result["anthropic_beta"] = [THINKING_BINDING_CONTROLS_BETA]
1332
+ else:
1333
+ # Custom budgets only cover token-based levels through high.
1334
+ level = "high" if reasoning in ("xhigh", "max") else reasoning
1335
+ budget = (options.get("thinking_budgets") or {}).get(level)
1336
+ if budget is None:
1337
+ budget = _DEFAULT_THINKING_BUDGETS.get(reasoning)
1338
+ thinking = {"type": "enabled"}
1339
+ if budget is not None:
1340
+ thinking["budget_tokens"] = budget
1341
+ if display is not None:
1342
+ thinking["display"] = display
1343
+ result = {"thinking": thinking}
1344
+ interleaved = options.get("interleaved_thinking")
1345
+ if not adaptive and (interleaved is None or interleaved):
1346
+ result["anthropic_beta"] = ["interleaved-thinking-2025-05-14"]
1347
+ return result
1348
+
1349
+ candidates = _model_match_candidates(model.id, model.name)
1350
+ if _matches_any(candidates, ("gpt-oss",)):
1351
+ return {"reasoning_effort": _OPENAI_GPT_OSS_EFFORT.get(reasoning)}
1352
+ if _matches_any(candidates, ("gpt-",)):
1353
+ mapped = (model.thinking_level_map or {}).get(reasoning)
1354
+ return {"reasoning": {"effort": mapped if isinstance(mapped, str) else _OPENAI_GPT_EFFORT.get(reasoning)}}
1355
+ return None
1356
+
1357
+
1358
+ # ---------------------------------------------------------------------------
1359
+ # Request conversion
1360
+ # ---------------------------------------------------------------------------
1361
+
1362
+
1363
+ def _cache_point(cache_retention: str) -> dict[str, Any]:
1364
+ point: dict[str, Any] = {"type": "default"}
1365
+ if cache_retention == "long":
1366
+ point["ttl"] = "1h"
1367
+ return {"cachePoint": point}
1368
+
1369
+
1370
+ def _build_system_prompt(
1371
+ system_prompt: str | None, model: Model, cache_retention: str, env: Mapping[str, str] | None
1372
+ ) -> list[dict[str, Any]] | None:
1373
+ if not system_prompt:
1374
+ return None
1375
+ blocks: list[dict[str, Any]] = [{"text": sanitize_surrogates(system_prompt)}]
1376
+ if cache_retention != "none" and _supports_prompt_caching(model, env):
1377
+ blocks.append(_cache_point(cache_retention))
1378
+ return blocks
1379
+
1380
+
1381
+ _TOOL_CALL_ID_INVALID = re.compile(r"[^a-zA-Z0-9_-]")
1382
+
1383
+
1384
+ def _normalize_tool_call_id(id_: str) -> str:
1385
+ return _TOOL_CALL_ID_INVALID.sub("_", id_)[:64]
1386
+
1387
+
1388
+ def _non_blank_text_block(text: str) -> dict[str, Any] | None:
1389
+ sanitized = sanitize_surrogates(text)
1390
+ return None if not sanitized.strip() else {"text": sanitized}
1391
+
1392
+
1393
+ def _required_text_block(text: str) -> dict[str, Any]:
1394
+ return _non_blank_text_block(text) or {"text": EMPTY_TEXT_PLACEHOLDER}
1395
+
1396
+
1397
+ def _sanitize_bedrock_document(value: Any) -> Any:
1398
+ """Copy a JSON document without empty property names, which Bedrock rejects."""
1399
+ if isinstance(value, (list, tuple)):
1400
+ return [_sanitize_bedrock_document(item) for item in value]
1401
+ if isinstance(value, Mapping):
1402
+ return {key: _sanitize_bedrock_document(entry) for key, entry in value.items() if len(key) > 0}
1403
+ return value
1404
+
1405
+
1406
+ _IMAGE_FORMATS = {
1407
+ "image/jpeg": "jpeg",
1408
+ "image/jpg": "jpeg",
1409
+ "image/png": "png",
1410
+ "image/gif": "gif",
1411
+ "image/webp": "webp",
1412
+ }
1413
+
1414
+
1415
+ _BASE64_WHITESPACE = re.compile(r"[\t\n\f\r ]+")
1416
+ _BASE64_INVALID = re.compile(r"[^A-Za-z0-9+/]")
1417
+
1418
+
1419
+ def _b64decode_forgiving(data: str) -> bytes:
1420
+ """Base64 decode like the browser ``atob``: whitespace and missing padding are tolerated.
1421
+
1422
+ Raises ``ValueError`` for characters outside the base64 alphabet.
1423
+ """
1424
+ text = _BASE64_WHITESPACE.sub("", data)
1425
+ if len(text) % 4 == 0 and text.endswith("="):
1426
+ text = text[:-2] if text.endswith("==") else text[:-1]
1427
+ if len(text) % 4 == 1 or _BASE64_INVALID.search(text):
1428
+ raise ValueError("Invalid base64 data")
1429
+ return base64.b64decode(text + "=" * (-len(text) % 4))
1430
+
1431
+
1432
+ def _image_block(mime_type: str, data: str) -> dict[str, Any]:
1433
+ image_format = _IMAGE_FORMATS.get(mime_type)
1434
+ if image_format is None:
1435
+ raise ValueError(f"Unknown image type: {mime_type}")
1436
+ return {"source": {"bytes": _b64decode_forgiving(data)}, "format": image_format}
1437
+
1438
+
1439
+ def _decode_redacted_content(signature: str | None) -> bytes | None:
1440
+ """A persisted session carries the blob as base64; drop a block whose signature is not base64."""
1441
+ if not signature:
1442
+ return None
1443
+ try:
1444
+ return _b64decode_forgiving(signature)
1445
+ except ValueError:
1446
+ return None
1447
+
1448
+
1449
+ def _tool_result_content(content: list[Any]) -> list[dict[str, Any]]:
1450
+ result: list[dict[str, Any]] = []
1451
+ for block in content:
1452
+ kind = getattr(block, "type", None)
1453
+ if kind == "image":
1454
+ result.append({"image": _image_block(block.mime_type, block.data)})
1455
+ elif kind == "text":
1456
+ text_block = _non_blank_text_block(block.text)
1457
+ if text_block is not None:
1458
+ result.append(text_block)
1459
+ if not result:
1460
+ result.append({"text": EMPTY_TEXT_PLACEHOLDER})
1461
+ return result
1462
+
1463
+
1464
+ def _convert_assistant_content(message: Any, model: Model) -> list[dict[str, Any]]:
1465
+ blocks: list[dict[str, Any]] = []
1466
+ for block in message.content:
1467
+ kind = getattr(block, "type", None)
1468
+ if kind == "text":
1469
+ text_block = _non_blank_text_block(block.text)
1470
+ if text_block is not None:
1471
+ blocks.append(text_block)
1472
+ elif kind == "toolCall":
1473
+ blocks.append(
1474
+ {
1475
+ "toolUse": {
1476
+ "toolUseId": block.id,
1477
+ "name": block.name,
1478
+ "input": _sanitize_bedrock_document(block.arguments),
1479
+ }
1480
+ }
1481
+ )
1482
+ elif kind == "thinking":
1483
+ # Encrypted reasoning is opaque: replay the stored payload as the `redactedContent`
1484
+ # member instead of lowering it to reasoning text.
1485
+ if block.redacted:
1486
+ redacted = _decode_redacted_content(block.thinking_signature)
1487
+ if redacted:
1488
+ blocks.append({"reasoningContent": {"redactedContent": redacted}})
1489
+ continue
1490
+ thinking = sanitize_surrogates(block.thinking)
1491
+ if not thinking.strip():
1492
+ continue
1493
+ if _supports_thinking_signature(model):
1494
+ # Signatures arrive after thinking deltas. Bedrock rejects a replayed reasoning block
1495
+ # without one, so fall back to plain text, matching Anthropic.
1496
+ signature = block.thinking_signature
1497
+ if not signature or not signature.strip():
1498
+ blocks.append({"text": thinking})
1499
+ else:
1500
+ blocks.append({"reasoningContent": {"reasoningText": {"text": thinking, "signature": signature}}})
1501
+ else:
1502
+ # Other models reject the signature field ("doesn't support ... reasoningText.signature").
1503
+ blocks.append({"reasoningContent": {"reasoningText": {"text": thinking}}})
1504
+ return blocks
1505
+
1506
+
1507
+ def _convert_messages(
1508
+ context: TranscriptContext, model: Model, cache_retention: str, env: Mapping[str, str] | None
1509
+ ) -> list[dict[str, Any]]:
1510
+ result: list[dict[str, Any]] = []
1511
+ messages: list[Any] = transform_messages(
1512
+ without_initial_system_message(context.messages),
1513
+ model,
1514
+ lambda id_, _model, _message: _normalize_tool_call_id(id_),
1515
+ )
1516
+ claude = _is_anthropic_claude_model(model)
1517
+ index = 0
1518
+ count = len(messages)
1519
+ while index < count:
1520
+ message = messages[index]
1521
+ role = message.role
1522
+ if role == "user":
1523
+ content: list[dict[str, Any]] = []
1524
+ if isinstance(message.content, str):
1525
+ content.append(_required_text_block(message.content))
1526
+ else:
1527
+ for block in message.content:
1528
+ kind = getattr(block, "type", None)
1529
+ if kind == "text":
1530
+ text_block = _non_blank_text_block(block.text)
1531
+ if text_block is not None:
1532
+ content.append(text_block)
1533
+ elif kind == "image":
1534
+ content.append({"image": _image_block(block.mime_type, block.data)})
1535
+ if not content:
1536
+ content.append({"text": EMPTY_TEXT_PLACEHOLDER})
1537
+ result.append({"role": "user", "content": content})
1538
+ elif role == "assistant":
1539
+ # Bedrock rejects messages with empty content arrays (e.g. from aborted requests).
1540
+ if message.content:
1541
+ blocks = _convert_assistant_content(message, model)
1542
+ if blocks:
1543
+ result.append({"role": "assistant", "content": blocks})
1544
+ elif role == "toolResult":
1545
+ # Bedrock requires all consecutive tool results in one user message. Claude rejects
1546
+ # error tool results with anything but text, so their images follow the results (#10794).
1547
+ tool_results: list[dict[str, Any]] = []
1548
+ error_images: list[dict[str, Any]] = []
1549
+ while index < count and messages[index].role == "toolResult":
1550
+ tool_message = messages[index]
1551
+ split = split_error_tool_result_images(tool_message) if claude else None
1552
+ tool_results.append(
1553
+ {
1554
+ "toolResult": {
1555
+ "toolUseId": tool_message.tool_call_id,
1556
+ "content": _tool_result_content(split.content if split else tool_message.content),
1557
+ "status": "error" if tool_message.is_error else "success",
1558
+ }
1559
+ }
1560
+ )
1561
+ if split is not None:
1562
+ error_images.append({"text": split.caption})
1563
+ error_images.extend({"image": _image_block(image.mime_type, image.data)} for image in split.images)
1564
+ index += 1
1565
+ index -= 1
1566
+ result.append({"role": "user", "content": [*tool_results, *error_images]})
1567
+ index += 1
1568
+
1569
+ # Cache point on the last user message for supported Claude models.
1570
+ if cache_retention != "none" and _supports_prompt_caching(model, env) and result:
1571
+ last = result[-1]
1572
+ if last["role"] == "user":
1573
+ last["content"].append(_cache_point(cache_retention))
1574
+ return result
1575
+
1576
+
1577
+ def _convert_tool_config(tools: list[Tool], tool_choice: Any, supports_strict_mode: bool) -> dict[str, Any] | None:
1578
+ if not tools or tool_choice == "none":
1579
+ return None
1580
+ bedrock_tools: list[dict[str, Any]] = []
1581
+ for tool in tools:
1582
+ strict = resolve_json_schema_strict_sampling(tool, supports_strict_mode)
1583
+ spec: dict[str, Any] = {
1584
+ "name": tool.name,
1585
+ "description": tool.description,
1586
+ "inputSchema": {"json": get_json_schema_tool_parameters(tool, strict)},
1587
+ }
1588
+ if strict is True:
1589
+ spec["strict"] = True
1590
+ bedrock_tools.append({"toolSpec": spec})
1591
+ config: dict[str, Any] = {"tools": bedrock_tools}
1592
+ if tool_choice == "auto":
1593
+ config["toolChoice"] = {"auto": {}}
1594
+ elif tool_choice == "any":
1595
+ config["toolChoice"] = {"any": {}}
1596
+ elif isinstance(tool_choice, Mapping) and tool_choice.get("type") == "tool":
1597
+ config["toolChoice"] = {"tool": {"name": tool_choice["name"]}}
1598
+ return config