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.
- frugal_metrics/__init__.py +13 -0
- frugal_metrics/block_hash_lru.py +75 -0
- frugal_metrics/caller.py +213 -0
- frugal_metrics/config.py +231 -0
- frugal_metrics/instrument.py +268 -0
- frugal_metrics/otel.py +321 -0
- frugal_metrics/prompt_analyzer.py +273 -0
- frugal_metrics/safe.py +34 -0
- frugal_metrics/semconv.py +70 -0
- frugal_metrics/streams.py +172 -0
- frugal_metrics/wrappers/__init__.py +1 -0
- frugal_metrics/wrappers/anthropic.py +277 -0
- frugal_metrics/wrappers/bedrock.py +358 -0
- frugal_metrics/wrappers/bedrock_models.py +430 -0
- frugal_metrics/wrappers/openai.py +465 -0
- frugal_sdk_python-0.1.0.dist-info/METADATA +195 -0
- frugal_sdk_python-0.1.0.dist-info/RECORD +18 -0
- frugal_sdk_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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)
|