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,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