frugal-sdk-python 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (80) hide show
  1. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/PKG-INFO +99 -3
  2. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/README.md +98 -2
  3. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/docs/metrics-reference.md +22 -2
  4. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/docs/quickstart.md +106 -0
  5. frugal_sdk_python-0.3.0/src/frugal_metrics/__init__.py +41 -0
  6. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/config.py +41 -0
  7. frugal_sdk_python-0.3.0/src/frugal_metrics/debug.py +109 -0
  8. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/instrument.py +16 -2
  9. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/otel.py +85 -5
  10. frugal_sdk_python-0.3.0/src/frugal_metrics/providers.py +219 -0
  11. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/safe.py +11 -0
  12. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/semconv.py +13 -1
  13. frugal_sdk_python-0.3.0/src/frugal_metrics/shutdown.py +140 -0
  14. frugal_sdk_python-0.3.0/src/frugal_metrics/track.py +341 -0
  15. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/anthropic.py +8 -0
  16. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock.py +8 -0
  17. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/openai.py +8 -0
  18. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/conftest.py +4 -0
  19. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_config.py +18 -0
  20. frugal_sdk_python-0.3.0/tests/test_debug.py +133 -0
  21. frugal_sdk_python-0.3.0/tests/test_providers.py +180 -0
  22. frugal_sdk_python-0.3.0/tests/test_shutdown.py +130 -0
  23. frugal_sdk_python-0.3.0/tests/test_track_ai_call.py +240 -0
  24. frugal_sdk_python-0.1.0/src/frugal_metrics/__init__.py +0 -13
  25. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/.github/workflows/cd.yml +0 -0
  26. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/.gitignore +0 -0
  27. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/LICENSES/Apache-2.0.txt +0 -0
  28. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/REUSE.toml +0 -0
  29. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/README.md +0 -0
  30. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
  31. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/docker-compose.yml +0 -0
  32. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
  33. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
  34. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
  35. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/Dockerfile +0 -0
  36. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/package.json +0 -0
  37. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/src/server.js +0 -0
  38. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
  39. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/models.html +0 -0
  40. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
  41. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
  42. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/prometheus.yml +0 -0
  43. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/Dockerfile +0 -0
  44. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/__init__.py +0 -0
  45. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/clients.py +0 -0
  46. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
  47. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
  48. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
  49. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
  50. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
  51. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
  52. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
  53. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
  54. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
  55. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
  56. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
  57. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
  58. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/main.py +0 -0
  59. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
  60. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
  61. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/pyproject.toml +0 -0
  62. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/requirements.txt +0 -0
  63. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/scripts/verify.sh +0 -0
  64. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/pyproject.toml +0 -0
  65. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/block_hash_lru.py +0 -0
  66. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/caller.py +0 -0
  67. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/prompt_analyzer.py +0 -0
  68. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/streams.py +0 -0
  69. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
  70. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
  71. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/__init__.py +0 -0
  72. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_bedrock_models.py +0 -0
  73. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_block_hash_lru.py +0 -0
  74. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_caller.py +0 -0
  75. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_caller_edge_cases.py +0 -0
  76. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_prompt_analyzer.py +0 -0
  77. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_anthropic.py +0 -0
  78. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_bedrock.py +0 -0
  79. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_openai.py +0 -0
  80. {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_openai_analyzer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frugal-sdk-python
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Instrumentation for Cost-to-Code attribution.
5
5
  Project-URL: Homepage, https://frugal.co
6
6
  Author: Frugal AI Inc.
@@ -78,6 +78,7 @@ Set both or the wrapper silently returns the client unmodified:
78
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
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
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_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. |
81
82
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
82
83
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
83
84
  | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
@@ -113,9 +114,9 @@ Every metric carries this base attribute set:
113
114
 
114
115
  - `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
116
  - `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.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`, …, or `custom`). Set via the `system` kwarg; an unrecognized value is coerced to `custom` with a one-time warning. See [`docs/metrics-reference.md`](docs/metrics-reference.md#attributes).
117
118
  - `gen_ai.request.model`, `gen_ai.response.model` — the model used
118
- - `gen_ai.operation.name` — `chat`, `text_completion`, or `embeddings`
119
+ - `gen_ai.operation.name` — `chat`, `text_completion`, `embeddings`, `generate_content`, `execute_tool`, `create_agent`, `invoke_agent`, or `other` (the sentinel an unrecognized operation coerces to)
119
120
  - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
120
121
 
121
122
  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.
@@ -140,6 +141,50 @@ profile prefixes (`us.`, `eu.`, `jp.`, `apac.`, `au.`, `global.`) are
140
141
  preserved in `gen_ai.request.model`. Async clients (`aiobotocore` /
141
142
  `aioboto3`) are detected automatically. See [`docs/quickstart.md`](docs/quickstart.md#native-aws-bedrock-wrap_bedrock) for the full Bedrock walkthrough.
142
143
 
144
+ ## Custom call sites
145
+
146
+ 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.
147
+
148
+ ```python
149
+ from frugal_metrics import track_ai_call
150
+
151
+ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514") as span:
152
+ res = my_custom_llm_call(prompt)
153
+ span.set_usage(
154
+ input_tokens=res.usage.input_tokens,
155
+ output_tokens=res.usage.output_tokens,
156
+ response_model=res.model,
157
+ )
158
+ ```
159
+
160
+ 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.
161
+
162
+ For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
163
+
164
+ ```python
165
+ from frugal_metrics import start_ai_call
166
+
167
+ span = start_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
168
+ try:
169
+ res = my_custom_llm_call(prompt)
170
+ span.success(
171
+ input_tokens=res.usage.input_tokens,
172
+ output_tokens=res.usage.output_tokens,
173
+ response_model=res.model,
174
+ )
175
+ except BaseException as exc:
176
+ span.error(exc)
177
+ raise
178
+ ```
179
+
180
+ `success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
181
+
182
+ **Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional): `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default `"chat"`), `request_model` (default `"<unknown>"`). To also run the sampled prompt analyzer, pass any of `messages` / `system_prompt` / `tools` / `response_format` / `max_tokens`.
183
+
184
+ **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
185
+
186
+ The same env-var configuration, safety guarantees, and exported metrics apply. See [`docs/quickstart.md`](docs/quickstart.md#custom-call-sites) for the full custom call-site walkthrough.
187
+
143
188
  ## Streaming
144
189
 
145
190
  Streaming works with no extra setup. The wrapper returns a drop-in replacement that passes events through and emits metrics when the stream finishes.
@@ -180,6 +225,57 @@ FRUGAL_PATH_BLOCK_LIST="src/acme_ai_helpers,libs/llm_utils"
180
225
  - 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
226
  - Wrapping is idempotent — calling `wrap_openai(client)` twice on the same client is safe.
182
227
 
228
+ ## Debugging
229
+
230
+ 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:
231
+
232
+ ```python
233
+ from frugal_metrics import set_debug_callback
234
+
235
+ def on_debug(level, message, context=None):
236
+ # level: "info" | "warn" | "error"
237
+ # message: human-readable string
238
+ # context: optional dict of structured detail, e.g. {"stage": "setup_failed"}
239
+ getattr(my_logger, level if level != "warn" else "warning")(
240
+ "[frugal-metrics] %s", message, extra=context or {}
241
+ )
242
+
243
+ set_debug_callback(on_debug)
244
+ ```
245
+
246
+ 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.
247
+
248
+ Notes:
249
+
250
+ - **A throwing callback is swallowed.** If your callback raises, the exception never escapes into the SDK or your code.
251
+ - **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.
252
+ - The callback is global and thread-safe. Pass `None` to clear it.
253
+
254
+ ## Flushing & graceful shutdown
255
+
256
+ 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:
257
+
258
+ ```python
259
+ from frugal_metrics import flush, shutdown
260
+
261
+ flush() # force an immediate export; SDK keeps recording afterwards
262
+ shutdown() # flush + tear down the provider (rebuilt lazily on next wrap)
263
+ ```
264
+
265
+ Both are **safe no-ops when the SDK was never initialized** and never raise.
266
+
267
+ For processes that exit on their own, opt in to automatic flush-on-exit:
268
+
269
+ ```python
270
+ from frugal_metrics import enable_auto_shutdown
271
+
272
+ enable_auto_shutdown() # flush + shutdown via atexit, plus SIGTERM / SIGINT
273
+ ```
274
+
275
+ `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.
276
+
277
+ 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.
278
+
183
279
  ## Verifying the setup
