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,465 @@
1
+ """OpenAI client wrapper.
2
+
3
+ Monkey-patches `chat.completions.create`, `completions.create`,
4
+ `embeddings.create`, and `responses.create` on a supplied OpenAI (or
5
+ AzureOpenAI) client instance — sync or async. Streaming calls pass through
6
+ unmodified in v1; streaming support lands in M2.
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.instrument import (
20
+ CallContext,
21
+ UsageReport,
22
+ start_call,
23
+ )
24
+ from frugal_metrics.prompt_analyzer import RequestPayload
25
+ from frugal_metrics.safe import safe_call
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_openai(client: T) -> T:
34
+ """Instrument an OpenAI / AsyncOpenAI / AzureOpenAI client in place.
35
+
36
+ Returns the same client unmodified when configuration is missing (see
37
+ :func:`frugal_metrics.config.load_config`). Safe to call more than once on
38
+ the same client — subsequent calls are idempotent.
39
+ """
40
+ config = load_config()
41
+ if config is None:
42
+ return client
43
+
44
+ try:
45
+ import openai # type: ignore
46
+ except ImportError:
47
+ logger.warning("frugal-metrics: openai package not installed; cannot wrap")
48
+ return client
49
+
50
+ if getattr(client, "_frugal_wrapped", False):
51
+ return client
52
+
53
+ system = _detect_system(client)
54
+ skip_dirs = _build_skip_dirs(openai)
55
+
56
+ _patch(
57
+ client,
58
+ attr_chain=("chat", "completions"),
59
+ method_name="create",
60
+ operation=semconv.OP_CHAT,
61
+ system=system,
62
+ skip_dirs=skip_dirs,
63
+ config=config,
64
+ usage_extractor=_chat_usage,
65
+ stream_handler=_chat_stream_event,
66
+ capture_payload=True,
67
+ )
68
+ _patch(
69
+ client,
70
+ attr_chain=("completions",),
71
+ method_name="create",
72
+ operation=semconv.OP_TEXT_COMPLETION,
73
+ system=system,
74
+ skip_dirs=skip_dirs,
75
+ config=config,
76
+ usage_extractor=_completion_usage,
77
+ stream_handler=_completion_stream_event,
78
+ capture_payload=True,
79
+ )
80
+ _patch(
81
+ client,
82
+ attr_chain=("embeddings",),
83
+ method_name="create",
84
+ operation=semconv.OP_EMBEDDINGS,
85
+ system=system,
86
+ skip_dirs=skip_dirs,
87
+ config=config,
88
+ usage_extractor=_embedding_usage,
89
+ stream_handler=None, # embeddings don't stream
90
+ )
91
+ # `responses` is only present on newer SDK versions.
92
+ _patch(
93
+ client,
94
+ attr_chain=("responses",),
95
+ method_name="create",
96
+ operation=semconv.OP_CHAT,
97
+ system=system,
98
+ skip_dirs=skip_dirs,
99
+ config=config,
100
+ usage_extractor=_responses_usage,
101
+ stream_handler=_responses_stream_event,
102
+ required=False,
103
+ capture_payload=True,
104
+ )
105
+
106
+ try:
107
+ setattr(client, "_frugal_wrapped", True)
108
+ except Exception: # noqa: BLE001
109
+ pass
110
+ return client
111
+
112
+
113
+ def _detect_system(client: Any) -> str:
114
+ # v1 supports OpenAI only. AzureOpenAI clients land here too but are tagged
115
+ # as plain `openai` until native Azure support ships.
116
+ return semconv.SYSTEM_OPENAI
117
+
118
+
119
+ def _build_skip_dirs(openai_mod: Any) -> tuple[str, ...]:
120
+ import frugal_metrics
121
+
122
+ return tuple(
123
+ d
124
+ for d in (
125
+ caller_mod.package_dir_for(frugal_metrics),
126
+ caller_mod.package_dir_for(openai_mod),
127
+ )
128
+ if d
129
+ )
130
+
131
+
132
+ def _resolve_target(client: Any, attr_chain: tuple[str, ...]) -> Any | None:
133
+ target: Any = client
134
+ for attr in attr_chain:
135
+ target = getattr(target, attr, None)
136
+ if target is None:
137
+ return None
138
+ return target
139
+
140
+
141
+ def _patch(
142
+ client: Any,
143
+ *,
144
+ attr_chain: tuple[str, ...],
145
+ method_name: str,
146
+ operation: str,
147
+ system: str,
148
+ skip_dirs: tuple[str, ...],
149
+ config: Any,
150
+ usage_extractor: Callable[[Any], UsageReport],
151
+ stream_handler: Callable[[Any, UsageReport], None] | None,
152
+ required: bool = True,
153
+ capture_payload: bool = False,
154
+ ) -> None:
155
+ target = _resolve_target(client, attr_chain)
156
+ if target is None:
157
+ if required:
158
+ logger.debug(
159
+ "frugal-metrics: %s missing on %s; skipping",
160
+ ".".join(attr_chain),
161
+ type(client).__name__,
162
+ )
163
+ return
164
+
165
+ original = getattr(target, method_name, None)
166
+ if original is None:
167
+ return
168
+ if getattr(original, "_frugal_wrapped", False):
169
+ return
170
+
171
+ is_async = inspect.iscoroutinefunction(original)
172
+ wrapped = (
173
+ _make_async_wrapper(
174
+ original,
175
+ operation,
176
+ system,
177
+ skip_dirs,
178
+ config,
179
+ usage_extractor,
180
+ stream_handler,
181
+ capture_payload,
182
+ )
183
+ if is_async
184
+ else _make_sync_wrapper(
185
+ original,
186
+ operation,
187
+ system,
188
+ skip_dirs,
189
+ config,
190
+ usage_extractor,
191
+ stream_handler,
192
+ capture_payload,
193
+ )
194
+ )
195
+ wrapped._frugal_wrapped = True # type: ignore[attr-defined]
196
+ try:
197
+ setattr(target, method_name, wrapped)
198
+ except Exception as exc: # noqa: BLE001
199
+ logger.warning(
200
+ "frugal-metrics: failed to patch %s.%s: %s",
201
+ ".".join(attr_chain),
202
+ method_name,
203
+ exc,
204
+ )
205
+
206
+
207
+ def _make_sync_wrapper(
208
+ original: Callable[..., Any],
209
+ operation: str,
210
+ system: str,
211
+ skip_dirs: tuple[str, ...],
212
+ config: Any,
213
+ usage_extractor: Callable[[Any], UsageReport],
214
+ stream_handler: Callable[[Any, UsageReport], None] | None,
215
+ capture_payload: bool,
216
+ ) -> Callable[..., Any]:
217
+ @functools.wraps(original)
218
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
219
+ # If ANY of our instrumentation logic fails, fall through to the
220
+ # original call unmodified — we must never break customer code.
221
+ try:
222
+ streaming = _is_streaming(kwargs)
223
+ if streaming and stream_handler is None:
224
+ return original(*args, **kwargs)
225
+ ctx = CallContext(
226
+ system=system,
227
+ operation=operation,
228
+ request_model=_request_model(kwargs),
229
+ skip_package_dirs=skip_dirs,
230
+ batched=False,
231
+ request_payload=(
232
+ _capture_openai_payload(kwargs) if capture_payload else None
233
+ ),
234
+ )
235
+ emit = start_call(config, ctx)
236
+ except Exception as exc: # noqa: BLE001
237
+ _record_internal_error("setup_failed", exc, config)
238
+ return original(*args, **kwargs)
239
+
240
+ try:
241
+ response = original(*args, **kwargs)
242
+ except BaseException as exc:
243
+ safe_call(lambda: emit.error(exc))
244
+ raise
245
+
246
+ if streaming:
247
+ try:
248
+ return SyncStreamProxy(response, emit, stream_handler) # type: ignore[arg-type]
249
+ except Exception as exc: # noqa: BLE001
250
+ _record_internal_error("stream_proxy_failed", exc, config)
251
+ return response
252
+
253
+ safe_call(lambda: emit.success(usage_extractor(response)))
254
+ return response
255
+
256
+ return wrapper
257
+
258
+
259
+ def _make_async_wrapper(
260
+ original: Callable[..., Any],
261
+ operation: str,
262
+ system: str,
263
+ skip_dirs: tuple[str, ...],
264
+ config: Any,
265
+ usage_extractor: Callable[[Any], UsageReport],
266
+ stream_handler: Callable[[Any, UsageReport], None] | None,
267
+ capture_payload: bool,
268
+ ) -> Callable[..., Any]:
269
+ @functools.wraps(original)
270
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
271
+ try:
272
+ streaming = _is_streaming(kwargs)
273
+ if streaming and stream_handler is None:
274
+ return await original(*args, **kwargs)
275
+ ctx = CallContext(
276
+ system=system,
277
+ operation=operation,
278
+ request_model=_request_model(kwargs),
279
+ skip_package_dirs=skip_dirs,
280
+ batched=False,
281
+ request_payload=(
282
+ _capture_openai_payload(kwargs) if capture_payload else None
283
+ ),
284
+ )
285
+ emit = start_call(config, ctx)
286
+ except Exception as exc: # noqa: BLE001
287
+ _record_internal_error("setup_failed", exc, config)
288
+ return await original(*args, **kwargs)
289
+
290
+ try:
291
+ response = await original(*args, **kwargs)
292
+ except BaseException as exc:
293
+ safe_call(lambda: emit.error(exc))
294
+ raise
295
+
296
+ if streaming:
297
+ try:
298
+ return AsyncStreamProxy(response, emit, stream_handler) # type: ignore[arg-type]
299
+ except Exception as exc: # noqa: BLE001
300
+ _record_internal_error("stream_proxy_failed", exc, config)
301
+ return response
302
+
303
+ safe_call(lambda: emit.success(usage_extractor(response)))
304
+ return response
305
+
306
+ return wrapper
307
+
308
+
309
+ def _record_internal_error(stage: str, exc: BaseException, config: Any) -> None:
310
+ """Try to emit an internal error metric; swallow any failure."""
311
+ try:
312
+ from frugal_metrics.otel import get_instruments
313
+
314
+ instruments = get_instruments(config)
315
+ err_attrs: dict[str, Any] = {
316
+ semconv.FRUGAL_PROJECT_ID: config.project_id,
317
+ "frugal.internal_error.stage": stage,
318
+ "frugal.internal_error.type": type(exc).__name__,
319
+ }
320
+ if config.customer_id:
321
+ err_attrs[semconv.FRUGAL_CUSTOMER_ID] = config.customer_id
322
+ instruments.internal_errors.add(1, err_attrs)
323
+ except Exception: # noqa: BLE001
324
+ pass
325
+
326
+
327
+ def _is_streaming(kwargs: dict[str, Any]) -> bool:
328
+ return bool(kwargs.get("stream"))
329
+
330
+
331
+ def _request_model(kwargs: dict[str, Any]) -> str:
332
+ model = kwargs.get("model")
333
+ return str(model) if model is not None else "<unknown>"
334
+
335
+
336
+ def _capture_openai_payload(kwargs: dict[str, Any]) -> RequestPayload:
337
+ max_tokens = kwargs.get("max_tokens", kwargs.get("max_completion_tokens"))
338
+ if not isinstance(max_tokens, int):
339
+ max_tokens = None
340
+ # The Responses API uses `input` instead of `messages`. Treat both as
341
+ # messages for analyzer purposes — they share the role/content shape.
342
+ messages = kwargs.get("messages") if "messages" in kwargs else kwargs.get("input")
343
+ return RequestPayload(
344
+ messages=messages,
345
+ tools=kwargs.get("tools"),
346
+ response_format=kwargs.get("response_format"),
347
+ max_tokens=max_tokens,
348
+ )
349
+
350
+
351
+ # --- usage extractors ------------------------------------------------------
352
+
353
+
354
+ def _attr(obj: Any, name: str, default: Any = None) -> Any:
355
+ """Get attribute from either a Pydantic-model-like object or a dict."""
356
+ if obj is None:
357
+ return default
358
+ if isinstance(obj, dict):
359
+ return obj.get(name, default)
360
+ return getattr(obj, name, default)
361
+
362
+
363
+ def _chat_usage(response: Any) -> UsageReport:
364
+ usage = _attr(response, "usage")
365
+ comp_details = _attr(usage, "completion_tokens_details")
366
+ prompt_details = _attr(usage, "prompt_tokens_details")
367
+ return UsageReport(
368
+ response_model=_attr(response, "model"),
369
+ input_tokens=_attr(usage, "prompt_tokens"),
370
+ output_tokens=_attr(usage, "completion_tokens"),
371
+ reasoning_tokens=_attr(comp_details, "reasoning_tokens"),
372
+ cache_read_tokens=_attr(prompt_details, "cached_tokens"),
373
+ )
374
+
375
+
376
+ def _completion_usage(response: Any) -> UsageReport:
377
+ usage = _attr(response, "usage")
378
+ return UsageReport(
379
+ response_model=_attr(response, "model"),
380
+ input_tokens=_attr(usage, "prompt_tokens"),
381
+ output_tokens=_attr(usage, "completion_tokens"),
382
+ )
383
+
384
+
385
+ def _embedding_usage(response: Any) -> UsageReport:
386
+ usage = _attr(response, "usage")
387
+ return UsageReport(
388
+ response_model=_attr(response, "model"),
389
+ input_tokens=_attr(usage, "prompt_tokens"),
390
+ output_tokens=None,
391
+ )
392
+
393
+
394
+ def _responses_usage(response: Any) -> UsageReport:
395
+ """OpenAI Responses API — `usage.input_tokens` / `usage.output_tokens`."""
396
+ usage = _attr(response, "usage")
397
+ out_details = _attr(usage, "output_tokens_details")
398
+ in_details = _attr(usage, "input_tokens_details")
399
+ return UsageReport(
400
+ response_model=_attr(response, "model"),
401
+ input_tokens=_attr(usage, "input_tokens"),
402
+ output_tokens=_attr(usage, "output_tokens"),
403
+ reasoning_tokens=_attr(out_details, "reasoning_tokens"),
404
+ cache_read_tokens=_attr(in_details, "cached_tokens"),
405
+ )
406
+
407
+
408
+ # --- stream handlers -------------------------------------------------------
409
+ #
410
+ # For OpenAI chat/completions streams, usage appears only on the final chunk
411
+ # and only when `stream_options={"include_usage": True}` is passed. We record
412
+ # whatever we see; if the customer didn't opt in, we emit the call counter but
413
+ # no token counts.
414
+
415
+
416
+ def _chat_stream_event(chunk: Any, usage: UsageReport) -> None:
417
+ model = _attr(chunk, "model")
418
+ if model and not usage.response_model:
419
+ usage.response_model = model
420
+ chunk_usage = _attr(chunk, "usage")
421
+ if chunk_usage is None:
422
+ return
423
+ usage.input_tokens = _attr(chunk_usage, "prompt_tokens", usage.input_tokens)
424
+ usage.output_tokens = _attr(chunk_usage, "completion_tokens", usage.output_tokens)
425
+ comp_details = _attr(chunk_usage, "completion_tokens_details")
426
+ if comp_details is not None:
427
+ reasoning = _attr(comp_details, "reasoning_tokens")
428
+ if reasoning is not None:
429
+ usage.reasoning_tokens = reasoning
430
+ prompt_details = _attr(chunk_usage, "prompt_tokens_details")
431
+ if prompt_details is not None:
432
+ cached = _attr(prompt_details, "cached_tokens")
433
+ if cached is not None:
434
+ usage.cache_read_tokens = cached
435
+
436
+
437
+ def _completion_stream_event(chunk: Any, usage: UsageReport) -> None:
438
+ # Legacy /v1/completions streams share the chat chunk shape for usage.
439
+ _chat_stream_event(chunk, usage)
440
+
441
+
442
+ def _responses_stream_event(chunk: Any, usage: UsageReport) -> None:
443
+ # Responses API streams emit ResponseEvent objects; the terminal
444
+ # "response.completed" event carries the full response including usage.
445
+ response = _attr(chunk, "response")
446
+ if response is None:
447
+ return
448
+ model = _attr(response, "model")
449
+ if model:
450
+ usage.response_model = model
451
+ r_usage = _attr(response, "usage")
452
+ if r_usage is None:
453
+ return
454
+ usage.input_tokens = _attr(r_usage, "input_tokens", usage.input_tokens)
455
+ usage.output_tokens = _attr(r_usage, "output_tokens", usage.output_tokens)
456
+ out_details = _attr(r_usage, "output_tokens_details")
457
+ if out_details is not None:
458
+ reasoning = _attr(out_details, "reasoning_tokens")
459
+ if reasoning is not None:
460
+ usage.reasoning_tokens = reasoning
461
+ in_details = _attr(r_usage, "input_tokens_details")
462
+ if in_details is not None:
463
+ cached = _attr(in_details, "cached_tokens")
464
+ if cached is not None:
465
+ usage.cache_read_tokens = cached
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: frugal-sdk-python
3
+ Version: 0.1.0
4
+ Summary: Instrumentation for Cost-to-Code attribution.
5
+ Project-URL: Homepage, https://frugal.co
6
+ Author: Frugal AI Inc.
7
+ License: Apache-2.0
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: opentelemetry-api>=1.25.0
16
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.25.0
17
+ Requires-Dist: opentelemetry-sdk>=1.25.0
18
+ Provides-Extra: anthropic
19
+ Requires-Dist: anthropic>=0.34.0; extra == 'anthropic'
20
+ Provides-Extra: dev
21
+ Requires-Dist: anthropic>=0.34.0; extra == 'dev'
22
+ Requires-Dist: openai>=1.40.0; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: reuse[charset-normalizer]>=5.0; extra == 'dev'
26
+ Provides-Extra: openai
27
+ Requires-Dist: openai>=1.40.0; extra == 'openai'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # Frugal SDK for Python
31
+
32
+ Instrument AI API calls with one line of code. Get per-call-site metrics — token usage, latency, call counts — exported via OpenTelemetry to any collector.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install frugal-sdk-python
38
+
39
+ # Plus whichever provider SDKs you use:
40
+ pip install openai anthropic
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from frugal_metrics import wrap_openai, wrap_anthropic, wrap_bedrock
47
+ from openai import OpenAI
48
+ from anthropic import Anthropic
49
+ import boto3
50
+
51
+ client = wrap_openai(OpenAI())
52
+ anthropic_client = wrap_anthropic(Anthropic())
53
+ bedrock_client = wrap_bedrock(boto3.client("bedrock-runtime", region_name="us-west-2"))
54
+
55
+ # Every call now emits metrics — no other code changes needed
56
+ response = client.chat.completions.create(model="gpt-4o", messages=[...])
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ All configuration is via environment variables. No `init()` call, no configuration objects.
62
+
63
+ ### Required
64
+
65
+ Set both or the wrapper silently returns the client unmodified:
66
+
67
+ | Variable | Purpose |
68
+ | ------------------- | ---------------------------------------------------------- |
69
+ | `FRUGAL_API_KEY` | Per-customer bearer token issued by Frugal. Builds the export `Authorization` header. (Or supply the bearer yourself via `FRUGAL_OTEL_EXPORTER_OTLP_HEADERS`.) |
70
+ | `FRUGAL_PROJECT_ID` | The Frugal project this deployment belongs to. Stats are attributed per project, so the same repo across microservices no longer collides. |
71
+
72
+ ### Optional
73
+
74
+ | Variable | Purpose |
75
+ | ---------------------------------------- | --------------------------------------------------------------------------------------- |
76
+ | `FRUGAL_CUSTOMER_ID` | Customer ID issued by Frugal. When unset, the tenant label (auto-injected server-side by VMAuth from the bearer token) identifies the customer and `frugal.customer_id` is omitted. |
77
+ | `FRUGAL_REPO_ROOT` | Absolute path to the source root; the strip prefix for `caller_file`. Defaults to the process working directory. |
78
+ | `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL for Frugal metrics. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
79
+ | `FRUGAL_OTEL_EXPORTER_OTLP_HEADERS` | Export auth headers (e.g. `Authorization=Bearer ...`). Overrides the header built from `FRUGAL_API_KEY`. Falls back to `OTEL_EXPORTER_OTLP_HEADERS`. |
80
+ | `FRUGAL_COMPONENTS` | Comma-separated logical components (e.g. `ai-handler,reco`). When set, each metric is ALSO emitted once per component (tagged `frugal.component`) on top of the base project series, so usage can be viewed for the whole project or split by component. |
81
+ | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
82
+ | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
83
+ | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
84
+ | `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE` | Fraction of calls (0..1) that run the lightweight prompt analyzer. Default `0.1`. Set to `0` to disable, `1` for every call. |
85
+ | `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
86
+
87
+ ## What gets captured
88
+
89
+ Every wrapped call emits OTel metrics tagged with the caller's source file and function, the provider, and the model:
90
+
91
+ | Metric | Kind | Description | Additional attributes |
92
+ | --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
93
+ | `gen_ai.client.operation.duration` | histogram | Call latency in seconds | — |
94
+ | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type` |
95
+ | `frugal.gen_ai.calls` | counter | Number of calls per site | — |
96
+ | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
97
+ | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
98
+ | `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
99
+ | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
100
+ | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
101
+ | `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
102
+ | `frugal.gen_ai.history_depth` | histogram | `messages.length` (analyzer-sampled) | — |
103
+ | `frugal.gen_ai.few_shot_count` | histogram | Few-shot exemplar pairs before final user (analyzer-sampled) | — |
104
+ | `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
105
+ | `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
106
+ | `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
107
+
108
+ The bottom block of analyzer-sampled metrics fires on a fraction of calls
109
+ (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics are
110
+ 100% — never sampled.
111
+
112
+ Every metric carries this base attribute set:
113
+
114
+ - `frugal.project_id` — from your env vars (`frugal.customer_id` too when set); `frugal.component` is added on the per-component series when `FRUGAL_COMPONENTS` is set
115
+ - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
116
+ - `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `vertex_ai`
117
+ - `gen_ai.request.model`, `gen_ai.response.model` — the model used
118
+ - `gen_ai.operation.name` — `chat`, `text_completion`, or `embeddings`
119
+ - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
120
+
121
+ The "Additional attributes" column above lists what each metric carries on top of the base set. See [`docs/metrics-reference.md`](docs/metrics-reference.md) for the values each attribute takes, sentinel values for caller fields, and the full internal-error-stage list.
122
+
123
+ Metrics are aggregated per export interval (not sent per call), so network overhead is minimal regardless of call volume.
124
+
125
+ ## Supported providers
126
+
127
+ | Provider | Usage |
128
+ | ---------------------------------------------- | ------------------------------------------------------ |
129
+ | OpenAI | `wrap_openai(OpenAI())` |
130
+ | Anthropic | `wrap_anthropic(Anthropic())` |
131
+ | Claude on AWS Bedrock (via Anthropic SDK) | `wrap_anthropic(AnthropicBedrock())` |
132
+ | AWS Bedrock — any model, AWS SDKs directly | `wrap_bedrock(boto3.client("bedrock-runtime"))` |
133
+ | Claude on Google Vertex | `wrap_anthropic(AnthropicVertex())` |
134
+
135
+ `wrap_bedrock` covers all four `bedrock-runtime` operations (`invoke_model`,
136
+ `invoke_model_with_response_stream`, `converse`, `converse_stream`) across
137
+ every model on Bedrock — Anthropic, OpenAI gpt-oss, Amazon Nova, Meta Llama,
138
+ Mistral, Cohere, AI21, DeepSeek, Qwen, Kimi, etc. Cross-region inference
139
+ profile prefixes (`us.`, `eu.`, `jp.`, `apac.`, `au.`, `global.`) are
140
+ preserved in `gen_ai.request.model`. Async clients (`aiobotocore` /
141
+ `aioboto3`) are detected automatically. See [`docs/quickstart.md`](docs/quickstart.md#native-aws-bedrock-wrap_bedrock) for the full Bedrock walkthrough.
142
+
143
+ ## Streaming
144
+
145
+ Streaming works with no extra setup. The wrapper returns a drop-in replacement that passes events through and emits metrics when the stream finishes.
146
+
147
+ ```python
148
+ # OpenAI — opt in to include_usage for token counts in streams
149
+ stream = client.chat.completions.create(
150
+ model="gpt-4o",
151
+ messages=[...],
152
+ stream=True,
153
+ stream_options={"include_usage": True},
154
+ )
155
+ for chunk in stream:
156
+ ...
157
+
158
+ # Anthropic — token counts available natively, no opt-in needed
159
+ stream = anthropic_client.messages.create(
160
+ model="claude-sonnet-4-20250514",
161
+ max_tokens=1024,
162
+ messages=[...],
163
+ stream=True,
164
+ )
165
+ for event in stream:
166
+ ...
167
+ ```
168
+
169
+ ## Internal library attribution
170
+
171
+ If your code calls an internal helper library that wraps the AI SDK (e.g. `acme_ai_helpers`), add it to the block list so attribution lands on your business code:
172
+
173
+ ```bash
174
+ FRUGAL_PATH_BLOCK_LIST="src/acme_ai_helpers,libs/llm_utils"
175
+ ```
176
+
177
+ ## Safety guarantees
178
+
179
+ - If any required env var is unset, `wrap_openai` / `wrap_anthropic` return the client completely unmodified with zero overhead.
180
+ - If any part of our instrumentation fails at runtime (OTel init, stack walking, metric emission), the original SDK call executes normally. We never raise our own exceptions into your code.
181
+ - Wrapping is idempotent — calling `wrap_openai(client)` twice on the same client is safe.
182
+
183
+ ## Verifying the setup
184
+
185
+ Set the OTLP endpoint to empty to print metrics to the console instead of exporting them:
186
+
187
+ ```bash
188
+ export FRUGAL_API_KEY=local
189
+ export FRUGAL_PROJECT_ID=my-project
190
+ export FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT= # empty → ConsoleMetricExporter
191
+ export FRUGAL_METRIC_EXPORT_INTERVAL_MS=5000
192
+ python my_script.py
193
+ ```
194
+
195
+ You should see JSON metric records with `gen_ai.*` and `frugal.*` attributes every 5 seconds.
@@ -0,0 +1,18 @@
1
+ frugal_metrics/__init__.py,sha256=BNGv6yC3amP1DPvrn7RHO1GmfzPDfkJJxqcOYNNM0L8,474
2
+ frugal_metrics/block_hash_lru.py,sha256=hv3VMS6xdjIraceA8Rih9bFeKGjbAqLhaL-nHv9bxwI,2223
3
+ frugal_metrics/caller.py,sha256=uiXLROjZ9sD54uUUinuAcgWq6El05B6HZzwSUdLkx1E,7045
4
+ frugal_metrics/config.py,sha256=53ed-Aun14MK7Ca0OJR2YX_rae8SU2ApXdwpXSeI1jY,8548
5
+ frugal_metrics/instrument.py,sha256=YVvR8uSnczTuwbfi1rl-HBxUULEyIndSo2X-wCsiqMA,9317
6
+ frugal_metrics/otel.py,sha256=ZRE-n7hWo6n1T4-3BktQ5zieuaqyE_tNI6f_TxDOe6E,11634
7
+ frugal_metrics/prompt_analyzer.py,sha256=GwxwULD73v-Qfr0cAKpd3DdusqjrbEUCuYhYh6l3EYc,8379
8
+ frugal_metrics/safe.py,sha256=V04QVgYYGdXM67MJjfBew4g2VF1jJ-JiQGXs3wxOp2k,1068
9
+ frugal_metrics/semconv.py,sha256=OVy79s9oKKAV6dqK_lG1vX6YpCS7geM-gxxQZ8ZRv9g,2573
10
+ frugal_metrics/streams.py,sha256=5nawTD08yAS-I_VQK2g_GhtMOmaqWaJGX23SuHs5YE0,5085
11
+ frugal_metrics/wrappers/__init__.py,sha256=asuO1kRv3sn_C5YVH81m9js5HcBErIzzY197CyWlxWE,45
12
+ frugal_metrics/wrappers/anthropic.py,sha256=3EpN2SZ9bBFY0IMFCBARSkVEQ1hXvAOaSjcuym2248I,8960
13
+ frugal_metrics/wrappers/bedrock.py,sha256=LsMh5BKKZsHPmSVnN2s-ko60ooqGB3RnXqbn3f2xVgk,12692
14
+ frugal_metrics/wrappers/bedrock_models.py,sha256=LMQsXpQ9p6P18_1LKeKF0A3wk5vMS5xIe6CKcWfYUYQ,15065
15
+ frugal_metrics/wrappers/openai.py,sha256=-F7PWx_9Zgze8fbEDaYg9Pe5sSmj0R55MRhCl-szqK4,15212
16
+ frugal_sdk_python-0.1.0.dist-info/METADATA,sha256=NR_6bLR3DPgAjrEYXieYaZgPIDvE9Nbtfh1FEbPlWvQ,11798
17
+ frugal_sdk_python-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
18
+ frugal_sdk_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any