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,277 @@
|
|
|
1
|
+
"""Anthropic client wrapper.
|
|
2
|
+
|
|
3
|
+
Monkey-patches `messages.create` on an Anthropic / AsyncAnthropic /
|
|
4
|
+
AnthropicBedrock / AnthropicVertex client instance. Handles non-streaming
|
|
5
|
+
responses and `stream=True` streams. The `messages.stream(...)` context-manager
|
|
6
|
+
API is not yet wrapped (deferred).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any, Callable, TypeVar
|
|
15
|
+
|
|
16
|
+
from frugal_metrics import caller as caller_mod
|
|
17
|
+
from frugal_metrics import semconv
|
|
18
|
+
from frugal_metrics.config import load_config
|
|
19
|
+
from frugal_metrics.prompt_analyzer import RequestPayload
|
|
20
|
+
from frugal_metrics.safe import safe_call
|
|
21
|
+
from frugal_metrics.instrument import (
|
|
22
|
+
CallContext,
|
|
23
|
+
UsageReport,
|
|
24
|
+
start_call,
|
|
25
|
+
)
|
|
26
|
+
from frugal_metrics.streams import AsyncStreamProxy, SyncStreamProxy
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("frugal_metrics")
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def wrap_anthropic(client: T) -> T:
|
|
34
|
+
"""Instrument an Anthropic (or Bedrock / Vertex variant) client in place."""
|
|
35
|
+
config = load_config()
|
|
36
|
+
if config is None:
|
|
37
|
+
return client
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import anthropic # type: ignore
|
|
41
|
+
except ImportError:
|
|
42
|
+
logger.warning(
|
|
43
|
+
"frugal-metrics: anthropic package not installed; cannot wrap"
|
|
44
|
+
)
|
|
45
|
+
return client
|
|
46
|
+
|
|
47
|
+
if getattr(client, "_frugal_wrapped", False):
|
|
48
|
+
return client
|
|
49
|
+
|
|
50
|
+
system = _detect_system(client)
|
|
51
|
+
skip_dirs = _build_skip_dirs(anthropic)
|
|
52
|
+
|
|
53
|
+
messages = getattr(client, "messages", None)
|
|
54
|
+
if messages is None:
|
|
55
|
+
logger.debug(
|
|
56
|
+
"frugal-metrics: no `messages` attribute on %s; skipping",
|
|
57
|
+
type(client).__name__,
|
|
58
|
+
)
|
|
59
|
+
return client
|
|
60
|
+
|
|
61
|
+
original = getattr(messages, "create", None)
|
|
62
|
+
if original is None or getattr(original, "_frugal_wrapped", False):
|
|
63
|
+
return client
|
|
64
|
+
|
|
65
|
+
is_async = inspect.iscoroutinefunction(original)
|
|
66
|
+
wrapped = (
|
|
67
|
+
_make_async(original, system, skip_dirs, config)
|
|
68
|
+
if is_async
|
|
69
|
+
else _make_sync(original, system, skip_dirs, config)
|
|
70
|
+
)
|
|
71
|
+
wrapped._frugal_wrapped = True # type: ignore[attr-defined]
|
|
72
|
+
try:
|
|
73
|
+
setattr(messages, "create", wrapped)
|
|
74
|
+
except Exception as exc: # noqa: BLE001
|
|
75
|
+
logger.warning("frugal-metrics: failed to patch messages.create: %s", exc)
|
|
76
|
+
return client
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
setattr(client, "_frugal_wrapped", True)
|
|
80
|
+
except Exception: # noqa: BLE001
|
|
81
|
+
pass
|
|
82
|
+
return client
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _detect_system(client: Any) -> str:
|
|
86
|
+
cls_name = type(client).__name__
|
|
87
|
+
if "Bedrock" in cls_name:
|
|
88
|
+
return semconv.SYSTEM_AWS_BEDROCK
|
|
89
|
+
if "Vertex" in cls_name:
|
|
90
|
+
return semconv.SYSTEM_VERTEX_AI
|
|
91
|
+
return semconv.SYSTEM_ANTHROPIC
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _build_skip_dirs(anthropic_mod: Any) -> tuple[str, ...]:
|
|
95
|
+
import frugal_metrics
|
|
96
|
+
|
|
97
|
+
return tuple(
|
|
98
|
+
d
|
|
99
|
+
for d in (
|
|
100
|
+
caller_mod.package_dir_for(frugal_metrics),
|
|
101
|
+
caller_mod.package_dir_for(anthropic_mod),
|
|
102
|
+
)
|
|
103
|
+
if d
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _make_sync(
|
|
108
|
+
original: Callable[..., Any],
|
|
109
|
+
system: str,
|
|
110
|
+
skip_dirs: tuple[str, ...],
|
|
111
|
+
config: Any,
|
|
112
|
+
) -> Callable[..., Any]:
|
|
113
|
+
@functools.wraps(original)
|
|
114
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
115
|
+
try:
|
|
116
|
+
streaming = bool(kwargs.get("stream"))
|
|
117
|
+
ctx = CallContext(
|
|
118
|
+
system=system,
|
|
119
|
+
operation=semconv.OP_CHAT,
|
|
120
|
+
request_model=_request_model(kwargs),
|
|
121
|
+
skip_package_dirs=skip_dirs,
|
|
122
|
+
batched=False,
|
|
123
|
+
request_payload=_capture_anthropic_payload(kwargs),
|
|
124
|
+
)
|
|
125
|
+
emit = start_call(config, ctx)
|
|
126
|
+
except Exception as exc: # noqa: BLE001
|
|
127
|
+
_record_internal_error("setup_failed", exc, config)
|
|
128
|
+
return original(*args, **kwargs)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
response = original(*args, **kwargs)
|
|
132
|
+
except BaseException as exc:
|
|
133
|
+
safe_call(lambda: emit.error(exc))
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
if streaming:
|
|
137
|
+
try:
|
|
138
|
+
return SyncStreamProxy(response, emit, _anthropic_stream_event)
|
|
139
|
+
except Exception as exc: # noqa: BLE001
|
|
140
|
+
_record_internal_error("stream_proxy_failed", exc, config)
|
|
141
|
+
return response
|
|
142
|
+
|
|
143
|
+
safe_call(lambda: emit.success(_messages_usage(response)))
|
|
144
|
+
return response
|
|
145
|
+
|
|
146
|
+
return wrapper
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _make_async(
|
|
150
|
+
original: Callable[..., Any],
|
|
151
|
+
system: str,
|
|
152
|
+
skip_dirs: tuple[str, ...],
|
|
153
|
+
config: Any,
|
|
154
|
+
) -> Callable[..., Any]:
|
|
155
|
+
@functools.wraps(original)
|
|
156
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
157
|
+
try:
|
|
158
|
+
streaming = bool(kwargs.get("stream"))
|
|
159
|
+
ctx = CallContext(
|
|
160
|
+
system=system,
|
|
161
|
+
operation=semconv.OP_CHAT,
|
|
162
|
+
request_model=_request_model(kwargs),
|
|
163
|
+
skip_package_dirs=skip_dirs,
|
|
164
|
+
batched=False,
|
|
165
|
+
request_payload=_capture_anthropic_payload(kwargs),
|
|
166
|
+
)
|
|
167
|
+
emit = start_call(config, ctx)
|
|
168
|
+
except Exception as exc: # noqa: BLE001
|
|
169
|
+
_record_internal_error("setup_failed", exc, config)
|
|
170
|
+
return await original(*args, **kwargs)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
response = await original(*args, **kwargs)
|
|
174
|
+
except BaseException as exc:
|
|
175
|
+
safe_call(lambda: emit.error(exc))
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
if streaming:
|
|
179
|
+
try:
|
|
180
|
+
return AsyncStreamProxy(response, emit, _anthropic_stream_event)
|
|
181
|
+
except Exception as exc: # noqa: BLE001
|
|
182
|
+
_record_internal_error("stream_proxy_failed", exc, config)
|
|
183
|
+
return response
|
|
184
|
+
|
|
185
|
+
safe_call(lambda: emit.success(_messages_usage(response)))
|
|
186
|
+
return response
|
|
187
|
+
|
|
188
|
+
return wrapper
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _record_internal_error(stage: str, exc: BaseException, config: Any) -> None:
|
|
192
|
+
"""Try to emit an internal error metric; swallow any failure."""
|
|
193
|
+
try:
|
|
194
|
+
from frugal_metrics.otel import get_instruments
|
|
195
|
+
|
|
196
|
+
instruments = get_instruments(config)
|
|
197
|
+
err_attrs: dict[str, Any] = {
|
|
198
|
+
semconv.FRUGAL_PROJECT_ID: config.project_id,
|
|
199
|
+
"frugal.internal_error.stage": stage,
|
|
200
|
+
"frugal.internal_error.type": type(exc).__name__,
|
|
201
|
+
}
|
|
202
|
+
if config.customer_id:
|
|
203
|
+
err_attrs[semconv.FRUGAL_CUSTOMER_ID] = config.customer_id
|
|
204
|
+
instruments.internal_errors.add(1, err_attrs)
|
|
205
|
+
except Exception: # noqa: BLE001
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _request_model(kwargs: dict[str, Any]) -> str:
|
|
210
|
+
model = kwargs.get("model")
|
|
211
|
+
return str(model) if model is not None else "<unknown>"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _capture_anthropic_payload(kwargs: dict[str, Any]) -> RequestPayload:
|
|
215
|
+
max_tokens = kwargs.get("max_tokens")
|
|
216
|
+
if not isinstance(max_tokens, int):
|
|
217
|
+
max_tokens = None
|
|
218
|
+
return RequestPayload(
|
|
219
|
+
messages=kwargs.get("messages"),
|
|
220
|
+
tools=kwargs.get("tools"),
|
|
221
|
+
response_format=None,
|
|
222
|
+
max_tokens=max_tokens,
|
|
223
|
+
system=kwargs.get("system"),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _attr(obj: Any, name: str, default: Any = None) -> Any:
|
|
228
|
+
if obj is None:
|
|
229
|
+
return default
|
|
230
|
+
if isinstance(obj, dict):
|
|
231
|
+
return obj.get(name, default)
|
|
232
|
+
return getattr(obj, name, default)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _messages_usage(response: Any) -> UsageReport:
|
|
236
|
+
usage = _attr(response, "usage")
|
|
237
|
+
return UsageReport(
|
|
238
|
+
response_model=_attr(response, "model"),
|
|
239
|
+
input_tokens=_attr(usage, "input_tokens"),
|
|
240
|
+
output_tokens=_attr(usage, "output_tokens"),
|
|
241
|
+
cache_read_tokens=_attr(usage, "cache_read_input_tokens"),
|
|
242
|
+
cache_write_tokens=_attr(usage, "cache_creation_input_tokens"),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _anthropic_stream_event(event: Any, usage: UsageReport) -> None:
|
|
247
|
+
"""Accumulate usage from Anthropic stream events.
|
|
248
|
+
|
|
249
|
+
Event types we care about:
|
|
250
|
+
- `message_start`: event.message.usage has initial input_tokens + cache_* and
|
|
251
|
+
a starting output_tokens value.
|
|
252
|
+
- `message_delta`: event.usage.output_tokens is the running output total.
|
|
253
|
+
"""
|
|
254
|
+
event_type = _attr(event, "type")
|
|
255
|
+
if event_type == "message_start":
|
|
256
|
+
message = _attr(event, "message")
|
|
257
|
+
model = _attr(message, "model")
|
|
258
|
+
if model:
|
|
259
|
+
usage.response_model = model
|
|
260
|
+
m_usage = _attr(message, "usage")
|
|
261
|
+
if m_usage is not None:
|
|
262
|
+
usage.input_tokens = _attr(m_usage, "input_tokens", usage.input_tokens)
|
|
263
|
+
usage.output_tokens = _attr(
|
|
264
|
+
m_usage, "output_tokens", usage.output_tokens
|
|
265
|
+
)
|
|
266
|
+
cache_read = _attr(m_usage, "cache_read_input_tokens")
|
|
267
|
+
if cache_read is not None:
|
|
268
|
+
usage.cache_read_tokens = cache_read
|
|
269
|
+
cache_write = _attr(m_usage, "cache_creation_input_tokens")
|
|
270
|
+
if cache_write is not None:
|
|
271
|
+
usage.cache_write_tokens = cache_write
|
|
272
|
+
elif event_type == "message_delta":
|
|
273
|
+
d_usage = _attr(event, "usage")
|
|
274
|
+
if d_usage is not None:
|
|
275
|
+
out = _attr(d_usage, "output_tokens")
|
|
276
|
+
if out is not None:
|
|
277
|
+
usage.output_tokens = out
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""boto3 / aiobotocore Bedrock client wrapper.
|
|
2
|
+
|
|
3
|
+
Monkey-patches the four bedrock-runtime operations on the customer's client
|
|
4
|
+
instance: `invoke_model`, `invoke_model_with_response_stream`, `converse`,
|
|
5
|
+
`converse_stream`. Detects sync (boto3) vs async (aiobotocore) per-method by
|
|
6
|
+
checking whether the bound method is a coroutine function — same pattern as
|
|
7
|
+
`wrap_openai` for `OpenAI` vs `AsyncOpenAI`.
|
|
8
|
+
|
|
9
|
+
Per-provider response-shape extraction lives in `bedrock_models.py`; this
|
|
10
|
+
file is only the glue that hooks into the existing `instrument.start_call`
|
|
11
|
+
emitter and the `SyncStreamProxy` / `AsyncStreamProxy` infrastructure.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import inspect
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any, Callable, TypeVar
|
|
20
|
+
|
|
21
|
+
from frugal_metrics import caller as caller_mod
|
|
22
|
+
from frugal_metrics import semconv
|
|
23
|
+
from frugal_metrics.config import load_config
|
|
24
|
+
from frugal_metrics.instrument import (
|
|
25
|
+
CallContext,
|
|
26
|
+
UsageReport,
|
|
27
|
+
start_call,
|
|
28
|
+
)
|
|
29
|
+
from frugal_metrics.safe import safe_call
|
|
30
|
+
from frugal_metrics.streams import AsyncStreamProxy, SyncStreamProxy
|
|
31
|
+
from frugal_metrics.wrappers import bedrock_models
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("frugal_metrics")
|
|
34
|
+
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
# The four bedrock-runtime operations we wrap.
|
|
38
|
+
_OP_INVOKE_MODEL = "invoke_model"
|
|
39
|
+
_OP_INVOKE_MODEL_STREAM = "invoke_model_with_response_stream"
|
|
40
|
+
_OP_CONVERSE = "converse"
|
|
41
|
+
_OP_CONVERSE_STREAM = "converse_stream"
|
|
42
|
+
|
|
43
|
+
_STREAMING_OPS = frozenset({_OP_INVOKE_MODEL_STREAM, _OP_CONVERSE_STREAM})
|
|
44
|
+
_INVOKE_MODEL_OPS = frozenset({_OP_INVOKE_MODEL, _OP_INVOKE_MODEL_STREAM})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def wrap_bedrock(client: T) -> T:
|
|
48
|
+
"""Instrument a boto3 / aiobotocore bedrock-runtime client in place.
|
|
49
|
+
|
|
50
|
+
Silently no-ops if:
|
|
51
|
+
- frugal-metrics config is missing,
|
|
52
|
+
- the client isn't actually bedrock-runtime (it's a different boto3 service),
|
|
53
|
+
- or the client is already wrapped.
|
|
54
|
+
"""
|
|
55
|
+
config = load_config()
|
|
56
|
+
if config is None:
|
|
57
|
+
return client
|
|
58
|
+
|
|
59
|
+
if getattr(client, "_frugal_wrapped", False):
|
|
60
|
+
return client
|
|
61
|
+
|
|
62
|
+
if not _is_bedrock_runtime_client(client):
|
|
63
|
+
return client
|
|
64
|
+
|
|
65
|
+
skip_dirs = _build_skip_dirs(client)
|
|
66
|
+
|
|
67
|
+
for op_name in (
|
|
68
|
+
_OP_INVOKE_MODEL,
|
|
69
|
+
_OP_INVOKE_MODEL_STREAM,
|
|
70
|
+
_OP_CONVERSE,
|
|
71
|
+
_OP_CONVERSE_STREAM,
|
|
72
|
+
):
|
|
73
|
+
original = getattr(client, op_name, None)
|
|
74
|
+
if original is None or getattr(original, "_frugal_wrapped", False):
|
|
75
|
+
continue
|
|
76
|
+
wrapped = (
|
|
77
|
+
_make_async(op_name, original, skip_dirs, config)
|
|
78
|
+
if inspect.iscoroutinefunction(original)
|
|
79
|
+
else _make_sync(op_name, original, skip_dirs, config)
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
wrapped._frugal_wrapped = True # type: ignore[attr-defined]
|
|
83
|
+
setattr(client, op_name, wrapped)
|
|
84
|
+
except Exception as exc: # noqa: BLE001
|
|
85
|
+
logger.warning(
|
|
86
|
+
"frugal-metrics: failed to patch bedrock-runtime.%s: %s", op_name, exc
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
setattr(client, "_frugal_wrapped", True)
|
|
91
|
+
except Exception: # noqa: BLE001
|
|
92
|
+
pass
|
|
93
|
+
return client
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- Internal helpers --------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_bedrock_runtime_client(client: Any) -> bool:
|
|
100
|
+
meta = getattr(client, "meta", None)
|
|
101
|
+
service_model = getattr(meta, "service_model", None)
|
|
102
|
+
service_name = getattr(service_model, "service_name", None)
|
|
103
|
+
return service_name == "bedrock-runtime"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_skip_dirs(client: Any) -> tuple[str, ...]:
|
|
107
|
+
"""Add botocore / boto3 / aiobotocore to the stack-walker skip list so
|
|
108
|
+
attribution lands on the customer's code rather than on AWS SDK frames.
|
|
109
|
+
"""
|
|
110
|
+
import frugal_metrics
|
|
111
|
+
|
|
112
|
+
candidates: list[Any] = [frugal_metrics]
|
|
113
|
+
for mod_name in ("botocore", "boto3", "aiobotocore"):
|
|
114
|
+
try:
|
|
115
|
+
mod = __import__(mod_name)
|
|
116
|
+
except ImportError:
|
|
117
|
+
continue
|
|
118
|
+
candidates.append(mod)
|
|
119
|
+
return tuple(
|
|
120
|
+
d for d in (caller_mod.package_dir_for(m) for m in candidates) if d
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_call_context(
|
|
125
|
+
op_name: str,
|
|
126
|
+
kwargs: dict[str, Any],
|
|
127
|
+
skip_dirs: tuple[str, ...],
|
|
128
|
+
) -> CallContext:
|
|
129
|
+
model_id = str(kwargs.get("modelId") or "<unknown>")
|
|
130
|
+
handler = bedrock_models.lookup_handler(model_id)
|
|
131
|
+
if op_name in _INVOKE_MODEL_OPS:
|
|
132
|
+
body_dict = bedrock_models.decode_invoke_model_body(kwargs.get("body"))
|
|
133
|
+
request_payload = handler.capture_invoke_model_payload(body_dict)
|
|
134
|
+
else:
|
|
135
|
+
request_payload = _capture_converse_payload(kwargs)
|
|
136
|
+
return CallContext(
|
|
137
|
+
system=semconv.SYSTEM_AWS_BEDROCK,
|
|
138
|
+
operation=semconv.OP_CHAT,
|
|
139
|
+
request_model=model_id,
|
|
140
|
+
skip_package_dirs=skip_dirs,
|
|
141
|
+
batched=False,
|
|
142
|
+
request_payload=request_payload,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _capture_converse_payload(kwargs: dict[str, Any]):
|
|
147
|
+
"""Converse takes messages as top-level kwargs, no JSON body to decode."""
|
|
148
|
+
from frugal_metrics.prompt_analyzer import RequestPayload
|
|
149
|
+
|
|
150
|
+
inference_config = kwargs.get("inferenceConfig") or {}
|
|
151
|
+
max_tokens = inference_config.get("maxTokens")
|
|
152
|
+
if not isinstance(max_tokens, int):
|
|
153
|
+
max_tokens = None
|
|
154
|
+
tool_config = kwargs.get("toolConfig") or {}
|
|
155
|
+
return RequestPayload(
|
|
156
|
+
messages=kwargs.get("messages"),
|
|
157
|
+
tools=tool_config.get("tools"),
|
|
158
|
+
response_format=None,
|
|
159
|
+
max_tokens=max_tokens,
|
|
160
|
+
system=kwargs.get("system"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _extract_non_streaming_usage(
|
|
165
|
+
op_name: str, kwargs: dict[str, Any], response: dict[str, Any]
|
|
166
|
+
) -> UsageReport:
|
|
167
|
+
model_id = str(kwargs.get("modelId") or "")
|
|
168
|
+
handler = bedrock_models.lookup_handler(model_id)
|
|
169
|
+
usage = UsageReport()
|
|
170
|
+
if op_name == _OP_CONVERSE:
|
|
171
|
+
bedrock_models.merge_usage(
|
|
172
|
+
usage, handler.extract_converse_usage(response)
|
|
173
|
+
)
|
|
174
|
+
return usage
|
|
175
|
+
# invoke_model — try per-provider body parse first, then HTTP-header fallback
|
|
176
|
+
if handler is not bedrock_models.UNIVERSAL_FALLBACK_HANDLER:
|
|
177
|
+
body_dict = bedrock_models.consume_invoke_model_response_body(response)
|
|
178
|
+
bedrock_models.merge_usage(
|
|
179
|
+
usage, handler.extract_invoke_model_usage(body_dict)
|
|
180
|
+
)
|
|
181
|
+
bedrock_models.merge_usage(
|
|
182
|
+
usage, bedrock_models.extract_universal_headers_usage(response)
|
|
183
|
+
)
|
|
184
|
+
return usage
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _make_stream_event_handler(op_name: str, model_id: str):
|
|
188
|
+
"""Return the StreamEventHandler closure for the given operation + model."""
|
|
189
|
+
handler = bedrock_models.lookup_handler(model_id)
|
|
190
|
+
|
|
191
|
+
if op_name == _OP_CONVERSE_STREAM:
|
|
192
|
+
def handle_converse(event: Any, usage: UsageReport) -> None:
|
|
193
|
+
bedrock_models.merge_usage(
|
|
194
|
+
usage, bedrock_models.extract_converse_stream_event(event)
|
|
195
|
+
)
|
|
196
|
+
return handle_converse
|
|
197
|
+
|
|
198
|
+
# invoke_model_with_response_stream — per-provider extractor first,
|
|
199
|
+
# then universal final-chunk metrics so unrecognized models still get
|
|
200
|
+
# token counts.
|
|
201
|
+
def handle_invoke_model_stream(event: Any, usage: UsageReport) -> None:
|
|
202
|
+
bedrock_models.merge_usage(
|
|
203
|
+
usage, handler.extract_invoke_model_stream_event(event)
|
|
204
|
+
)
|
|
205
|
+
if handler is not bedrock_models.ANTHROPIC_CLAUDE_HANDLER:
|
|
206
|
+
# Anthropic's per-provider extractor already covers the
|
|
207
|
+
# message_start/message_delta path; the universal metrics block
|
|
208
|
+
# only appears in the terminal chunk so applying both for non-
|
|
209
|
+
# Anthropic models is safe and additive.
|
|
210
|
+
bedrock_models.merge_usage(
|
|
211
|
+
usage,
|
|
212
|
+
bedrock_models.extract_invoke_model_stream_event_universal(event),
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
# For Anthropic specifically, only consult the universal block
|
|
216
|
+
# to fill in tokens that the per-provider extractor missed.
|
|
217
|
+
partial = bedrock_models.extract_invoke_model_stream_event_universal(event)
|
|
218
|
+
if partial is not None:
|
|
219
|
+
if usage.input_tokens is None:
|
|
220
|
+
usage.input_tokens = partial.input_tokens
|
|
221
|
+
if usage.output_tokens is None:
|
|
222
|
+
usage.output_tokens = partial.output_tokens
|
|
223
|
+
|
|
224
|
+
return handle_invoke_model_stream
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _wrap_response_stream_sync(
|
|
228
|
+
op_name: str, kwargs: dict[str, Any], response: Any, emit: Any
|
|
229
|
+
) -> Any:
|
|
230
|
+
model_id = str(kwargs.get("modelId") or "")
|
|
231
|
+
inner_stream = _resolve_stream_iterable(op_name, response)
|
|
232
|
+
handler_fn = _make_stream_event_handler(op_name, model_id)
|
|
233
|
+
proxy = SyncStreamProxy(inner_stream, emit, handler_fn)
|
|
234
|
+
response[_response_stream_key(op_name)] = proxy
|
|
235
|
+
return response
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _wrap_response_stream_async(
|
|
239
|
+
op_name: str, kwargs: dict[str, Any], response: Any, emit: Any
|
|
240
|
+
) -> Any:
|
|
241
|
+
model_id = str(kwargs.get("modelId") or "")
|
|
242
|
+
inner_stream = _resolve_stream_iterable(op_name, response)
|
|
243
|
+
handler_fn = _make_stream_event_handler(op_name, model_id)
|
|
244
|
+
proxy = AsyncStreamProxy(inner_stream, emit, handler_fn)
|
|
245
|
+
response[_response_stream_key(op_name)] = proxy
|
|
246
|
+
return response
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _resolve_stream_iterable(op_name: str, response: dict[str, Any]) -> Any:
|
|
250
|
+
"""Pick the right field on the boto3 response that holds the EventStream.
|
|
251
|
+
|
|
252
|
+
- converse_stream → response['stream']
|
|
253
|
+
- invoke_model_with_response_stream → response['body']
|
|
254
|
+
"""
|
|
255
|
+
return response.get(_response_stream_key(op_name))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _response_stream_key(op_name: str) -> str:
|
|
259
|
+
return "stream" if op_name == _OP_CONVERSE_STREAM else "body"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# --- The actual wrappers -----------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _make_sync(
|
|
266
|
+
op_name: str,
|
|
267
|
+
original: Callable[..., Any],
|
|
268
|
+
skip_dirs: tuple[str, ...],
|
|
269
|
+
config: Any,
|
|
270
|
+
) -> Callable[..., Any]:
|
|
271
|
+
@functools.wraps(original)
|
|
272
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
273
|
+
try:
|
|
274
|
+
ctx = _build_call_context(op_name, kwargs, skip_dirs)
|
|
275
|
+
emit = start_call(config, ctx)
|
|
276
|
+
except Exception as exc: # noqa: BLE001
|
|
277
|
+
_record_internal_error("setup_failed", exc, config)
|
|
278
|
+
return original(*args, **kwargs)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
response = original(*args, **kwargs)
|
|
282
|
+
except BaseException as exc:
|
|
283
|
+
safe_call(lambda: emit.error(exc))
|
|
284
|
+
raise
|
|
285
|
+
|
|
286
|
+
if op_name in _STREAMING_OPS:
|
|
287
|
+
try:
|
|
288
|
+
return _wrap_response_stream_sync(op_name, kwargs, response, emit)
|
|
289
|
+
except Exception as exc: # noqa: BLE001
|
|
290
|
+
_record_internal_error("stream_proxy_failed", exc, config)
|
|
291
|
+
return response
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
usage = _extract_non_streaming_usage(op_name, kwargs, response)
|
|
295
|
+
except Exception: # noqa: BLE001
|
|
296
|
+
usage = UsageReport()
|
|
297
|
+
safe_call(lambda: emit.success(usage))
|
|
298
|
+
return response
|
|
299
|
+
|
|
300
|
+
return wrapper
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _make_async(
|
|
304
|
+
op_name: str,
|
|
305
|
+
original: Callable[..., Any],
|
|
306
|
+
skip_dirs: tuple[str, ...],
|
|
307
|
+
config: Any,
|
|
308
|
+
) -> Callable[..., Any]:
|
|
309
|
+
@functools.wraps(original)
|
|
310
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
311
|
+
try:
|
|
312
|
+
ctx = _build_call_context(op_name, kwargs, skip_dirs)
|
|
313
|
+
emit = start_call(config, ctx)
|
|
314
|
+
except Exception as exc: # noqa: BLE001
|
|
315
|
+
_record_internal_error("setup_failed", exc, config)
|
|
316
|
+
return await original(*args, **kwargs)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
response = await original(*args, **kwargs)
|
|
320
|
+
except BaseException as exc:
|
|
321
|
+
safe_call(lambda: emit.error(exc))
|
|
322
|
+
raise
|
|
323
|
+
|
|
324
|
+
if op_name in _STREAMING_OPS:
|
|
325
|
+
try:
|
|
326
|
+
return _wrap_response_stream_async(op_name, kwargs, response, emit)
|
|
327
|
+
except Exception as exc: # noqa: BLE001
|
|
328
|
+
_record_internal_error("stream_proxy_failed", exc, config)
|
|
329
|
+
return response
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
usage = _extract_non_streaming_usage(op_name, kwargs, response)
|
|
333
|
+
except Exception: # noqa: BLE001
|
|
334
|
+
usage = UsageReport()
|
|
335
|
+
safe_call(lambda: emit.success(usage))
|
|
336
|
+
return response
|
|
337
|
+
|
|
338
|
+
return wrapper
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _record_internal_error(stage: str, exc: BaseException, config: Any) -> None:
|
|
342
|
+
"""Mirror of the same helper in wrappers/anthropic.py — emit the internal
|
|
343
|
+
error metric, swallow any failure trying to emit it.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
from frugal_metrics.otel import get_instruments
|
|
347
|
+
|
|
348
|
+
instruments = get_instruments(config)
|
|
349
|
+
err_attrs: dict[str, Any] = {
|
|
350
|
+
semconv.FRUGAL_PROJECT_ID: config.project_id,
|
|
351
|
+
"frugal.internal_error.stage": stage,
|
|
352
|
+
"frugal.internal_error.type": type(exc).__name__,
|
|
353
|
+
}
|
|
354
|
+
if config.customer_id:
|
|
355
|
+
err_attrs[semconv.FRUGAL_CUSTOMER_ID] = config.customer_id
|
|
356
|
+
instruments.internal_errors.add(1, err_attrs)
|
|
357
|
+
except Exception: # noqa: BLE001
|
|
358
|
+
pass
|