frugal-sdk-python 0.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,430 @@
1
+ """Per-provider response-shape dispatch for AWS Bedrock.
2
+
3
+ Every Bedrock customer can hit any provider through `invoke_model`,
4
+ `invoke_model_with_response_stream`, `converse`, or `converse_stream`.
5
+ The shape of the request body and the response usage block depends on
6
+ the underlying provider — so `wrap_bedrock` looks the modelId up in the
7
+ DISPATCH table to find the right extractor.
8
+
9
+ Coverage in v1: per-provider extractors for Anthropic Claude, OpenAI
10
+ gpt-oss, Amazon Nova, Meta Llama, Mistral. Everything else falls
11
+ through to the UNIVERSAL_FALLBACK_HANDLER, which uses the
12
+ `amazon-bedrock-invocationMetrics` chunk / `x-amzn-bedrock-*-token-count`
13
+ headers that Bedrock attaches to every response regardless of provider.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import json
20
+ import re
21
+ from dataclasses import dataclass
22
+ from typing import Any, Callable
23
+
24
+ from frugal_metrics.instrument import UsageReport
25
+ from frugal_metrics.prompt_analyzer import RequestPayload
26
+
27
+
28
+ # --- Cross-region inference profile prefixes ---------------------------------
29
+ #
30
+ # Newer Claude / Nova / Llama models on Bedrock are typically invoked through
31
+ # cross-region inference profile IDs that prefix the underlying modelId with a
32
+ # region scope, e.g. `us.anthropic.claude-haiku-4-5-20251001-v1:0`.
33
+
34
+ CROSS_REGION_PREFIX_RE = re.compile(r"^(us|eu|jp|apac|au|global)\.")
35
+
36
+
37
+ def strip_cross_region_prefix(model_id: str) -> str:
38
+ """Drop a leading region prefix so dispatch can match the base model ID.
39
+
40
+ The wrapper still emits the original (un-stripped) modelId in
41
+ `gen_ai.request.model` so customers can tell `us.` vs `eu.` apart.
42
+ """
43
+ return CROSS_REGION_PREFIX_RE.sub("", model_id)
44
+
45
+
46
+ # --- Dispatch entry shape ----------------------------------------------------
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class BedrockModelHandler:
51
+ name: str
52
+ extract_invoke_model_usage: Callable[[dict[str, Any]], UsageReport]
53
+ extract_converse_usage: Callable[[dict[str, Any]], UsageReport]
54
+ extract_invoke_model_stream_event: Callable[[dict[str, Any]], "UsageReport | None"]
55
+ capture_invoke_model_payload: Callable[[dict[str, Any]], "RequestPayload | None"]
56
+
57
+
58
+ # --- Per-provider InvokeModel response extractors ----------------------------
59
+
60
+
61
+ def _anthropic_invoke_usage(body: dict[str, Any]) -> UsageReport:
62
+ usage = body.get("usage") or {}
63
+ thinking = usage.get("thinking_tokens")
64
+ return UsageReport(
65
+ response_model=body.get("model"),
66
+ input_tokens=usage.get("input_tokens"),
67
+ output_tokens=usage.get("output_tokens"),
68
+ cache_read_tokens=usage.get("cache_read_input_tokens"),
69
+ cache_write_tokens=usage.get("cache_creation_input_tokens"),
70
+ reasoning_tokens=thinking if isinstance(thinking, int) else None,
71
+ )
72
+
73
+
74
+ def _openai_gpt_oss_invoke_usage(body: dict[str, Any]) -> UsageReport:
75
+ usage = body.get("usage") or {}
76
+ return UsageReport(
77
+ response_model=body.get("model"),
78
+ input_tokens=usage.get("prompt_tokens"),
79
+ output_tokens=usage.get("completion_tokens"),
80
+ )
81
+
82
+
83
+ def _amazon_nova_invoke_usage(body: dict[str, Any]) -> UsageReport:
84
+ usage = body.get("usage") or {}
85
+ return UsageReport(
86
+ response_model=body.get("model") or body.get("modelId"),
87
+ input_tokens=usage.get("inputTokens"),
88
+ output_tokens=usage.get("outputTokens"),
89
+ )
90
+
91
+
92
+ def _meta_llama_invoke_usage(body: dict[str, Any]) -> UsageReport:
93
+ return UsageReport(
94
+ input_tokens=body.get("prompt_token_count"),
95
+ output_tokens=body.get("generation_token_count"),
96
+ )
97
+
98
+
99
+ def _mistral_invoke_usage(body: dict[str, Any]) -> UsageReport:
100
+ # Mistral on Bedrock historically did not include usage in the body; rely
101
+ # on the universal x-amzn-bedrock-*-token-count headers that the wrapper
102
+ # also reads.
103
+ return UsageReport()
104
+
105
+
106
+ def _empty_invoke_usage(body: dict[str, Any]) -> UsageReport:
107
+ return UsageReport()
108
+
109
+
110
+ # --- Universal Converse extractor (every provider returns the same shape) ----
111
+
112
+
113
+ def extract_converse_usage_universal(response: dict[str, Any]) -> UsageReport:
114
+ usage = response.get("usage") or {}
115
+ return UsageReport(
116
+ input_tokens=usage.get("inputTokens"),
117
+ output_tokens=usage.get("outputTokens"),
118
+ cache_read_tokens=usage.get("cacheReadInputTokens"),
119
+ cache_write_tokens=usage.get("cacheWriteInputTokens"),
120
+ )
121
+
122
+
123
+ def extract_converse_stream_event(event: dict[str, Any]) -> "UsageReport | None":
124
+ """Converse streams a terminal `metadata` event with the usage block."""
125
+ metadata = event.get("metadata")
126
+ if metadata is None:
127
+ return None
128
+ usage = metadata.get("usage") or {}
129
+ if not usage:
130
+ return None
131
+ return UsageReport(
132
+ input_tokens=usage.get("inputTokens"),
133
+ output_tokens=usage.get("outputTokens"),
134
+ cache_read_tokens=usage.get("cacheReadInputTokens"),
135
+ cache_write_tokens=usage.get("cacheWriteInputTokens"),
136
+ )
137
+
138
+
139
+ # --- Universal InvokeModel stream extractor (final-chunk metrics) ------------
140
+
141
+
142
+ def extract_invoke_model_stream_event_universal(
143
+ event: dict[str, Any],
144
+ ) -> "UsageReport | None":
145
+ """Pull token counts from `amazon-bedrock-invocationMetrics` in the last chunk.
146
+
147
+ Bedrock attaches these to the terminal chunk of every InvokeModel stream
148
+ regardless of provider — gives us non-zero coverage for any model.
149
+ """
150
+ decoded = _decode_invoke_model_chunk(event)
151
+ if decoded is None:
152
+ return None
153
+ metrics = decoded.get("amazon-bedrock-invocationMetrics")
154
+ if not metrics:
155
+ return None
156
+ return UsageReport(
157
+ input_tokens=metrics.get("inputTokenCount"),
158
+ output_tokens=metrics.get("outputTokenCount"),
159
+ )
160
+
161
+
162
+ def _invoke_model_stream_event_anthropic(
163
+ event: dict[str, Any],
164
+ ) -> "UsageReport | None":
165
+ """Anthropic-specific extractor for invoke_model_with_response_stream chunks.
166
+
167
+ Anthropic emits SSE-shaped events inside chunk bytes:
168
+ message_start → initial usage including cache_*
169
+ message_delta → running output_tokens total
170
+ """
171
+ decoded = _decode_invoke_model_chunk(event)
172
+ if decoded is None:
173
+ return None
174
+ event_type = decoded.get("type")
175
+ if event_type == "message_start":
176
+ message = decoded.get("message") or {}
177
+ usage = message.get("usage") or {}
178
+ return UsageReport(
179
+ response_model=message.get("model"),
180
+ input_tokens=usage.get("input_tokens"),
181
+ output_tokens=usage.get("output_tokens"),
182
+ cache_read_tokens=usage.get("cache_read_input_tokens"),
183
+ cache_write_tokens=usage.get("cache_creation_input_tokens"),
184
+ )
185
+ if event_type == "message_delta":
186
+ usage = decoded.get("usage") or {}
187
+ out = usage.get("output_tokens")
188
+ if out is not None:
189
+ return UsageReport(output_tokens=out)
190
+ return None
191
+
192
+
193
+ def _decode_invoke_model_chunk(event: dict[str, Any]) -> dict[str, Any] | None:
194
+ chunk = event.get("chunk") or {}
195
+ raw = chunk.get("bytes")
196
+ if not raw:
197
+ return None
198
+ if isinstance(raw, (bytes, bytearray)):
199
+ raw = bytes(raw).decode("utf-8", errors="replace")
200
+ try:
201
+ return json.loads(raw)
202
+ except (ValueError, TypeError):
203
+ return None
204
+
205
+
206
+ # --- Universal HTTP-header extractor (for non-streaming unrecognized models) -
207
+
208
+
209
+ def extract_universal_headers_usage(response: dict[str, Any]) -> UsageReport:
210
+ """Pull token counts from boto3's response['ResponseMetadata']['HTTPHeaders'].
211
+
212
+ Bedrock returns `x-amzn-bedrock-input-token-count` and
213
+ `x-amzn-bedrock-output-token-count` on every InvokeModel response — same
214
+ shape across providers. Used as the fallback when no per-provider handler
215
+ matches.
216
+ """
217
+ headers = (response.get("ResponseMetadata") or {}).get("HTTPHeaders") or {}
218
+ return UsageReport(
219
+ input_tokens=_try_int(headers.get("x-amzn-bedrock-input-token-count")),
220
+ output_tokens=_try_int(headers.get("x-amzn-bedrock-output-token-count")),
221
+ )
222
+
223
+
224
+ def _try_int(value: Any) -> int | None:
225
+ if value is None:
226
+ return None
227
+ try:
228
+ return int(value)
229
+ except (TypeError, ValueError):
230
+ return None
231
+
232
+
233
+ # --- Request-payload capture (drives the M5 prompt analyzer) -----------------
234
+
235
+
236
+ def _capture_anthropic_payload(body: dict[str, Any]) -> RequestPayload | None:
237
+ max_tokens = body.get("max_tokens")
238
+ if not isinstance(max_tokens, int):
239
+ max_tokens = None
240
+ return RequestPayload(
241
+ messages=body.get("messages"),
242
+ tools=body.get("tools"),
243
+ response_format=None,
244
+ max_tokens=max_tokens,
245
+ system=body.get("system"),
246
+ )
247
+
248
+
249
+ def _capture_openai_payload(body: dict[str, Any]) -> RequestPayload | None:
250
+ max_tokens = body.get("max_tokens")
251
+ if not isinstance(max_tokens, int):
252
+ max_tokens = body.get("max_completion_tokens")
253
+ if not isinstance(max_tokens, int):
254
+ max_tokens = None
255
+ return RequestPayload(
256
+ messages=body.get("messages"),
257
+ tools=body.get("tools"),
258
+ response_format=body.get("response_format"),
259
+ max_tokens=max_tokens,
260
+ system=None,
261
+ )
262
+
263
+
264
+ def _no_payload(_body: dict[str, Any]) -> RequestPayload | None:
265
+ return None
266
+
267
+
268
+ # --- Body decode helpers (shared by per-provider extractors) ------------------
269
+
270
+
271
+ def decode_invoke_model_body(raw_body: Any) -> dict[str, Any]:
272
+ """Decode the `body` kwarg of an InvokeModel call into a dict.
273
+
274
+ Customers usually pass `body=json.dumps({...})` or `body=b'{...}'`. We
275
+ always parse into a dict so handlers can reason about request kwargs (for
276
+ the analyzer) and so the universal stream parser shares the same shape.
277
+ """
278
+ if raw_body is None:
279
+ return {}
280
+ if isinstance(raw_body, (bytes, bytearray)):
281
+ raw_body = bytes(raw_body).decode("utf-8", errors="replace")
282
+ if isinstance(raw_body, str):
283
+ try:
284
+ decoded = json.loads(raw_body)
285
+ except (ValueError, TypeError):
286
+ return {}
287
+ return decoded if isinstance(decoded, dict) else {}
288
+ if isinstance(raw_body, dict):
289
+ return raw_body
290
+ return {}
291
+
292
+
293
+ def consume_invoke_model_response_body(response: dict[str, Any]) -> dict[str, Any]:
294
+ """Read response['body'].read() once, replace it with a re-readable BytesIO,
295
+ and return the parsed JSON. Customers can still call .read() afterwards.
296
+ """
297
+ body_stream = response.get("body")
298
+ if body_stream is None:
299
+ return {}
300
+ reader = getattr(body_stream, "read", None)
301
+ if not callable(reader):
302
+ return {}
303
+ raw = reader()
304
+ response["body"] = _ReReadableStreamingBody(raw)
305
+ if isinstance(raw, (bytes, bytearray)):
306
+ raw = bytes(raw).decode("utf-8", errors="replace")
307
+ try:
308
+ decoded = json.loads(raw)
309
+ except (ValueError, TypeError):
310
+ return {}
311
+ return decoded if isinstance(decoded, dict) else {}
312
+
313
+
314
+ class _ReReadableStreamingBody:
315
+ """Tiny stand-in for botocore.response.StreamingBody after we've consumed it.
316
+
317
+ We only need to satisfy the customer if they call `.read()` again.
318
+ Anything fancier (close, iter chunks) is best-effort.
319
+ """
320
+
321
+ def __init__(self, data: Any) -> None:
322
+ if isinstance(data, str):
323
+ data = data.encode("utf-8")
324
+ if not isinstance(data, (bytes, bytearray)):
325
+ data = b""
326
+ self._data = bytes(data)
327
+ self._buf = io.BytesIO(self._data)
328
+
329
+ def read(self, amt: int | None = None) -> bytes:
330
+ if amt is None:
331
+ return self._buf.read()
332
+ return self._buf.read(amt)
333
+
334
+ def close(self) -> None:
335
+ self._buf.close()
336
+
337
+ def iter_chunks(self) -> Any:
338
+ yield self._data
339
+
340
+
341
+ # --- Handler registry --------------------------------------------------------
342
+
343
+
344
+ ANTHROPIC_CLAUDE_HANDLER = BedrockModelHandler(
345
+ name="anthropic.claude",
346
+ extract_invoke_model_usage=_anthropic_invoke_usage,
347
+ extract_converse_usage=extract_converse_usage_universal,
348
+ extract_invoke_model_stream_event=_invoke_model_stream_event_anthropic,
349
+ capture_invoke_model_payload=_capture_anthropic_payload,
350
+ )
351
+
352
+ OPENAI_GPT_OSS_HANDLER = BedrockModelHandler(
353
+ name="openai.gpt-oss",
354
+ extract_invoke_model_usage=_openai_gpt_oss_invoke_usage,
355
+ extract_converse_usage=extract_converse_usage_universal,
356
+ extract_invoke_model_stream_event=extract_invoke_model_stream_event_universal,
357
+ capture_invoke_model_payload=_capture_openai_payload,
358
+ )
359
+
360
+ AMAZON_NOVA_HANDLER = BedrockModelHandler(
361
+ name="amazon.nova",
362
+ extract_invoke_model_usage=_amazon_nova_invoke_usage,
363
+ extract_converse_usage=extract_converse_usage_universal,
364
+ extract_invoke_model_stream_event=extract_invoke_model_stream_event_universal,
365
+ capture_invoke_model_payload=_no_payload,
366
+ )
367
+
368
+ META_LLAMA_HANDLER = BedrockModelHandler(
369
+ name="meta.llama",
370
+ extract_invoke_model_usage=_meta_llama_invoke_usage,
371
+ extract_converse_usage=extract_converse_usage_universal,
372
+ extract_invoke_model_stream_event=extract_invoke_model_stream_event_universal,
373
+ capture_invoke_model_payload=_no_payload,
374
+ )
375
+
376
+ MISTRAL_HANDLER = BedrockModelHandler(
377
+ name="mistral",
378
+ extract_invoke_model_usage=_mistral_invoke_usage,
379
+ extract_converse_usage=extract_converse_usage_universal,
380
+ extract_invoke_model_stream_event=extract_invoke_model_stream_event_universal,
381
+ capture_invoke_model_payload=_no_payload,
382
+ )
383
+
384
+ UNIVERSAL_FALLBACK_HANDLER = BedrockModelHandler(
385
+ name="<universal>",
386
+ extract_invoke_model_usage=_empty_invoke_usage, # body parsing skipped — header path used instead
387
+ extract_converse_usage=extract_converse_usage_universal,
388
+ extract_invoke_model_stream_event=extract_invoke_model_stream_event_universal,
389
+ capture_invoke_model_payload=_no_payload,
390
+ )
391
+
392
+
393
+ # Order matters: longest provider+family prefix first so e.g.
394
+ # `openai.gpt-oss-...` matches before any future shorter `openai.` entry.
395
+ DISPATCH: list[tuple[str, BedrockModelHandler]] = [
396
+ ("anthropic.claude-", ANTHROPIC_CLAUDE_HANDLER),
397
+ ("openai.gpt-oss-", OPENAI_GPT_OSS_HANDLER),
398
+ ("openai.gpt-", OPENAI_GPT_OSS_HANDLER),
399
+ ("amazon.nova-", AMAZON_NOVA_HANDLER),
400
+ ("meta.llama", META_LLAMA_HANDLER),
401
+ ("mistral.", MISTRAL_HANDLER),
402
+ ]
403
+
404
+
405
+ def lookup_handler(model_id: str) -> BedrockModelHandler:
406
+ """Strip cross-region prefix, find the longest matching provider+family
407
+ prefix, fall back to the universal handler.
408
+ """
409
+ stripped = strip_cross_region_prefix(model_id)
410
+ for prefix, handler in DISPATCH:
411
+ if stripped.startswith(prefix):
412
+ return handler
413
+ return UNIVERSAL_FALLBACK_HANDLER
414
+
415
+
416
+ def merge_usage(into: UsageReport, partial: "UsageReport | None") -> None:
417
+ """Update `into` in place with non-None fields from `partial`."""
418
+ if partial is None:
419
+ return
420
+ for field_name in (
421
+ "response_model",
422
+ "input_tokens",
423
+ "output_tokens",
424
+ "cache_read_tokens",
425
+ "cache_write_tokens",
426
+ "reasoning_tokens",
427
+ ):
428
+ value = getattr(partial, field_name)
429
+ if value is not None:
430
+ setattr(into, field_name, value)