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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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
|