Frugal SDK for Python
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.
Install
pip install frugal-sdk-python
# Plus whichever provider SDKs you use:
pip install openai anthropic
Quick start
from frugal_metrics import wrap_openai, wrap_anthropic, wrap_bedrock
from openai import OpenAI
from anthropic import Anthropic
import boto3
client = wrap_openai(OpenAI())
anthropic_client = wrap_anthropic(Anthropic())
bedrock_client = wrap_bedrock(boto3.client("bedrock-runtime", region_name="us-west-2"))
# Every call now emits metrics — no other code changes needed
response = client.chat.completions.create(model="gpt-4o", messages=[...])
Configuration
All configuration is via environment variables. No init() call, no configuration objects.
Required
Console mode (FRUGAL_METRICS_EXPORTER=console) needs only FRUGAL_PROJECT_ID; every other mode needs both. If a required variable is unset, the wrapper silently returns the client unmodified:
| Variable | Purpose |
|---|---|
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.) |
FRUGAL_PROJECT_ID |
The Frugal project this deployment belongs to. Stats are attributed per project, so the same repo across microservices no longer collides. |
Optional
The export endpoint and authentication come only from FRUGAL_-prefixed variables, so the SDK never crosses wires with the host's own OTel pipeline: the generic OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS are never read. (The OTel exporter library still reads its own generic TLS, compression, timeout and temporality variables.)
| Variable | Purpose |
|---|---|
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. |
FRUGAL_REPO_ROOT |
Absolute path to the source root; the strip prefix for caller_file. Defaults to the process working directory. |
FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP collector URL for Frugal metrics. Defaults to https://metrics.frugal.co/opentelemetry/v1/metrics (also when set but empty). |
FRUGAL_OTEL_EXPORTER_OTLP_HEADERS |
Export auth headers (e.g. Authorization=Bearer ...). Precedence: this variable wins; otherwise the header built from FRUGAL_API_KEY (FRUGAL_METRICS_API_KEY first), which is sent verbatim. Values set here are percent-decoded (Bearer%20tok → Bearer tok). A value with no key=value pair is treated as missing auth (the API key is not used instead); a blank (whitespace-only) value is treated as unset. |
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. Deduped, and only the first 10 entries are honored (hard cap, not configurable); the rest are dropped at startup with one debug warning. |
FRUGAL_CUSTOM_PROVIDERS |
Comma-separated allowlist of provider names beyond the built-in registry (e.g. together_ai,fireworks) that should pass through verbatim on gen_ai.system / gen_ai.provider.name instead of being coerced to custom. Capped at 10 (extras are coerced) to keep label cardinality bounded. |
FRUGAL_CUSTOM_LABEL |
Comma-separated allowlist of values for the frugal.custom_label label (e.g. billing,search). Applies only to track_ai_call / start_ai_call calls that pass custom_label=.... The vocabulary is the first 20 comma-separated entries by raw position (hard cap, not configurable); empty entries are ignored and duplicates collapsed. other is reserved (the coercion sentinel) and is ignored with one warning. A value outside the allowlist is coerced to other with a bounded once-per-value warning; when the variable is unset, every value is other. One warning fires only when non-empty entries past the 20th are actually dropped (a trailing comma does not warn). |
FRUGAL_METRICS_EXPORTER |
otlp (default) or console (print metrics to stdout; console mode needs only FRUGAL_PROJECT_ID; the API key, endpoint and header variables are ignored). Case-insensitive; any other value warns once and uses otlp. |
FRUGAL_METRICS_PROJECT_ID / FRUGAL_METRICS_API_KEY |
Namespaced overrides read before FRUGAL_PROJECT_ID / FRUGAL_API_KEY, for apps that already use those names. An empty value falls through to the bare name. |
FRUGAL_PATH_BLOCK_LIST |
Comma-separated repo-relative paths to skip for caller attribution (see below). |
FRUGAL_METRIC_EXPORT_INTERVAL_MS |
Export cadence in milliseconds, 1-2147483647. Default 60000; a value outside the range warns once and uses the default. |
FRUGAL_MAX_CALL_SITES |
Cardinality cap — max distinct (caller_file, caller_function) pairs to attribute; past it frugal.caller_file / frugal.caller_function become <overflow>. Default 500. Set to 0 to disable. |
FRUGAL_MAX_MODELS |
Cardinality cap — max distinct model strings to attribute; past it gen_ai.request.model / gen_ai.response.model become <overflow>. One registry shared by both labels, so a request alias and its dated response snapshot each take a slot. Default 100. Set to 0 to disable. |
FRUGAL_MAX_SERIES_PER_INSTRUMENT |
OpenTelemetry's per-instrument limit on distinct attribute combinations, raised from the SDK default. Default 50000. Set to 0 for effectively unlimited (a 1,000,000 ceiling). Applied only when the installed opentelemetry-sdk accepts a per-instrument cardinality limit — current releases (through 1.42) do not implement one, so nothing is capped and setting this variable emits one warn telling you it has no effect. |
FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE |
Fraction of calls that run the lightweight prompt analyzer: a decimal in [0, 1], plain or scientific notation (e.g. 0.25, 1e-1); anything else warns once and uses 0.1. Default 0.1. Set to 0 to disable, 1 for every call. |
FRUGAL_DUPLICATE_REQUEST_DETECTION |
Duplicate-request detection (the frugal.gen_ai.duplicate_requests metric) is on by default and hashes each chat request in-process (the hash never leaves the SDK). Set to 0/false/no/off to disable — no hashing, no filter memory, no metric. |
FRUGAL_DUPLICATE_WINDOW_SECONDS |
How long a request signature is remembered for duplicate detection. Default 3600, max 86400 (1 day). |
FRUGAL_DUPLICATE_CAPACITY |
Expected distinct request signatures per window (sizes the in-memory filter). Default 10000, max 1000000. Raise it if the SDK warns the filter is saturated. |
FRUGAL_DUPLICATE_FP_RATE |
Target false-positive rate of the duplicate filter. Default 0.01, clamped to [1e-6, 0.5]. |
FRUGAL_DUPLICATE_GENERATIONS |
Rotation granularity of the duplicate filter's time window. Default 2, max 16. |
FRUGAL_DISABLED |
Set to 1 to disable all instrumentation without uninstalling. |
What gets captured
Every wrapped call emits OTel metrics tagged with the caller's source file and function, the provider, and the model:
| Metric | Kind | Description | Additional attributes |
|---|---|---|---|
gen_ai.client.operation.duration |
histogram | Call latency in seconds | — |
gen_ai.client.token.usage |
histogram | Input and output token counts | gen_ai.token.type, frugal.history_bucket |
frugal.gen_ai.calls |
counter | Number of calls per site | frugal.history_bucket, frugal.structured_output_mode |
frugal.gen_ai.cache_read_tokens |
histogram | Prompt cache hits | — |
frugal.gen_ai.cache_write_tokens |
histogram | Prompt cache writes | — |
frugal.gen_ai.reasoning_tokens |
histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens | — |
frugal.gen_ai.agent.turns |
histogram | Model-response turns per successful invoke_agent call (manual only) |
— |
frugal.gen_ai.errors |
counter | Errors by exception type | frugal.error.type |
frugal.gen_ai.internal_errors |
counter | frugal-metrics internal failures (cardinality caps, bugs) | frugal.internal_error.stage, frugal.internal_error.type, frugal.instrument |
frugal.gen_ai.output_cap_utilization |
histogram | output_tokens / max_tokens ratio when max_tokens is set |
frugal.structured_output_mode |
frugal.gen_ai.history_depth |
histogram | messages.length — every chat call (100%, not sampled) |
— |
frugal.gen_ai.duplicate_requests |
counter | Repeat-request detection — every chat call (100%, not sampled; disable via FRUGAL_DUPLICATE_REQUEST_DETECTION) |
frugal.dup_state |
frugal.gen_ai.few_shot_count |
histogram | Few-shot exemplar pairs before final user (analyzer-sampled) | — |
frugal.gen_ai.few_shot_tokens |
histogram | Approx token size of the few-shot example block (analyzer-sampled) | — |
frugal.gen_ai.noise_ratio |
histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
frugal.gen_ai.cache_prefix_stable |
counter | Per-block prompt-cache stability (analyzer-sampled) | frugal.block, frugal.stable |
frugal.gen_ai.structured_output_hint |
counter | "Return JSON" prompts missing response_format/tools |
— |
The analyzer-sampled metrics fire on a fraction of calls
(FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE, default 10%); the token-side metrics,
history_depth, and duplicate_requests are 100% — never sampled.
Every metric carries this base attribute set (except
frugal.gen_ai.internal_errors, which is emitted from failure paths where
these attributes may not exist yet and carries only frugal.project_id, the
stage/type, frugal.instrument on the otel_cardinality_overflow stage, and
frugal.customer_id / frugal.custom_label when set; frugal.custom_label
never on the setup_failed / emission stages):
frugal.project_id— from your env vars (frugal.customer_idtoo when set);frugal.componentis added on the per-component series whenFRUGAL_COMPONENTSis setfrugal.caller_file,frugal.caller_function— auto-detected from the call stackgen_ai.system+gen_ai.provider.name— the provider, dual-emitted under both the legacy and current OTel attribute (openai,anthropic,aws.bedrock,vertex_ai/gcp.vertex_ai, …, orcustom). Set via thesystemkwarg; an unrecognized value is coerced tocustomwith a one-time warning. Seedocs/metrics-reference.md.gen_ai.request.model,gen_ai.response.model— the model used; both draw on one shared registry and become<overflow>pastFRUGAL_MAX_MODELSdistinct model stringsgen_ai.operation.name—chat,text_completion,embeddings,generate_content,execute_tool,create_agent,invoke_agent, orother(the sentinel an unrecognized operation coerces to)frugal.batched—truefor batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results),falseotherwisefrugal.custom_label— only ontrack_ai_call/start_ai_callcalls that passcustom_label=...; the value must be in theFRUGAL_CUSTOM_LABELallowlist or it is coerced toother(with a once-per-value warning).custom_label=Nonemeans no label; a non-string value is coerced toother
The "Additional attributes" column above lists what each metric carries on top of the base set. See docs/metrics-reference.md for the values each attribute takes, sentinel values for caller fields, and the full internal-error-stage list.
Metrics are aggregated per export interval (not sent per call), so network overhead is minimal regardless of call volume.
Supported providers
| Provider | Usage |
|---|---|
| OpenAI | wrap_openai(OpenAI()) |
| Anthropic | wrap_anthropic(Anthropic()) |
| Claude on AWS Bedrock (via Anthropic SDK) | wrap_anthropic(AnthropicBedrock()) |
| AWS Bedrock — any model, AWS SDKs directly | wrap_bedrock(boto3.client("bedrock-runtime")) |
| Claude on Google Vertex | wrap_anthropic(AnthropicVertex()) |
wrap_bedrock covers all four bedrock-runtime operations (invoke_model,
invoke_model_with_response_stream, converse, converse_stream) across
every model on Bedrock — Anthropic, OpenAI gpt-oss, Amazon Nova, Meta Llama,
Mistral, Cohere, AI21, DeepSeek, Qwen, Kimi, etc. Cross-region inference
profile prefixes (us., eu., jp., apac., au., global.) are
preserved in gen_ai.request.model. Async clients (aiobotocore /
aioboto3) are detected automatically. See docs/quickstart.md for the full Bedrock walkthrough.
Custom call sites
For AI calls that don't go through a wrapped client — an HTTP call to a model gateway, a CLI shell-out, or an internal helper that returns raw usage — mark the call site yourself and feed in the usage you have. You get the same metrics and the same caller attribution as the auto-wrappers.
from frugal_metrics import track_ai_call
with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514") as span:
res = my_custom_llm_call(prompt)
span.set_usage(
input_tokens=res.usage.input_tokens,
output_tokens=res.usage.output_tokens,
response_model=res.model,
)
For an enclosing agent invocation, set operation="invoke_agent" and report
the number of model-response steps performed inside it:
with track_ai_call(
system="anthropic",
operation="invoke_agent",
request_model="claude-sonnet-4-20250514",
) as span:
result = my_agent_framework_call(prompt)
span.set_usage(
input_tokens=result.usage.input_tokens,
output_tokens=result.usage.output_tokens,
agent_turns=result.turn_count,
)
- Provider wrappers still measure individual model calls and never set
agent_turns. - Agent frameworks such as the Claude Agent SDK, OpenAI Agents SDK, and LangGraph do not have dedicated wrappers yet; track them manually as shown.
frugal.gen_ai.history_depthis the request message count (messages.length), not a turn count.- Missing, non-integer, non-positive, or otherwise invalid
agent_turnsvalues are silently dropped. They never raise or affect customer execution. - Turn data is emitted only on success, like token usage.
- Avoid double-counting tokens. Only report aggregate
input_tokens/output_tokenson theinvoke_agentcall when the agent's inner model-client calls are not already wrapped by a supported provider wrapper. If they are wrapped, those wrappers already emit token usage per inner call — reportagent_turnsonly on theinvoke_agentcall in that case, or the same tokens are double-counted.
The with block times the call and emits metrics on exit — or an error metric if the block raises (the exception always propagates). Works the same around an await in async code. Call span.set_usage(...) once the response is back; if you never call it, you still get a call-count and latency point.
For calls that span function boundaries — where a single with block won't fit — use the manual handle:
from frugal_metrics import start_ai_call
span = start_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
try:
res = my_custom_llm_call(prompt)
span.success(
input_tokens=res.usage.input_tokens,
output_tokens=res.usage.output_tokens,
response_model=res.model,
)
except BaseException as exc:
span.error(exc)
raise
success() and error() finalize the call exactly once and are idempotent — extra calls are ignored.
Metadata — track_ai_call / start_ai_call kwargs (all optional): system (→ gen_ai.system, default "custom"), operation (default "chat"), request_model (default "<unknown>"), custom_label (→ the frugal.custom_label label on every metric of the call except the frugal.gen_ai.internal_errors setup_failed / emission points; trimmed, and coerced to other unless in the FRUGAL_CUSTOM_LABEL allowlist). To also run the sampled prompt analyzer, pass any of messages / system_prompt / tools / response_format / max_tokens.
Usage — set_usage(...) / success(...) kwargs (all optional): input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, agent_turns, response_model.
The same env-var configuration, safety guarantees, and exported metrics apply. See docs/quickstart.md for the full custom call-site walkthrough.
Streaming
Streaming works with no extra setup. The wrapper returns a drop-in replacement that passes events through and emits metrics when the stream finishes.
# OpenAI — opt in to include_usage for token counts in streams
stream = client.chat.completions.create(
model="gpt-4o",
messages=[...],
stream=True,
stream_options={"include_usage": True},
)
for chunk in stream:
...
# Anthropic — token counts available natively, no opt-in needed
stream = anthropic_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[...],
stream=True,
)
for event in stream:
...
Internal library attribution
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:
FRUGAL_PATH_BLOCK_LIST="src/acme_ai_helpers,libs/llm_utils"
Safety guarantees
- If any required env var is unset,
wrap_openai/wrap_anthropicreturn the client completely unmodified with zero overhead. - 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.
- Wrapping is idempotent — calling
wrap_openai(client)twice on the same client is safe.
Debugging
The SDK is silent by design — when misconfigured it degrades to a no-op. To see why it is doing nothing (missing env var, instrument-build failure, cardinality-cap hit, a metric export that keeps failing), register a debug callback:
from frugal_metrics import set_debug_callback
def on_debug(level, message, context=None):
# level: "info" | "warn" | "error"
# message: human-readable string
# context: optional dict of structured detail, e.g. {"stage": "setup_failed"}
getattr(my_logger, level if level != "warn" else "warning")(
"[frugal-metrics] %s", message, extra=context or {}
)
set_debug_callback(on_debug)
This is a structural-diagnostics hook, not a per-call trace. It only fires for structural or persistent problems — missing/invalid configuration, setup failures, cardinality-cap hits, and consecutive export failures (one de-duped error, re-armed on the next success). Ordinary transient per-call API errors are never emitted — those are your own API errors and already visible to you.
Notes:
- A throwing callback is swallowed. If your callback raises, the exception never escapes into the SDK or your code.
- Issues are replayed. Configuration runs lazily and may happen before you register, so init/config diagnostics emitted beforehand are held in a small bounded buffer and replayed in order the moment you call
set_debug_callback. Register once at startup to catch them all. - The callback is global and thread-safe. Pass
Noneto clear it.
Flushing & graceful shutdown
Metrics export on a periodic interval (default 60s). A short-lived process — a CLI, a job, a serverless invocation, a test run — can exit before the next export, silently dropping its final window of metrics. Use flush / shutdown to avoid that:
from frugal_metrics import flush, shutdown
flush() # force an immediate export; SDK keeps recording afterwards
shutdown() # flush + tear down the provider (rebuilt lazily on next wrap)
Both are safe no-ops when the SDK was never initialized and never raise.
For processes that exit on their own, opt in to automatic flush-on-exit:
from frugal_metrics import enable_auto_shutdown
enable_auto_shutdown() # flush + shutdown via atexit, plus SIGTERM / SIGINT
enable_auto_shutdown always registers an atexit hook and, when handle_signals=True (the default), installs SIGTERM/SIGINT handlers that flush+shutdown and then chain to whatever handler was previously installed (so your own handler still runs). Off the main thread, signal registration is unavailable and it falls back to atexit-only. It is idempotent — repeat calls register nothing extra.
It is opt-in because installing signal handlers is intrusive. If you run your own signal handling, do not call enable_auto_shutdown(handle_signals=True); prefer calling shutdown() from inside your own handler instead.
Verifying the setup
Set FRUGAL_METRICS_EXPORTER=console to print the metrics to stdout instead
of exporting them. Console mode needs only FRUGAL_PROJECT_ID; the API key,
endpoint and header variables are ignored. An empty
endpoint does not select the console exporter: it falls through to the default
Frugal endpoint.
export FRUGAL_PROJECT_ID=my-project
export FRUGAL_METRICS_EXPORTER=console
export FRUGAL_METRIC_EXPORT_INTERVAL_MS=5000
python my_script.py
Or point the library at a local OTLP/HTTP collector to see what it exports:
export FRUGAL_API_KEY=test-key
export FRUGAL_PROJECT_ID=my-project
export FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/metrics
export FRUGAL_METRIC_EXPORT_INTERVAL_MS=5000
python my_script.py
The collector should receive metric records with gen_ai.* and frugal.* attributes every 5 seconds.
Metadata
Release files for frugal-sdk-python 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| frugal_sdk_python-0.6.0.tar.gz | 162.2 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| frugal_sdk_python-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 250.7 kB
Release files / frugal_sdk_python-0.6.0.tar.gz
| Download URL | frugal_sdk_python-0.6.0.tar.gz |
|---|---|
| Size | 162.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c41b2dbd4fc229f45d40eed39b1fa37b9586435c5cb54f9a3bb64ff89579d8f1
|
|
BLAKE2b-256 checksum How to use checksums |
ee90d6426cef3174a2a3acca4acee8b1a3248cbb2b1831bd2a57684a788ff2b2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logRelease files / frugal_sdk_python-0.6.0-py3-none-any.whl
| Download URL | frugal_sdk_python-0.6.0-py3-none-any.whl |
|---|---|
| Size | 88.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
79001d66f2235f3033f0e2c656b64e86d3481f3d6d05d9c431d7b5e05577d05f
|
|
BLAKE2b-256 checksum How to use checksums |
faf522129965f5b843f26383f9af4b7e51704dc2dc66a61beea41093532c57c8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logSecurity for frugal-sdk-python 0.6.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for frugal_sdk_python-0.6.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.