184
280
 
185
281
  Set the OTLP endpoint to empty to print metrics to the console instead of exporting them:
@@ -49,6 +49,7 @@ Set both or the wrapper silently returns the client unmodified:
49
49
  | `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`. |
50
50
  | `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`. |
51
51
  | `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. |
52
+ | `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. |
52
53
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
53
54
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
54
55
  | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
@@ -84,9 +85,9 @@ Every metric carries this base attribute set:
84
85
 
85
86
  - `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
86
87
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
87
- - `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `vertex_ai`
88
+ - `gen_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`, …, or `custom`). Set via the `system` kwarg; an unrecognized value is coerced to `custom` with a one-time warning. See [`docs/metrics-reference.md`](docs/metrics-reference.md#attributes).
88
89
  - `gen_ai.request.model`, `gen_ai.response.model` — the model used
89
- - `gen_ai.operation.name` — `chat`, `text_completion`, or `embeddings`
90
+ - `gen_ai.operation.name` — `chat`, `text_completion`, `embeddings`, `generate_content`, `execute_tool`, `create_agent`, `invoke_agent`, or `other` (the sentinel an unrecognized operation coerces to)
90
91
  - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
91
92
 
92
93
  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.
@@ -111,6 +112,50 @@ profile prefixes (`us.`, `eu.`, `jp.`, `apac.`, `au.`, `global.`) are
111
112
  preserved in `gen_ai.request.model`. Async clients (`aiobotocore` /
112
113
  `aioboto3`) are detected automatically. See [`docs/quickstart.md`](docs/quickstart.md#native-aws-bedrock-wrap_bedrock) for the full Bedrock walkthrough.
113
114
 
115
+ ## Custom call sites
116
+
117
+ 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.
118
+
119
+ ```python
120
+ from frugal_metrics import track_ai_call
121
+
122
+ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514") as span:
123
+ res = my_custom_llm_call(prompt)
124
+ span.set_usage(
125
+ input_tokens=res.usage.input_tokens,
126
+ output_tokens=res.usage.output_tokens,
127
+ response_model=res.model,
128
+ )
129
+ ```
130
+
131
+ 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.
132
+
133
+ For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
134
+
135
+ ```python
136
+ from frugal_metrics import start_ai_call
137
+
138
+ span = start_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
139
+ try:
140
+ res = my_custom_llm_call(prompt)
141
+ span.success(
142
+ input_tokens=res.usage.input_tokens,
143
+ output_tokens=res.usage.output_tokens,
144
+ response_model=res.model,
145
+ )
146
+ except BaseException as exc:
147
+ span.error(exc)
148
+ raise
149
+ ```
150
+
151
+ `success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
152
+
153
+ **Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional): `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default `"chat"`), `request_model` (default `"<unknown>"`). To also run the sampled prompt analyzer, pass any of `messages` / `system_prompt` / `tools` / `response_format` / `max_tokens`.
154
+
155
+ **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
156
+
157
+ The same env-var configuration, safety guarantees, and exported metrics apply. See [`docs/quickstart.md`](docs/quickstart.md#custom-call-sites) for the full custom call-site walkthrough.
158
+
114
159
  ## Streaming
115
160
 
116
161
  Streaming works with no extra setup. The wrapper returns a drop-in replacement that passes events through and emits metrics when the stream finishes.
@@ -151,6 +196,57 @@ FRUGAL_PATH_BLOCK_LIST="src/acme_ai_helpers,libs/llm_utils"
151
196
  - 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.
152
197
  - Wrapping is idempotent — calling `wrap_openai(client)` twice on the same client is safe.
153
198
 
199
+ ## Debugging
200
+
201
+ 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:
202
+
203
+ ```python
204
+ from frugal_metrics import set_debug_callback
205
+
206
+ def on_debug(level, message, context=None):
207
+ # level: "info" | "warn" | "error"
208
+ # message: human-readable string
209
+ # context: optional dict of structured detail, e.g. {"stage": "setup_failed"}
210
+ getattr(my_logger, level if level != "warn" else "warning")(
211
+ "[frugal-metrics] %s", message, extra=context or {}
212
+ )
213
+
214
+ set_debug_callback(on_debug)
215
+ ```
216
+
217
+ 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.
218
+
219
+ Notes:
220
+
221
+ - **A throwing callback is swallowed.** If your callback raises, the exception never escapes into the SDK or your code.
222
+ - **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.
223
+ - The callback is global and thread-safe. Pass `None` to clear it.
224
+
225
+ ## Flushing & graceful shutdown
226
+
227
+ 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:
228
+
229
+ ```python
230
+ from frugal_metrics import flush, shutdown
231
+
232
+ flush() # force an immediate export; SDK keeps recording afterwards
233
+ shutdown() # flush + tear down the provider (rebuilt lazily on next wrap)
234
+ ```
235
+
236
+ Both are **safe no-ops when the SDK was never initialized** and never raise.
237
+
238
+ For processes that exit on their own, opt in to automatic flush-on-exit:
239
+
240
+ ```python
241
+ from frugal_metrics import enable_auto_shutdown
242
+
243
+ enable_auto_shutdown() # flush + shutdown via atexit, plus SIGTERM / SIGINT
244
+ ```
245
+
246
+ `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.
247
+
248
+ 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.
249
+
154
250
  ## Verifying the setup
155
251
 
156
252
  Set the OTLP endpoint to empty to print metrics to the console instead of exporting them:
@@ -45,12 +45,31 @@ calls, token usage) are 100% — never sampled.
45
45
 
46
46
  | Attribute | Example |
47
47
  | -------------------------- | ------------------------------------------ |
48
- | `gen_ai.system` | `openai` \| `anthropic` \| `aws.bedrock` \| `vertex_ai` |
49
- | `gen_ai.operation.name` | `chat` \| `text_completion` \| `embeddings` |
48
+ | `gen_ai.system` | `openai` \| `anthropic` \| `aws.bedrock` \| `vertex_ai` \| `az.ai.openai` \| `cohere` \| `mistral_ai` \| `deepseek` \| `groq` \| `perplexity` \| `x_ai` \| `ibm.watsonx.ai` \| `gcp.gemini` \| `custom` (legacy spelling — see below) |
49
+ | `gen_ai.provider.name` | `openai` \| `anthropic` \| `aws.bedrock` \| `gcp.vertex_ai` \| `azure.ai.openai` \| `cohere` \| `mistral_ai` \| `deepseek` \| `groq` \| `perplexity` \| `x_ai` \| `ibm.watsonx.ai` \| `gcp.gemini` \| `custom` (current OTel attribute) |
50
+ | `gen_ai.operation.name` | `chat` \| `text_completion` \| `embeddings` \| `generate_content` \| `execute_tool` \| `create_agent` \| `invoke_agent` \| `other` (sentinel for an unrecognized operation) |
50
51
  | `gen_ai.request.model` | `gpt-4o` / `claude-3-5-sonnet-latest` / … |
51
52
  | `gen_ai.response.model` | Provider-returned model id (often identical)|
52
53
  | `gen_ai.token.type` | `input` \| `output` (token usage metric only) |
53
54
 
55
+ > **Provider attribute migration.** OTel renamed `gen_ai.system` to
56
+ > `gen_ai.provider.name` (some spellings changed, e.g. `vertex_ai` →
57
+ > `gcp.vertex_ai`). The SDK **dual-emits both** during the transition: every
58
+ > metric carries the legacy `gen_ai.system` value (so existing queries/history
59
+ > keep matching) and the new `gen_ai.provider.name` value. The `system` kwarg
60
+ > accepts either spelling. A value outside the well-known set (e.g. a typo like
61
+ > `amazon-bedrock`) is **coerced to `custom`** on both labels — so a wrong label
62
+ > never reaches metrics — and surfaced once through the debug callback
63
+ > (`set_debug_callback`), per distinct value (bounded so a high-cardinality
64
+ > caller can't grow the warning set without limit). To keep a legitimate provider
65
+ > that isn't in the registry, register it via `FRUGAL_CUSTOM_PROVIDERS` (up to 10)
66
+ > and it passes through verbatim on both labels. An `operation` outside the accepted set is coerced to
67
+ > the `other` sentinel (NOT `chat`, so a typo can't inflate the real chat series) with the same
68
+ > one-time warning — the seven listed values plus an explicit `other` pass through unchanged. The
69
+ > exported `GenAiSystem` / `GenAiOperation` `Literal` aliases give strict typing for callers who want
70
+ > it — `GenAiSystem` lists both provider spellings the runtime accepts (the kwargs stay `str` so a
71
+ > free-form custom value remains legal).
72
+
54
73
  ### Frugal-added
55
74
 
56
75
  | Attribute | Value |
@@ -110,6 +129,7 @@ attribute describing where our own code failed. Non-exhaustive list:
110
129
  | Claude on AWS Bedrock (via Anthropic SDK) | `wrap_anthropic(AnthropicBedrock())` | `aws.bedrock` |
111
130
  | AWS Bedrock — any model, AWS SDKs directly | `wrap_bedrock` | `aws.bedrock` |
112
131
  | Claude on Google Vertex | `wrap_anthropic(AnthropicVertex())` | `vertex_ai` |
132
+ | Custom / manual call sites | `track_ai_call` / `start_ai_call` | `custom` (override via `system=`) |
113
133
 
114
134
  `wrap_bedrock` covers all four `bedrock-runtime` operations (`InvokeModel`,
115
135
  `InvokeModelWithResponseStream`, `Converse`, `ConverseStream`) on every model
@@ -92,6 +92,13 @@ token counts are not (the OpenAI SDK doesn't send them).
92
92
  Use `wrap_anthropic` with the Anthropic SDK's `AnthropicBedrock` client. The
93
93
  wrapper detects it and tags metrics with `gen_ai.system = aws.bedrock`.
94
94
 
95
+ > Every metric is **dual-emitted** under both `gen_ai.system` (legacy) and
96
+ > `gen_ai.provider.name` (current OTel spelling — e.g. `aws.bedrock`,
97
+ > `gcp.vertex_ai`). A provider or operation outside the known set is coerced to
98
+ > the `custom` / `other` sentinel with a one-time warning (via
99
+ > `set_debug_callback`); register extra providers with `FRUGAL_CUSTOM_PROVIDERS`.
100
+ > See [metrics-reference.md](metrics-reference.md#attributes).
101
+
95
102
  ```python
96
103
  from anthropic import AnthropicBedrock
97
104
  from frugal_metrics import wrap_anthropic
@@ -198,6 +205,65 @@ client = wrap_anthropic(AnthropicVertex(region="us-central1", project_id="my-pro
198
205
  > integrations are not yet instrumented. The block-list approach below
199
206
  > usually covers framework wrappers in the meantime.
200
207
 
208
+ ## Custom call sites
209
+
210
+ For AI calls that don't go through a wrapped client — an HTTP call to a model
211
+ gateway, a CLI shell-out, or an internal helper that returns raw usage — mark
212
+ the call site yourself and feed in the usage you have. You get the same metrics
213
+ (tagged `gen_ai.system = custom` by default) and the same caller attribution as
214
+ the auto-wrappers.
215
+
216
+ ```python
217
+ from frugal_metrics import track_ai_call
218
+
219
+ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514") as span:
220
+ res = my_custom_llm_call(prompt)
221
+ span.set_usage(
222
+ input_tokens=res.usage.input_tokens,
223
+ output_tokens=res.usage.output_tokens,
224
+ response_model=res.model,
225
+ )
226
+ ```
227
+
228
+ The `with` block times the call and emits metrics on exit — or an error metric
229
+ if the block raises (the exception always propagates). Works the same around an
230
+ `await` in async code. Call `span.set_usage(...)` once the response is back; if
231
+ you never call it, you still get a call-count and latency point.
232
+
233
+ For calls that span function boundaries — where a single `with` block won't fit
234
+ — use the manual handle:
235
+
236
+ ```python
237
+ from frugal_metrics import start_ai_call
238
+
239
+ span = start_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
240
+ try:
241
+ res = my_custom_llm_call(prompt)
242
+ span.success(
243
+ input_tokens=res.usage.input_tokens,
244
+ output_tokens=res.usage.output_tokens,
245
+ response_model=res.model,
246
+ )
247
+ except BaseException as exc:
248
+ span.error(exc)
249
+ raise
250
+ ```
251
+
252
+ `success()` and `error()` finalize the call exactly once and are idempotent —
253
+ extra calls are ignored.
254
+
255
+ **Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional):
256
+ `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default
257
+ `"chat"`), `request_model` (default `"<unknown>"`). To also run the sampled
258
+ prompt analyzer, pass any of `messages` / `system_prompt` / `tools` /
259
+ `response_format` / `max_tokens`.
260
+
261
+ **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional):
262
+ `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
263
+ `reasoning_tokens`, `response_model`.
264
+
265
+ The same env-var configuration, safety guarantees, and exported metrics apply.
266
+
201
267
  ## Attributing the right caller
202
268
 
203
269
  By default we walk the stack and pick the first frame that is:
@@ -272,6 +338,46 @@ The token-side metrics (`gen_ai.client.token.usage`, `frugal.gen_ai.calls`,
272
338
  `frugal.gen_ai.cache_*`, `frugal.gen_ai.output_cap_utilization`) are 100% —
273
339
  never sampled.
274
340
 
341
+ ## See why nothing is exporting (debug callback)
342
+
343
+ The SDK is silent by design, so when it no-ops you get no feedback. Register a
344
+ debug callback once at startup to surface structural diagnostics (missing config,
345
+ setup failures, cardinality-cap hits, persistent export failures) through your own
346
+ logger:
347
+
348
+ ```python
349
+ from frugal_metrics import set_debug_callback
350
+
351
+ def on_debug(level, message, context=None):
352
+ print(f"[frugal-metrics] {level}: {message}", context or {})
353
+
354
+ set_debug_callback(on_debug)
355
+ ```
356
+
357
+ It is a structural-diagnostics hook, not a per-call trace. A throwing callback is
358
+ swallowed, and issues emitted before you register are replayed on registration —
359
+ so register early. Pass `None` to clear it.
360
+
361
+ ## Flush on shutdown
362
+
363
+ Export runs on an interval, so a short-lived process can exit before the final
364
+ batch ships. Flush (or shut down) before exit:
365
+
366
+ ```python
367
+ from frugal_metrics import flush, shutdown, enable_auto_shutdown
368
+
369
+ flush() # immediate export, keep recording
370
+ shutdown() # flush + tear down (safe even if never initialized)
371
+
372
+ enable_auto_shutdown() # or: opt in to auto flush+shutdown on exit/SIGTERM/SIGINT
373
+ ```
374
+
375
+ `flush` / `shutdown` are safe no-ops when the SDK was never initialized.
376
+ `enable_auto_shutdown` registers an `atexit` hook and (by default) chained
377
+ SIGTERM/SIGINT handlers, and is idempotent; if you manage your own signal
378
+ handlers, call it with `handle_signals=False` (or just call `shutdown()` from
379
+ your handler).
380
+
275
381
  ## Verifying the setup
276
382
 
277
383
  Point the library at a local collector (or set the endpoint empty to print
@@ -0,0 +1,41 @@
1
+ """frugal-metrics: call-site instrumentation for AI API calls."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from frugal_metrics.debug import set_debug_callback
6
+ from frugal_metrics.providers import (
7
+ PROVIDERS,
8
+ GenAiOperation,
9
+ GenAiSystem,
10
+ ProviderEntry,
11
+ normalize_operation,
12
+ normalize_provider,
13
+ )
14
+ from frugal_metrics.shutdown import enable_auto_shutdown, flush, shutdown
15
+ from frugal_metrics.track import AICallSpan, start_ai_call, track_ai_call
16
+ from frugal_metrics.wrappers.openai import wrap_openai
17
+ from frugal_metrics.wrappers.anthropic import wrap_anthropic
18
+ from frugal_metrics.wrappers.bedrock import wrap_bedrock
19
+
20
+ __all__ = [
21
+ "wrap_openai",
22
+ "wrap_anthropic",
23
+ "wrap_bedrock",
24
+ "track_ai_call",
25
+ "start_ai_call",
26
+ "AICallSpan",
27
+ "set_debug_callback",
28
+ "flush",
29
+ "shutdown",
30
+ "enable_auto_shutdown",
31
+ "PROVIDERS",
32
+ "ProviderEntry",
33
+ "GenAiSystem",
34
+ "GenAiOperation",
35
+ "normalize_provider",
36
+ "normalize_operation",
37
+ ]
38
+ try:
39
+ __version__ = version("frugal-sdk-python")
40
+ except PackageNotFoundError:
41
+ __version__ = "0+unknown"
@@ -35,12 +35,15 @@ import os
35
35
  import threading
36
36
  from dataclasses import dataclass, field
37
37
 
38
+ from frugal_metrics.debug import emit_log, emit_log_once
39
+
38
40
  logger = logging.getLogger("frugal_metrics")
39
41
 
40
42
  _ENV_API_KEY = "FRUGAL_API_KEY"
41
43
  _ENV_CUSTOMER_ID = "FRUGAL_CUSTOMER_ID"
42
44
  _ENV_PROJECT_ID = "FRUGAL_PROJECT_ID"
43
45
  _ENV_COMPONENTS = "FRUGAL_COMPONENTS"
46
+ _ENV_CUSTOM_PROVIDERS = "FRUGAL_CUSTOM_PROVIDERS"
44
47
  _ENV_REPO_ROOT = "FRUGAL_REPO_ROOT"
45
48
  _ENV_PATH_BLOCK_LIST = "FRUGAL_PATH_BLOCK_LIST"
46
49
  _ENV_DISABLED = "FRUGAL_DISABLED"
@@ -56,6 +59,8 @@ _ENV_PROMPT_ANALYSIS_SAMPLE_RATE = "FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE"
56
59
  _DEFAULT_OTLP_ENDPOINT = "https://metrics.frugal.co/opentelemetry/v1/metrics"
57
60
  _DEFAULT_MAX_CALL_SITES = 500
58
61
  _DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE = 0.1
62
+ # Upper bound on customer-registered provider names — keeps label cardinality bounded.
63
+ _MAX_CUSTOM_PROVIDERS = 10
59
64
 
60
65
  _warned_once = False
61
66
  _warned_lock = threading.Lock()
@@ -70,6 +75,10 @@ class Config:
70
75
  repo_root: str
71
76
  # Optional logical components; metrics fan out one extra series per entry.
72
77
  components: tuple[str, ...] = field(default_factory=tuple)
78
+ # Customer-registered provider names (beyond the built-in OTel registry) that
79
+ # pass through verbatim instead of being coerced to "custom". Capped to keep
80
+ # gen_ai.system / gen_ai.provider.name cardinality bounded.
81
+ custom_providers: tuple[str, ...] = field(default_factory=tuple)
73
82
  path_block_list: tuple[str, ...] = field(default_factory=tuple)
74
83
  otlp_endpoint: str = _DEFAULT_OTLP_ENDPOINT
75
84
  otlp_headers: str | None = None
@@ -92,6 +101,7 @@ def _warn_once(message: str) -> None:
92
101
  return
93
102
  _warned_once = True
94
103
  logger.warning("frugal-metrics disabled: %s", message)
104
+ emit_log("warn", f"disabled: {message}")
95
105
 
96
106
 
97
107
  def load_config() -> Config | None:
@@ -154,6 +164,28 @@ def load_config() -> Config | None:
154
164
  entry.strip() for entry in components_raw.split(",") if entry.strip()
155
165
  )
156
166
 
167
+ # Optional comma-separated allowlist of custom provider names that pass
168
+ # through verbatim (e.g. "together_ai,fireworks"). Deduped (order-preserving)
169
+ # and capped at _MAX_CUSTOM_PROVIDERS so a misconfigured value can't blow up
170
+ # label cardinality.
171
+ custom_providers_raw = os.environ.get(_ENV_CUSTOM_PROVIDERS, "")
172
+ custom_providers_all = list(
173
+ dict.fromkeys(
174
+ entry.strip()
175
+ for entry in custom_providers_raw.split(",")
176
+ if entry.strip()
177
+ )
178
+ )
179
+ custom_providers = tuple(custom_providers_all[:_MAX_CUSTOM_PROVIDERS])
180
+ if len(custom_providers_all) > _MAX_CUSTOM_PROVIDERS:
181
+ emit_log_once(
182
+ "custom_providers_truncated",
183
+ "warn",
184
+ f"{_ENV_CUSTOM_PROVIDERS} lists {len(custom_providers_all)} entries; "
185
+ f"only the first {_MAX_CUSTOM_PROVIDERS} are honored, the rest are "
186
+ f'coerced to "custom".',
187
+ )
188
+
157
189
  otlp_endpoint = (
158
190
  os.environ.get(_ENV_FRUGAL_OTLP_ENDPOINT)
159
191
  or os.environ.get(_ENV_OTLP_ENDPOINT)
@@ -182,6 +214,7 @@ def load_config() -> Config | None:
182
214
  project_id=project_id, # type: ignore[arg-type]
183
215
  repo_root=repo_root_abs,
184
216
  components=components,
217
+ custom_providers=custom_providers,
185
218
  path_block_list=block_list,
186
219
  otlp_endpoint=otlp_endpoint,
187
220
  otlp_headers=otlp_headers,
@@ -207,6 +240,10 @@ def _parse_int(raw: str | None, *, default: int) -> int:
207
240
  raw,
208
241
  default,
209
242
  )
243
+ emit_log(
244
+ "warn",
245
+ f"could not parse int env var value {raw!r}; using default {default}",
246
+ )
210
247
  return default
211
248
 
212
249
 
@@ -221,6 +258,10 @@ def _parse_float(raw: str | None, *, default: float) -> float:
221
258
  raw,
222
259
  default,
223
260
  )
261
+ emit_log(
262
+ "warn",
263
+ f"could not parse float env var value {raw!r}; using default {default}",
264
+ )
224
265
  return default
225
266
 
226
267