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.
@@ -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="-._~")