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