thinwrap-llm 1.1.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.
- thinwrap/llm/__init__.py +51 -0
- thinwrap/llm/_anthropic.py +526 -0
- thinwrap/llm/_awssigv4.py +95 -0
- thinwrap/llm/_bedrock.py +454 -0
- thinwrap/llm/_gemini.py +590 -0
- thinwrap/llm/_jsonpath.py +43 -0
- thinwrap/llm/_openai_compat.py +345 -0
- thinwrap/llm/_openai_compat_embeddings.py +124 -0
- thinwrap/llm/_util.py +30 -0
- thinwrap/llm/base.py +265 -0
- thinwrap/llm/chat.py +200 -0
- thinwrap/llm/config.py +54 -0
- thinwrap/llm/embeddings.py +57 -0
- thinwrap/llm/enums.py +28 -0
- thinwrap/llm/errors.py +188 -0
- thinwrap/llm/passthrough.py +30 -0
- thinwrap/llm/py.typed +0 -0
- thinwrap/llm/spec.py +66 -0
- thinwrap/llm/sse.py +53 -0
- thinwrap/llm/transport.py +280 -0
- thinwrap_llm-1.1.0.dist-info/METADATA +181 -0
- thinwrap_llm-1.1.0.dist-info/RECORD +25 -0
- thinwrap_llm-1.1.0.dist-info/WHEEL +5 -0
- thinwrap_llm-1.1.0.dist-info/licenses/LICENSE +21 -0
- thinwrap_llm-1.1.0.dist-info/top_level.txt +1 -0
thinwrap/llm/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""thinwrap.llm — unified, SDK-free, zero-dependency Python wrapper for chat
|
|
2
|
+
completions (with streaming) and embeddings across 18 LLM providers behind one
|
|
3
|
+
normalized surface.
|
|
4
|
+
|
|
5
|
+
Switching vendor is a change of provider id + model only::
|
|
6
|
+
|
|
7
|
+
from thinwrap.llm import Chat, ChatInput, ChatMessage, OpenAICompatConfig, ProviderID
|
|
8
|
+
|
|
9
|
+
chat = Chat(ProviderID.OPENAI, OpenAICompatConfig(api_key="..."))
|
|
10
|
+
res = chat.complete(ChatInput(model="gpt-4o-mini",
|
|
11
|
+
messages=[ChatMessage(role="user", content="Hi")]))
|
|
12
|
+
print(res.message.content, res.usage.total_tokens)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .chat import (
|
|
18
|
+
AssistantMessage,
|
|
19
|
+
Chat,
|
|
20
|
+
ChatInput,
|
|
21
|
+
ChatMessage,
|
|
22
|
+
ChatResult,
|
|
23
|
+
ChatStreamDelta,
|
|
24
|
+
ContentPart,
|
|
25
|
+
ReasoningOptions,
|
|
26
|
+
ResponseFormat,
|
|
27
|
+
ToolCall,
|
|
28
|
+
ToolCallDelta,
|
|
29
|
+
ToolChoice,
|
|
30
|
+
ToolDefinition,
|
|
31
|
+
Usage,
|
|
32
|
+
image_part,
|
|
33
|
+
text_part,
|
|
34
|
+
)
|
|
35
|
+
from .config import AnthropicConfig, BedrockConfig, GeminiConfig, OpenAICompatConfig
|
|
36
|
+
from .embeddings import Embeddings, EmbeddingsInput, EmbeddingsResult, EmbeddingsUsage
|
|
37
|
+
from .enums import ProviderID
|
|
38
|
+
from .errors import ConnectorError, ProviderCode
|
|
39
|
+
from .passthrough import Passthrough
|
|
40
|
+
from .transport import HttpRequest, HttpResponse, Transport, UrllibTransport
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Chat", "Embeddings",
|
|
44
|
+
"ChatInput", "ChatMessage", "ChatResult", "ChatStreamDelta", "ContentPart",
|
|
45
|
+
"ToolCall", "ToolCallDelta", "ToolDefinition", "ToolChoice", "ResponseFormat",
|
|
46
|
+
"ReasoningOptions", "Usage", "AssistantMessage", "text_part", "image_part",
|
|
47
|
+
"EmbeddingsInput", "EmbeddingsResult", "EmbeddingsUsage",
|
|
48
|
+
"OpenAICompatConfig", "AnthropicConfig", "BedrockConfig", "GeminiConfig",
|
|
49
|
+
"ProviderID", "ConnectorError", "ProviderCode", "Passthrough",
|
|
50
|
+
"Transport", "UrllibTransport", "HttpRequest", "HttpResponse",
|
|
51
|
+
]
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Native adapter for the Anthropic Messages API (POST <base_url>/messages).
|
|
2
|
+
|
|
3
|
+
Anthropic is structurally different from OpenAI Chat Completions — different
|
|
4
|
+
auth (x-api-key + anthropic-version), a top-level ``system``, typed content
|
|
5
|
+
blocks, tool results carried inside a user turn, ``stop_sequences``, and
|
|
6
|
+
Anthropic-shaped SSE events — so this is a full translation layer, not an
|
|
7
|
+
OpenAI passthrough. It emits the identical normalized ChatResult /
|
|
8
|
+
ChatStreamDelta / ConnectorError as every other connector, so it stays drop-in
|
|
9
|
+
swappable via the Chat facade.
|
|
10
|
+
|
|
11
|
+
Not normalized here (surfaced via ``raw`` or ``passthrough``, never emulated):
|
|
12
|
+
response_format (Anthropic has none — use tool-based structured output via
|
|
13
|
+
passthrough); reasoning CoT output (thinking blocks land in ``raw``, and verbatim
|
|
14
|
+
in ``provider_metadata["anthropic"]["content"]`` for replay); prompt caching
|
|
15
|
+
(cache_control via passthrough; cache read/write tokens are counted in
|
|
16
|
+
``usage.input_tokens``, the breakdown stays in ``raw``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import copy
|
|
22
|
+
import json
|
|
23
|
+
from typing import Any, Iterator, Optional
|
|
24
|
+
|
|
25
|
+
from ._jsonpath import extract_error_message, jget, jlist, jnum, jstr
|
|
26
|
+
from ._util import decode_json, ok_status
|
|
27
|
+
from .base import BaseConnector
|
|
28
|
+
from .chat import (
|
|
29
|
+
AssistantMessage,
|
|
30
|
+
ChatInput,
|
|
31
|
+
ChatMessage,
|
|
32
|
+
ChatResult,
|
|
33
|
+
ChatStreamDelta,
|
|
34
|
+
ToolCall,
|
|
35
|
+
ToolCallDelta,
|
|
36
|
+
Usage,
|
|
37
|
+
)
|
|
38
|
+
from .errors import ProviderCode, empty_body_error, invalid_request, vendor_error
|
|
39
|
+
from .passthrough import apply_body, merge_headers
|
|
40
|
+
|
|
41
|
+
DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
|
42
|
+
DEFAULT_VERSION = "2023-06-01"
|
|
43
|
+
# Used when the caller sets no max_tokens (Anthropic requires one). Current
|
|
44
|
+
# models always think and default to medium effort, so a smaller ceiling often
|
|
45
|
+
# ends at `length` before any text; max_tokens caps output, it is not billed as
|
|
46
|
+
# such. 16000 is Anthropic's recommended non-streaming default.
|
|
47
|
+
DEFAULT_MAX_TOKENS = 16000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AnthropicConnector(BaseConnector):
|
|
51
|
+
def __init__(self, config, transport=None) -> None:
|
|
52
|
+
super().__init__(transport)
|
|
53
|
+
from .config import AnthropicConfig
|
|
54
|
+
|
|
55
|
+
if not isinstance(config, AnthropicConfig):
|
|
56
|
+
raise invalid_request("anthropic requires an AnthropicConfig")
|
|
57
|
+
self.cfg = config
|
|
58
|
+
self.base_url = (config.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
59
|
+
|
|
60
|
+
def complete(self, msg: ChatInput) -> ChatResult:
|
|
61
|
+
url, body, headers = self._build_request(msg, stream=False)
|
|
62
|
+
resp = self.send_post_json(url, body, headers)
|
|
63
|
+
raw = decode_json(resp.body)
|
|
64
|
+
if not ok_status(resp.status):
|
|
65
|
+
raise self._error(resp.status, resp.headers, raw)
|
|
66
|
+
# A Messages response always carries a `content` array; without it this
|
|
67
|
+
# is not a result, and parsing it would fake an empty completion.
|
|
68
|
+
if raw is None or jlist(jget(raw, "content")) is None:
|
|
69
|
+
raise empty_body_error(resp.status)
|
|
70
|
+
return self._parse_result(raw, msg.model)
|
|
71
|
+
|
|
72
|
+
def stream(self, msg: ChatInput) -> Iterator[ChatStreamDelta]:
|
|
73
|
+
url, body, headers = self._build_request(msg, stream=True)
|
|
74
|
+
stream = self.open_post_stream(url, body, headers)
|
|
75
|
+
if not ok_status(stream.status):
|
|
76
|
+
raw = self._read_error_body(stream)
|
|
77
|
+
raise self._error(stream.status, stream.headers, raw)
|
|
78
|
+
self._require_event_stream(stream)
|
|
79
|
+
return self._iter_stream(stream)
|
|
80
|
+
|
|
81
|
+
def _iter_stream(self, stream) -> Iterator[ChatStreamDelta]:
|
|
82
|
+
# Usage is merged across events field by field, as the official SDK's
|
|
83
|
+
# MessageStream does: message_start reports the counts, and message_delta
|
|
84
|
+
# restates some of them cumulatively; a field it omits or sends as null
|
|
85
|
+
# keeps the earlier value.
|
|
86
|
+
usage_fields: dict[str, float] = {}
|
|
87
|
+
# The response's content blocks, rebuilt from the events in block order
|
|
88
|
+
# so the terminal delta can carry them for verbatim replay (the same
|
|
89
|
+
# array a non-stream call returns).
|
|
90
|
+
blocks: dict[int, dict[str, Any]] = {}
|
|
91
|
+
partial_json: dict[int, str] = {}
|
|
92
|
+
# A complete stream reports its stop_reason on message_delta and closes
|
|
93
|
+
# with message_stop; without either the answer was cut off.
|
|
94
|
+
saw_stop = False
|
|
95
|
+
# Anthropic indexes CONTENT BLOCKS (text + tool_use interleaved), so the
|
|
96
|
+
# first tool call can arrive at block index 1. Normalize to a 0-based
|
|
97
|
+
# tool-call-relative index (matching the OpenAI-compat surface and the other
|
|
98
|
+
# languages); map each block index to its tool ordinal so the argument
|
|
99
|
+
# continuation deltas line up.
|
|
100
|
+
next_tool_index = 0
|
|
101
|
+
tool_index_by_block: dict[int, int] = {}
|
|
102
|
+
try:
|
|
103
|
+
for payload in self._iter_sse(stream):
|
|
104
|
+
try:
|
|
105
|
+
evt = json.loads(payload)
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
continue
|
|
108
|
+
typ = jstr(jget(evt, "type"))
|
|
109
|
+
# An `event: error` frame after 200 OK must surface, not truncate.
|
|
110
|
+
# The HTTP status is 200, so classify from the error frame itself.
|
|
111
|
+
if typ == "error":
|
|
112
|
+
raise self._error(stream.status, stream.headers, evt, _stream_error_code(evt))
|
|
113
|
+
if typ == "message_start":
|
|
114
|
+
_merge_usage(usage_fields, jget(jget(evt, "message"), "usage"))
|
|
115
|
+
continue
|
|
116
|
+
if typ == "content_block_start":
|
|
117
|
+
cb = jget(evt, "content_block")
|
|
118
|
+
block = int(jnum(jget(evt, "index")))
|
|
119
|
+
if isinstance(cb, dict):
|
|
120
|
+
blocks[block] = copy.deepcopy(cb)
|
|
121
|
+
if "input" in cb or jstr(cb.get("type")).endswith("tool_use"):
|
|
122
|
+
# The input arrives as input_json_delta fragments.
|
|
123
|
+
partial_json[block] = ""
|
|
124
|
+
if jstr(jget(cb, "type")) == "tool_use":
|
|
125
|
+
tool_index_by_block[block] = next_tool_index
|
|
126
|
+
yield ChatStreamDelta(
|
|
127
|
+
tool_call_delta=ToolCallDelta(
|
|
128
|
+
index=next_tool_index,
|
|
129
|
+
id=jstr(jget(cb, "id")),
|
|
130
|
+
function_name=jstr(jget(cb, "name")),
|
|
131
|
+
),
|
|
132
|
+
raw=evt,
|
|
133
|
+
)
|
|
134
|
+
next_tool_index += 1
|
|
135
|
+
continue
|
|
136
|
+
if typ == "content_block_delta":
|
|
137
|
+
d = jget(evt, "delta")
|
|
138
|
+
dtype = jstr(jget(d, "type"))
|
|
139
|
+
block = int(jnum(jget(evt, "index")))
|
|
140
|
+
_accumulate_block(blocks.get(block), partial_json, block, d)
|
|
141
|
+
if dtype == "text_delta":
|
|
142
|
+
s = jstr(jget(d, "text"))
|
|
143
|
+
if s:
|
|
144
|
+
yield ChatStreamDelta(content_delta=s, raw=evt)
|
|
145
|
+
elif dtype == "input_json_delta":
|
|
146
|
+
pj = jget(d, "partial_json")
|
|
147
|
+
# Server-tool blocks (server_tool_use) stream their input
|
|
148
|
+
# the same way but are not caller tool calls.
|
|
149
|
+
if isinstance(pj, str) and block in tool_index_by_block:
|
|
150
|
+
yield ChatStreamDelta(
|
|
151
|
+
tool_call_delta=ToolCallDelta(index=tool_index_by_block[block], arguments_delta=pj),
|
|
152
|
+
raw=evt,
|
|
153
|
+
)
|
|
154
|
+
# thinking_delta → CoT output not normalized; available via
|
|
155
|
+
# raw and the terminal provider_metadata.
|
|
156
|
+
continue
|
|
157
|
+
if typ == "message_delta":
|
|
158
|
+
out = ChatStreamDelta(raw=evt)
|
|
159
|
+
sr = jstr(jget(jget(evt, "delta"), "stop_reason"))
|
|
160
|
+
_merge_usage(usage_fields, jget(evt, "usage"))
|
|
161
|
+
in_tok = _input_tokens(usage_fields)
|
|
162
|
+
out_tok = int(usage_fields.get("output_tokens", 0))
|
|
163
|
+
out.usage = Usage(input_tokens=in_tok, output_tokens=out_tok, total_tokens=in_tok + out_tok)
|
|
164
|
+
if sr:
|
|
165
|
+
out.finish_reason = _map_stop_reason(sr)
|
|
166
|
+
saw_stop = True
|
|
167
|
+
# Only the terminal delta carries the turn's blocks.
|
|
168
|
+
out.provider_metadata = {"anthropic": {"content": _finish_blocks(blocks, partial_json)}}
|
|
169
|
+
yield out
|
|
170
|
+
continue
|
|
171
|
+
if typ == "message_stop":
|
|
172
|
+
saw_stop = True
|
|
173
|
+
# content_block_stop / ping → ignored
|
|
174
|
+
if not saw_stop:
|
|
175
|
+
raise self._stream_ended_early_error()
|
|
176
|
+
finally:
|
|
177
|
+
stream.close()
|
|
178
|
+
|
|
179
|
+
def _auth_headers(self, msg: ChatInput) -> dict[str, str]:
|
|
180
|
+
version = self.cfg.anthropic_version or DEFAULT_VERSION
|
|
181
|
+
headers: dict[str, str] = {"x-api-key": self.cfg.api_key, "anthropic-version": version}
|
|
182
|
+
if self.cfg.headers:
|
|
183
|
+
headers.update(self.cfg.headers)
|
|
184
|
+
return merge_headers(headers, msg.passthrough)
|
|
185
|
+
|
|
186
|
+
def _build_request(self, msg: ChatInput, stream: bool):
|
|
187
|
+
system, messages = _split_messages(msg.messages)
|
|
188
|
+
|
|
189
|
+
max_tokens = DEFAULT_MAX_TOKENS
|
|
190
|
+
if self.cfg.default_max_tokens > 0:
|
|
191
|
+
max_tokens = self.cfg.default_max_tokens
|
|
192
|
+
if msg.max_output_tokens is not None:
|
|
193
|
+
max_tokens = msg.max_output_tokens
|
|
194
|
+
|
|
195
|
+
body: dict[str, Any] = {"model": msg.model, "max_tokens": max_tokens, "messages": messages}
|
|
196
|
+
if system:
|
|
197
|
+
body["system"] = system
|
|
198
|
+
|
|
199
|
+
if msg.tools:
|
|
200
|
+
tools = []
|
|
201
|
+
for t in msg.tools:
|
|
202
|
+
tool: dict[str, Any] = {"name": t.name}
|
|
203
|
+
if t.description:
|
|
204
|
+
tool["description"] = t.description
|
|
205
|
+
tool["input_schema"] = t.parameters if t.parameters is not None else {"type": "object", "properties": {}}
|
|
206
|
+
tools.append(tool)
|
|
207
|
+
body["tools"] = tools
|
|
208
|
+
if msg.tool_choice is not None:
|
|
209
|
+
tc = _map_tool_choice(msg.tool_choice)
|
|
210
|
+
if tc is not None:
|
|
211
|
+
body["tool_choice"] = tc
|
|
212
|
+
|
|
213
|
+
if msg.reasoning is not None:
|
|
214
|
+
# Adaptive thinking is the current-generation control: the model sizes
|
|
215
|
+
# its own thinking from the effort, so there is no budget to fit under
|
|
216
|
+
# max_tokens. Claude 4.5 and older reject it with a 400; those models
|
|
217
|
+
# take budget thinking via passthrough. Thinking also rules out
|
|
218
|
+
# custom sampling, so temperature/top_p are omitted.
|
|
219
|
+
body["thinking"] = {"type": "adaptive"}
|
|
220
|
+
# An empty effort means "unspecified", so the documented default
|
|
221
|
+
# applies; any other value is sent as given for Anthropic to validate.
|
|
222
|
+
body["output_config"] = {"effort": msg.reasoning.effort or "medium"}
|
|
223
|
+
else:
|
|
224
|
+
if msg.temperature is not None:
|
|
225
|
+
body["temperature"] = msg.temperature
|
|
226
|
+
if msg.top_p is not None:
|
|
227
|
+
body["top_p"] = msg.top_p
|
|
228
|
+
if msg.stop:
|
|
229
|
+
body["stop_sequences"] = list(msg.stop)
|
|
230
|
+
# NOTE: response_format is intentionally NOT mapped — Anthropic has no
|
|
231
|
+
# response_format; structured output is via tools / passthrough.
|
|
232
|
+
|
|
233
|
+
if stream:
|
|
234
|
+
body["stream"] = True
|
|
235
|
+
apply_body(body, msg.passthrough)
|
|
236
|
+
|
|
237
|
+
url = self.base_url + "/messages"
|
|
238
|
+
if msg.passthrough and msg.passthrough.query:
|
|
239
|
+
from urllib.parse import urlencode
|
|
240
|
+
|
|
241
|
+
url += "?" + urlencode(dict(msg.passthrough.query))
|
|
242
|
+
return url, body, self._auth_headers(msg)
|
|
243
|
+
|
|
244
|
+
def _parse_result(self, raw: Any, requested_model: str) -> ChatResult:
|
|
245
|
+
text_parts: list[str] = []
|
|
246
|
+
tool_calls: list[ToolCall] = []
|
|
247
|
+
content = jlist(jget(raw, "content")) or []
|
|
248
|
+
for b in content:
|
|
249
|
+
btype = jstr(jget(b, "type"))
|
|
250
|
+
if btype == "text":
|
|
251
|
+
text_parts.append(jstr(jget(b, "text")))
|
|
252
|
+
elif btype == "tool_use":
|
|
253
|
+
inp = jget(b, "input")
|
|
254
|
+
if inp is None:
|
|
255
|
+
inp = {}
|
|
256
|
+
tool_calls.append(
|
|
257
|
+
ToolCall(id=jstr(jget(b, "id")), name=jstr(jget(b, "name")), arguments=_dump_json(inp))
|
|
258
|
+
)
|
|
259
|
+
# thinking blocks → raw + provider_metadata only
|
|
260
|
+
usage = jget(raw, "usage")
|
|
261
|
+
in_tok = _input_tokens(usage)
|
|
262
|
+
out_tok = int(jnum(jget(usage, "output_tokens")))
|
|
263
|
+
return ChatResult(
|
|
264
|
+
message=AssistantMessage(
|
|
265
|
+
role="assistant",
|
|
266
|
+
content="".join(text_parts),
|
|
267
|
+
tool_calls=tool_calls,
|
|
268
|
+
provider_metadata={"anthropic": {"content": copy.deepcopy(content)}},
|
|
269
|
+
),
|
|
270
|
+
finish_reason=_map_stop_reason(jstr(jget(raw, "stop_reason"))),
|
|
271
|
+
usage=Usage(input_tokens=in_tok, output_tokens=out_tok, total_tokens=in_tok + out_tok),
|
|
272
|
+
model=jstr(jget(raw, "model")) or requested_model,
|
|
273
|
+
raw=raw,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _error(self, status: int, headers, raw, code: ProviderCode | None = None):
|
|
277
|
+
msg = extract_error_message(raw) or f"HTTP {status}"
|
|
278
|
+
if code is None:
|
|
279
|
+
code = _map_code(status, msg)
|
|
280
|
+
return vendor_error(status, headers, raw, code, msg)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---- module-private mappers ----
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _stream_error_code(evt: Any) -> ProviderCode:
|
|
287
|
+
"""Classify an Anthropic `error` SSE frame delivered after HTTP 200 by its
|
|
288
|
+
error.type (the HTTP status is 200 and can't classify it)."""
|
|
289
|
+
etype = jstr(jget(jget(evt, "error"), "type")).lower()
|
|
290
|
+
if etype == "rate_limit_error":
|
|
291
|
+
return ProviderCode.RATE_LIMITED
|
|
292
|
+
if etype in ("authentication_error", "permission_error"):
|
|
293
|
+
return ProviderCode.AUTH_FAILED
|
|
294
|
+
if etype == "invalid_request_error":
|
|
295
|
+
if _is_context_overflow(extract_error_message(evt)):
|
|
296
|
+
return ProviderCode.CONTEXT_LENGTH_EXCEEDED
|
|
297
|
+
return ProviderCode.INVALID_REQUEST
|
|
298
|
+
if etype == "billing_error":
|
|
299
|
+
# The streamed form of a 402; classified the same way.
|
|
300
|
+
return ProviderCode.RATE_LIMITED
|
|
301
|
+
if etype in ("not_found_error", "request_too_large", "conflict_error"):
|
|
302
|
+
return ProviderCode.INVALID_REQUEST
|
|
303
|
+
if etype in ("overloaded_error", "api_error", "timeout_error"):
|
|
304
|
+
return ProviderCode.PROVIDER_UNAVAILABLE
|
|
305
|
+
# A frame without a recognizable type says nothing classifiable.
|
|
306
|
+
return ProviderCode.UNKNOWN
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _is_context_overflow(message: str) -> bool:
|
|
310
|
+
"""Anthropic reports a context overflow as a plain invalid_request_error, so
|
|
311
|
+
only its own wording ("prompt is too long: N tokens > M maximum", "input
|
|
312
|
+
length and `max_tokens` exceed context limit") tells it apart. Looser
|
|
313
|
+
matches ("context", "max_tokens") also hit ordinary validation errors, and a
|
|
314
|
+
caller that trims history on this code would then loop forever."""
|
|
315
|
+
msg = message.lower()
|
|
316
|
+
return "prompt is too long" in msg or "exceed context limit" in msg
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _merge_usage(fields: dict[str, float], usage: Any) -> None:
|
|
320
|
+
if not isinstance(usage, dict):
|
|
321
|
+
return
|
|
322
|
+
for key in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "output_tokens"):
|
|
323
|
+
v = usage.get(key)
|
|
324
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
325
|
+
fields[key] = v
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _input_tokens(usage: Any) -> int:
|
|
329
|
+
"""Every input token billed for the request. Anthropic reports cache reads
|
|
330
|
+
and cache writes apart from ``input_tokens``; the normalized count includes
|
|
331
|
+
them so it means the same thing across providers."""
|
|
332
|
+
return int(
|
|
333
|
+
jnum(jget(usage, "input_tokens"))
|
|
334
|
+
+ jnum(jget(usage, "cache_read_input_tokens"))
|
|
335
|
+
+ jnum(jget(usage, "cache_creation_input_tokens"))
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _accumulate_block(block: Any, partial_json: dict[int, str], index: int, delta: Any) -> None:
|
|
340
|
+
if not isinstance(block, dict):
|
|
341
|
+
return
|
|
342
|
+
dtype = jstr(jget(delta, "type"))
|
|
343
|
+
if dtype == "text_delta":
|
|
344
|
+
block["text"] = jstr(block.get("text")) + jstr(jget(delta, "text"))
|
|
345
|
+
elif dtype == "thinking_delta":
|
|
346
|
+
block["thinking"] = jstr(block.get("thinking")) + jstr(jget(delta, "thinking"))
|
|
347
|
+
elif dtype == "signature_delta":
|
|
348
|
+
block["signature"] = jstr(jget(delta, "signature"))
|
|
349
|
+
elif dtype == "input_json_delta" and index in partial_json:
|
|
350
|
+
partial_json[index] += jstr(jget(delta, "partial_json"))
|
|
351
|
+
elif dtype == "citations_delta":
|
|
352
|
+
citation = jget(delta, "citation")
|
|
353
|
+
if citation is not None:
|
|
354
|
+
citations = block.get("citations")
|
|
355
|
+
if not isinstance(citations, list):
|
|
356
|
+
citations = []
|
|
357
|
+
block["citations"] = citations
|
|
358
|
+
citations.append(citation)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _finish_blocks(blocks: dict[int, dict[str, Any]], partial_json: dict[int, str]) -> list[dict[str, Any]]:
|
|
362
|
+
out: list[dict[str, Any]] = []
|
|
363
|
+
for index in sorted(blocks):
|
|
364
|
+
block = blocks[index]
|
|
365
|
+
if index in partial_json:
|
|
366
|
+
pj = partial_json[index]
|
|
367
|
+
if pj:
|
|
368
|
+
block["input"] = _safe_json_parse(pj)
|
|
369
|
+
elif not isinstance(block.get("input"), dict):
|
|
370
|
+
# No deltas followed: the input on content_block_start (if any)
|
|
371
|
+
# is the whole input.
|
|
372
|
+
block["input"] = {}
|
|
373
|
+
out.append(block)
|
|
374
|
+
return out
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _dump_json(v: Any) -> str:
|
|
378
|
+
return json.dumps(v, separators=(",", ":"), ensure_ascii=False)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _safe_json_parse(s: str) -> Any:
|
|
382
|
+
v = decode_json(s.encode("utf-8"))
|
|
383
|
+
return v if v is not None else {}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _split_messages(messages: list[ChatMessage]):
|
|
387
|
+
system = ""
|
|
388
|
+
out: list[dict[str, Any]] = []
|
|
389
|
+
# Anthropic invalidates every thinking block after a turn whose thinking was
|
|
390
|
+
# edited or stripped, and accounts that enforce preserved thinking reject
|
|
391
|
+
# replaying them with a 400. Once one assistant turn is rebuilt, every later
|
|
392
|
+
# one is rebuilt too.
|
|
393
|
+
rebuilt = False
|
|
394
|
+
for m in messages:
|
|
395
|
+
if m.role == "system":
|
|
396
|
+
text = _content_to_text(m)
|
|
397
|
+
system = system + "\n\n" + text if system else text
|
|
398
|
+
elif m.role == "tool":
|
|
399
|
+
block = {"type": "tool_result", "tool_use_id": m.tool_call_id, "content": _content_to_text(m)}
|
|
400
|
+
if out and out[-1].get("role") == "user" and _is_tool_result_content(out[-1].get("content")):
|
|
401
|
+
out[-1]["content"].append(block)
|
|
402
|
+
else:
|
|
403
|
+
out.append({"role": "user", "content": [block]})
|
|
404
|
+
else:
|
|
405
|
+
replay = None
|
|
406
|
+
if m.role == "assistant":
|
|
407
|
+
replay = None if rebuilt else _verbatim_content(m)
|
|
408
|
+
rebuilt = rebuilt or replay is None
|
|
409
|
+
out.append({"role": m.role, "content": replay if replay is not None else _map_content_blocks(m)})
|
|
410
|
+
return system, out
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _verbatim_content(m: ChatMessage) -> Optional[list[Any]]:
|
|
414
|
+
"""The assistant turn exactly as Anthropic returned it, thinking blocks
|
|
415
|
+
included, when ``provider_metadata`` still describes this message: the same
|
|
416
|
+
tool_use ids in order with the same inputs, and the same text. A message
|
|
417
|
+
edited since (or built by hand, or from another provider) is rebuilt from
|
|
418
|
+
its normalized fields instead, which drops its thinking blocks; replaying
|
|
419
|
+
the stored blocks would silently undo the edit."""
|
|
420
|
+
meta = m.provider_metadata.get("anthropic") if isinstance(m.provider_metadata, dict) else None
|
|
421
|
+
content = jget(meta, "content")
|
|
422
|
+
if not isinstance(content, list) or not content:
|
|
423
|
+
return None
|
|
424
|
+
if not all(isinstance(b, dict) for b in content):
|
|
425
|
+
return None
|
|
426
|
+
tool_uses = [b for b in content if b.get("type") == "tool_use"]
|
|
427
|
+
if [jstr(b.get("id")) for b in tool_uses] != [tc.id for tc in m.tool_calls]:
|
|
428
|
+
return None
|
|
429
|
+
for block, tc in zip(tool_uses, m.tool_calls):
|
|
430
|
+
try:
|
|
431
|
+
args = json.loads(tc.arguments) if tc.arguments else {}
|
|
432
|
+
except (TypeError, ValueError):
|
|
433
|
+
return None
|
|
434
|
+
if block.get("input") != args:
|
|
435
|
+
return None
|
|
436
|
+
text = "".join(jstr(b.get("text")) for b in content if b.get("type") == "text")
|
|
437
|
+
if text != _content_to_text(m):
|
|
438
|
+
return None
|
|
439
|
+
return copy.deepcopy(content)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _content_to_text(m: ChatMessage) -> str:
|
|
443
|
+
if m.content_parts:
|
|
444
|
+
return "".join(p.text for p in m.content_parts if p.type == "text")
|
|
445
|
+
return m.content
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _map_content_blocks(m: ChatMessage) -> list[dict[str, Any]]:
|
|
449
|
+
blocks: list[dict[str, Any]] = []
|
|
450
|
+
if m.content_parts:
|
|
451
|
+
for p in m.content_parts:
|
|
452
|
+
if p.type == "text":
|
|
453
|
+
# Anthropic rejects an empty text block, so an empty part is dropped.
|
|
454
|
+
if p.text:
|
|
455
|
+
blocks.append({"type": "text", "text": p.text})
|
|
456
|
+
else:
|
|
457
|
+
blocks.append(
|
|
458
|
+
{"type": "image", "source": {"type": "base64", "media_type": p.media_type, "data": p.base64}}
|
|
459
|
+
)
|
|
460
|
+
elif m.content:
|
|
461
|
+
blocks.append({"type": "text", "text": m.content})
|
|
462
|
+
if m.role == "assistant" and m.tool_calls:
|
|
463
|
+
for tc in m.tool_calls:
|
|
464
|
+
blocks.append({"type": "tool_use", "id": tc.id, "name": tc.name, "input": _safe_json_parse(tc.arguments)})
|
|
465
|
+
if not blocks:
|
|
466
|
+
blocks.append({"type": "text", "text": ""})
|
|
467
|
+
return blocks
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _is_tool_result_content(content: Any) -> bool:
|
|
471
|
+
return (
|
|
472
|
+
isinstance(content, list)
|
|
473
|
+
and len(content) > 0
|
|
474
|
+
and isinstance(content[0], dict)
|
|
475
|
+
and content[0].get("type") == "tool_result"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _map_tool_choice(tc) -> Optional[dict[str, Any]]:
|
|
480
|
+
if tc.mode == "auto":
|
|
481
|
+
return {"type": "auto"}
|
|
482
|
+
if tc.mode == "required":
|
|
483
|
+
return {"type": "any"}
|
|
484
|
+
if tc.mode == "none":
|
|
485
|
+
return {"type": "none"}
|
|
486
|
+
if tc.mode == "function":
|
|
487
|
+
return {"type": "tool", "name": tc.function_name}
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _map_stop_reason(sr: str) -> str:
|
|
492
|
+
if sr in ("end_turn", "stop_sequence"):
|
|
493
|
+
return "stop"
|
|
494
|
+
# model_context_window_exceeded: the output was cut off by the context
|
|
495
|
+
# window rather than max_tokens; for the caller both mean "truncated".
|
|
496
|
+
if sr in ("max_tokens", "model_context_window_exceeded"):
|
|
497
|
+
return "length"
|
|
498
|
+
if sr == "tool_use":
|
|
499
|
+
return "tool_calls"
|
|
500
|
+
if sr == "refusal":
|
|
501
|
+
return "content_filter"
|
|
502
|
+
# pause_turn (a server-tool loop hit its iteration limit; send the turn back
|
|
503
|
+
# to continue) has no normalized counterpart.
|
|
504
|
+
return "unknown"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _map_code(status: int, message: str) -> ProviderCode:
|
|
508
|
+
if status in (401, 403):
|
|
509
|
+
return ProviderCode.AUTH_FAILED
|
|
510
|
+
# 402 means the account is out of credit or quota. OpenAI (429
|
|
511
|
+
# insufficient_quota) and Gemini (RESOURCE_EXHAUSTED) already report that as
|
|
512
|
+
# rate-limited, so 402 classifies the same way.
|
|
513
|
+
if status in (402, 429):
|
|
514
|
+
return ProviderCode.RATE_LIMITED
|
|
515
|
+
# Any 5xx is a provider-side failure, including gateway and Cloudflare-origin
|
|
516
|
+
# statuses (504/520/522/524) a fixed whitelist would miss; 408 is a
|
|
517
|
+
# transient timeout.
|
|
518
|
+
if status >= 500 or status == 408:
|
|
519
|
+
return ProviderCode.PROVIDER_UNAVAILABLE
|
|
520
|
+
if status >= 400:
|
|
521
|
+
if _is_context_overflow(message):
|
|
522
|
+
return ProviderCode.CONTEXT_LENGTH_EXCEEDED
|
|
523
|
+
# 404, 413 request_too_large (a byte limit, not a token one), 422, …:
|
|
524
|
+
# the request cannot succeed as sent.
|
|
525
|
+
return ProviderCode.INVALID_REQUEST
|
|
526
|
+
return ProviderCode.UNKNOWN
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Hand-rolled AWS Signature Version 4 (HMAC-SHA256) on the standard library —
|
|
2
|
+
no third-party dependency. Used by the Bedrock native adapter."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Mapping, Optional
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
_UNSIGNABLE = {
|
|
15
|
+
"authorization", "cache-control", "connection", "content-length", "expect",
|
|
16
|
+
"from", "keep-alive", "max-forwards", "pragma", "proxy-authenticate",
|
|
17
|
+
"proxy-authorization", "referer", "te", "trailer", "transfer-encoding",
|
|
18
|
+
"upgrade", "user-agent", "x-amzn-trace-id",
|
|
19
|
+
}
|
|
20
|
+
_WS = re.compile(r"\s+")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SigV4Params:
|
|
25
|
+
method: str
|
|
26
|
+
service: str
|
|
27
|
+
region: str
|
|
28
|
+
host: str
|
|
29
|
+
path: str
|
|
30
|
+
payload: bytes
|
|
31
|
+
access_key_id: str
|
|
32
|
+
secret_access_key: str
|
|
33
|
+
session_token: str = ""
|
|
34
|
+
canonical_query: str = ""
|
|
35
|
+
signed: Mapping[str, str] = field(default_factory=dict)
|
|
36
|
+
now: Optional[_dt.datetime] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sign_aws_v4(p: SigV4Params) -> dict[str, str]:
|
|
40
|
+
ts = (p.now or _dt.datetime.now(tz=_dt.timezone.utc)).astimezone(_dt.timezone.utc)
|
|
41
|
+
amz_date = ts.strftime("%Y%m%dT%H%M%SZ")
|
|
42
|
+
date_stamp = ts.strftime("%Y%m%d")
|
|
43
|
+
hashed_payload = hashlib.sha256(p.payload).hexdigest()
|
|
44
|
+
|
|
45
|
+
canonical: dict[str, str] = {}
|
|
46
|
+
for k, v in p.signed.items():
|
|
47
|
+
canonical[k.lower()] = _WS.sub(" ", v.strip())
|
|
48
|
+
canonical["host"] = _WS.sub(" ", p.host.strip())
|
|
49
|
+
canonical["x-amz-date"] = amz_date
|
|
50
|
+
canonical["x-amz-content-sha256"] = hashed_payload
|
|
51
|
+
if p.session_token:
|
|
52
|
+
canonical["x-amz-security-token"] = p.session_token
|
|
53
|
+
for h in list(canonical):
|
|
54
|
+
if h in _UNSIGNABLE:
|
|
55
|
+
del canonical[h]
|
|
56
|
+
|
|
57
|
+
names = sorted(canonical)
|
|
58
|
+
canonical_headers = "".join(f"{n}:{canonical[n]}\n" for n in names)
|
|
59
|
+
signed_headers = ";".join(names)
|
|
60
|
+
canonical_request = "\n".join([p.method, p.path, p.canonical_query, canonical_headers, signed_headers, hashed_payload])
|
|
61
|
+
credential_scope = f"{date_stamp}/{p.region}/{p.service}/aws4_request"
|
|
62
|
+
string_to_sign = "\n".join(["AWS4-HMAC-SHA256", amz_date, credential_scope, hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
|
|
63
|
+
|
|
64
|
+
def _hmac(key: bytes, data: str) -> bytes:
|
|
65
|
+
return hmac.new(key, data.encode("utf-8"), hashlib.sha256).digest()
|
|
66
|
+
|
|
67
|
+
k_date = _hmac(("AWS4" + p.secret_access_key).encode("utf-8"), date_stamp)
|
|
68
|
+
k_region = _hmac(k_date, p.region)
|
|
69
|
+
k_service = _hmac(k_region, p.service)
|
|
70
|
+
k_signing = _hmac(k_service, "aws4_request")
|
|
71
|
+
signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
72
|
+
|
|
73
|
+
authorization = (
|
|
74
|
+
f"AWS4-HMAC-SHA256 Credential={p.access_key_id}/{credential_scope}, "
|
|
75
|
+
f"SignedHeaders={signed_headers}, Signature={signature}"
|
|
76
|
+
)
|
|
77
|
+
out = {
|
|
78
|
+
"Host": p.host,
|
|
79
|
+
"X-Amz-Date": amz_date,
|
|
80
|
+
"X-Amz-Content-Sha256": hashed_payload,
|
|
81
|
+
"Authorization": authorization,
|
|
82
|
+
}
|
|
83
|
+
if p.session_token:
|
|
84
|
+
out["X-Amz-Security-Token"] = p.session_token
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_canonical_query(query: Mapping[str, str]) -> str:
|
|
89
|
+
if not query:
|
|
90
|
+
return ""
|
|
91
|
+
return "&".join(f"{_rfc3986(k)}={_rfc3986(query[k])}" for k in sorted(query))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _rfc3986(s: str) -> str:
|
|
95
|
+
return quote(s, safe="-._~")
|