frugal-sdk-python 0.4.0__tar.gz → 0.5.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 (89) hide show
  1. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/PKG-INFO +44 -7
  2. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/README.md +43 -6
  3. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/docs/metrics-reference.md +59 -2
  4. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/docs/quickstart.md +122 -8
  5. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +1 -1
  6. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/block_hash_lru.py +22 -6
  7. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/config.py +152 -9
  8. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/instrument.py +105 -8
  9. frugal_sdk_python-0.5.0/src/frugal_metrics/model_cap.py +103 -0
  10. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/otel.py +270 -15
  11. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/semconv.py +14 -3
  12. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/track.py +5 -0
  13. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/conftest.py +9 -0
  14. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_block_hash_lru.py +31 -2
  15. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_config.py +321 -0
  16. frugal_sdk_python-0.5.0/tests/test_model_cap.py +413 -0
  17. frugal_sdk_python-0.5.0/tests/test_never_impact_host.py +270 -0
  18. frugal_sdk_python-0.5.0/tests/test_otel_overflow_detection.py +439 -0
  19. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_track_ai_call.py +130 -0
  20. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/.github/workflows/cd.yml +0 -0
  21. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/.gitignore +0 -0
  22. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/LICENSES/Apache-2.0.txt +0 -0
  23. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/REUSE.toml +0 -0
  24. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/README.md +0 -0
  25. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
  26. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/docker-compose.yml +0 -0
  27. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
  28. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
  29. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/Dockerfile +0 -0
  30. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/package.json +0 -0
  31. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/src/server.js +0 -0
  32. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
  33. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/models.html +0 -0
  34. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
  35. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
  36. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/prometheus.yml +0 -0
  37. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/Dockerfile +0 -0
  38. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/__init__.py +0 -0
  39. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/clients.py +0 -0
  40. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
  41. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
  42. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
  43. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
  44. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
  45. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
  46. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
  47. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
  48. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
  49. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
  50. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
  51. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
  52. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/main.py +0 -0
  53. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
  54. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
  55. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/pyproject.toml +0 -0
  56. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/requirements.txt +0 -0
  57. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/scripts/verify.sh +0 -0
  58. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/pyproject.toml +0 -0
  59. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/__init__.py +0 -0
  60. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/caller.py +0 -0
  61. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/debug.py +0 -0
  62. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/duplicate_filter.py +0 -0
  63. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/prompt_analyzer.py +0 -0
  64. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/providers.py +0 -0
  65. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/safe.py +0 -0
  66. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/shutdown.py +0 -0
  67. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/streams.py +0 -0
  68. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
  69. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/_batch.py +0 -0
  70. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/_patching.py +0 -0
  71. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/anthropic.py +0 -0
  72. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
  73. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
  74. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/openai.py +0 -0
  75. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/__init__.py +0 -0
  76. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_bedrock_models.py +0 -0
  77. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_caller.py +0 -0
  78. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_caller_edge_cases.py +0 -0
  79. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_debug.py +0 -0
  80. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_duplicate_filter.py +0 -0
  81. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_prompt_analyzer.py +0 -0
  82. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_providers.py +0 -0
  83. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_shutdown.py +0 -0
  84. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_anthropic.py +0 -0
  85. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_anthropic_batches.py +0 -0
  86. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_bedrock.py +0 -0
  87. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_openai.py +0 -0
  88. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_openai_analyzer.py +0 -0
  89. {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_openai_batches.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: frugal-sdk-python
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Instrumentation for Cost-to-Code attribution.
5
5
  Project-URL: Homepage, https://frugal.co
6
6
  Author: Frugal AI Inc.
@@ -77,12 +77,14 @@ Set both or the wrapper silently returns the client unmodified:
77
77
  | `FRUGAL_REPO_ROOT` | Absolute path to the source root; the strip prefix for `caller_file`. Defaults to the process working directory. |
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
- | `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. |
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. Deduped, and only the first **10** entries are honored (hard cap, not configurable); the rest are dropped at startup with one debug warning. |
81
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. |
82
82
  | `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=...`; a value outside the first 20 entries (hard cap, not configurable) is coerced to `other`. |
83
83
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
84
84
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
85
- | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
85
+ | `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. |
86
+ | `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. |
87
+ | `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. |
86
88
  | `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. |
87
89
  | `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. |
88
90
  | `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
@@ -103,8 +105,9 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
103
105
  | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
104
106
  | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
105
107
  | `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
108
+ | `frugal.gen_ai.agent.turns` | histogram | Model-response turns per successful `invoke_agent` call (manual only) | — |
106
109
  | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
107
- | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
110
+ | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality caps, bugs) | `frugal.internal_error.stage`, `frugal.internal_error.type`, `frugal.instrument` |
108
111
  | `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
109
112
  | `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
110
113
  | `frugal.gen_ai.duplicate_requests` | counter | Repeat-request detection — every chat call (100%, not sampled; disable via `FRUGAL_DUPLICATE_REQUEST_DETECTION`) | `frugal.dup_state` |
@@ -121,12 +124,13 @@ The analyzer-sampled metrics fire on a fraction of calls
121
124
  Every metric carries this base attribute set (except
122
125
  `frugal.gen_ai.internal_errors`, which is emitted from failure paths where
123
126
  these attributes may not exist yet and carries only `frugal.project_id`, the
124
- stage/type, and `frugal.customer_id` / `frugal.custom_label` when set):
127
+ stage/type, `frugal.instrument` on the `otel_cardinality_overflow` stage, and
128
+ `frugal.customer_id` / `frugal.custom_label` when set):
125
129
 
126
130
  - `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
127
131
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
128
132
  - `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).
129
- - `gen_ai.request.model`, `gen_ai.response.model` — the model used
133
+ - `gen_ai.request.model`, `gen_ai.response.model` — the model used; both draw on one shared registry and become `<overflow>` past `FRUGAL_MAX_MODELS` distinct model strings
130
134
  - `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)
131
135
  - `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
132
136
  - `frugal.custom_label` — only on `track_ai_call` / `start_ai_call` calls that pass `custom_label=...`; the value must be in the `FRUGAL_CUSTOM_LABEL` allowlist or it is coerced to `other`
@@ -169,6 +173,39 @@ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
169
173
  )
170
174
  ```
171
175
 
176
+ For an enclosing agent invocation, set `operation="invoke_agent"` and report
177
+ the number of model-response steps performed inside it:
178
+
179
+ ```python
180
+ with track_ai_call(
181
+ system="anthropic",
182
+ operation="invoke_agent",
183
+ request_model="claude-sonnet-4-20250514",
184
+ ) as span:
185
+ result = my_agent_framework_call(prompt)
186
+ span.set_usage(
187
+ input_tokens=result.usage.input_tokens,
188
+ output_tokens=result.usage.output_tokens,
189
+ agent_turns=result.turn_count,
190
+ )
191
+ ```
192
+
193
+ - Provider wrappers still measure individual model calls and never set
194
+ `agent_turns`.
195
+ - Agent frameworks such as the Claude Agent SDK, OpenAI Agents SDK, and
196
+ LangGraph do not have dedicated wrappers yet; track them manually as shown.
197
+ - `frugal.gen_ai.history_depth` is the request message count
198
+ (`messages.length`), not a turn count.
199
+ - Missing, non-integer, non-positive, or otherwise invalid `agent_turns`
200
+ values are silently dropped. They never raise or affect customer execution.
201
+ - Turn data is emitted only on success, like token usage.
202
+ - **Avoid double-counting tokens.** Only report aggregate `input_tokens` /
203
+ `output_tokens` on the `invoke_agent` call when the agent's inner
204
+ model-client calls are **not** already wrapped by a supported provider
205
+ wrapper. If they are wrapped, those wrappers already emit token usage per
206
+ inner call — report `agent_turns` only on the `invoke_agent` call in that
207
+ case, or the same tokens are double-counted.
208
+
172
209
  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.
173
210
 
174
211
  For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
@@ -193,7 +230,7 @@ except BaseException as exc:
193
230
 
194
231
  **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; 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`.
195
232
 
196
- **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
233
+ **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `agent_turns`, `response_model`.
197
234
 
198
235
  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.
199
236
 
@@ -48,12 +48,14 @@ Set both or the wrapper silently returns the client unmodified:
48
48
  | `FRUGAL_REPO_ROOT` | Absolute path to the source root; the strip prefix for `caller_file`. Defaults to the process working directory. |
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
- | `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. |
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. Deduped, and only the first **10** entries are honored (hard cap, not configurable); the rest are dropped at startup with one debug warning. |
52
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. |
53
53
  | `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=...`; a value outside the first 20 entries (hard cap, not configurable) is coerced to `other`. |
54
54
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
55
55
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
56
- | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
56
+ | `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. |
57
+ | `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. |
58
+ | `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. |
57
59
  | `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. |
58
60
  | `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. |
59
61
  | `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
@@ -74,8 +76,9 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
74
76
  | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
75
77
  | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
76
78
  | `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
79
+ | `frugal.gen_ai.agent.turns` | histogram | Model-response turns per successful `invoke_agent` call (manual only) | — |
77
80
  | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
78
- | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
81
+ | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality caps, bugs) | `frugal.internal_error.stage`, `frugal.internal_error.type`, `frugal.instrument` |
79
82
  | `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
80
83
  | `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
81
84
  | `frugal.gen_ai.duplicate_requests` | counter | Repeat-request detection — every chat call (100%, not sampled; disable via `FRUGAL_DUPLICATE_REQUEST_DETECTION`) | `frugal.dup_state` |
@@ -92,12 +95,13 @@ The analyzer-sampled metrics fire on a fraction of calls
92
95
  Every metric carries this base attribute set (except
93
96
  `frugal.gen_ai.internal_errors`, which is emitted from failure paths where
94
97
  these attributes may not exist yet and carries only `frugal.project_id`, the
95
- stage/type, and `frugal.customer_id` / `frugal.custom_label` when set):
98
+ stage/type, `frugal.instrument` on the `otel_cardinality_overflow` stage, and
99
+ `frugal.customer_id` / `frugal.custom_label` when set):
96
100
 
97
101
  - `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
98
102
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
99
103
  - `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).
100
- - `gen_ai.request.model`, `gen_ai.response.model` — the model used
104
+ - `gen_ai.request.model`, `gen_ai.response.model` — the model used; both draw on one shared registry and become `<overflow>` past `FRUGAL_MAX_MODELS` distinct model strings
101
105
  - `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)
102
106
  - `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
103
107
  - `frugal.custom_label` — only on `track_ai_call` / `start_ai_call` calls that pass `custom_label=...`; the value must be in the `FRUGAL_CUSTOM_LABEL` allowlist or it is coerced to `other`
@@ -140,6 +144,39 @@ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
140
144
  )
141
145
  ```
142
146
 
147
+ For an enclosing agent invocation, set `operation="invoke_agent"` and report
148
+ the number of model-response steps performed inside it:
149
+
150
+ ```python
151
+ with track_ai_call(
152
+ system="anthropic",
153
+ operation="invoke_agent",
154
+ request_model="claude-sonnet-4-20250514",
155
+ ) as span:
156
+ result = my_agent_framework_call(prompt)
157
+ span.set_usage(
158
+ input_tokens=result.usage.input_tokens,
159
+ output_tokens=result.usage.output_tokens,
160
+ agent_turns=result.turn_count,
161
+ )
162
+ ```
163
+
164
+ - Provider wrappers still measure individual model calls and never set
165
+ `agent_turns`.
166
+ - Agent frameworks such as the Claude Agent SDK, OpenAI Agents SDK, and
167
+ LangGraph do not have dedicated wrappers yet; track them manually as shown.
168
+ - `frugal.gen_ai.history_depth` is the request message count
169
+ (`messages.length`), not a turn count.
170
+ - Missing, non-integer, non-positive, or otherwise invalid `agent_turns`
171
+ values are silently dropped. They never raise or affect customer execution.
172
+ - Turn data is emitted only on success, like token usage.
173
+ - **Avoid double-counting tokens.** Only report aggregate `input_tokens` /
174
+ `output_tokens` on the `invoke_agent` call when the agent's inner
175
+ model-client calls are **not** already wrapped by a supported provider
176
+ wrapper. If they are wrapped, those wrappers already emit token usage per
177
+ inner call — report `agent_turns` only on the `invoke_agent` call in that
178
+ case, or the same tokens are double-counted.
179
+
143
180
  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.
144
181
 
145
182
  For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
@@ -164,7 +201,7 @@ except BaseException as exc:
164
201
 
165
202
  **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; 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`.
166
203
 
167
- **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
204
+ **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `agent_turns`, `response_model`.
168
205
 
169
206
  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.
170
207
 
@@ -20,6 +20,7 @@ metric may carry the extra attributes shown in the rightmost column.
20
20
  | `frugal.gen_ai.cache_read_tokens` | histogram | `{token}` | when provider reports cache reads | — |
21
21
  | `frugal.gen_ai.cache_write_tokens` | histogram | `{token}` | when provider reports cache writes | — |
22
22
  | `frugal.gen_ai.reasoning_tokens` | histogram | `{token}` | OpenAI o-series reasoning models | — |
23
+ | `frugal.gen_ai.agent.turns` | histogram | `{turn}` | successful `invoke_agent` calls with a valid `agent_turns` count (manual tracking only) | — |
23
24
  | `frugal.gen_ai.errors` | counter | `{error}` | when the wrapped call raises | `frugal.error.type` |
24
25
  | `frugal.gen_ai.internal_errors` | counter | `{error}` | frugal-metrics internal failures (cardinality cap, instrumentation bugs) | `frugal.internal_error.stage` |
25
26
  | `frugal.gen_ai.output_cap_utilization`| histogram | `1` | when request had `max_tokens` (ratio 0..1) | `frugal.structured_output_mode` |
@@ -34,6 +35,37 @@ metric may carry the extra attributes shown in the rightmost column.
34
35
  `gen_ai.client.token.usage` is split across input and output via the
35
36
  `gen_ai.token.type` attribute (`input` | `output`).
36
37
 
38
+ ### Agent turns
39
+
40
+ Manual `track_ai_call` and `start_ai_call` instrumentation accepts the
41
+ `agent_turns` usage kwarg through `AICallSpan.set_usage` and
42
+ `AICallSpan.success`. A sample is emitted only when the call succeeds, the
43
+ normalized `gen_ai.operation.name` is exactly `invoke_agent`, and the value is
44
+ a positive integer. Booleans, missing values, non-integers, zero, and negative
45
+ values are silently ignored; validation never affects customer execution.
46
+ Provider wrappers never set this field, so agent frameworks remain
47
+ manual-tracking call sites in this release. `frugal.gen_ai.history_depth`
48
+ continues to mean request message count, not agent turns. The histogram uses
49
+ explicit bucket boundaries
50
+ `(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 75, 100)` (mirroring
51
+ `gen_ai.client.operation.duration`'s view) so `histogram_quantile()` resolves
52
+ meaningful spread across realistic agent-run turn counts instead of
53
+ collapsing into OTel's duration-tuned default buckets.
54
+
55
+ Only report aggregate token usage on the `invoke_agent` call itself when the
56
+ agent's inner model-client calls are **not** already wrapped by a supported
57
+ provider wrapper; if they are wrapped, report `agent_turns` only on the
58
+ `invoke_agent` call, since the inner wrappers already emit token usage and
59
+ combining both would double-count tokens.
60
+
61
+ To derive average turns per reporting agent invocation in MetricsQL, use:
62
+
63
+ ```promql
64
+ increase(frugal.gen_ai.agent.turns_sum[...])
65
+ /
66
+ increase(frugal.gen_ai.agent.turns_count[...])
67
+ ```
68
+
37
69
  The analyzer-sampled metrics are gated by `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`
38
70
  (default `0.1`). Sampling is deterministic per `(caller_file, caller_function,
39
71
  minute)` — within a minute a given call site is either fully sampled or fully
@@ -182,6 +214,11 @@ Reading `duplicate_requests` correctly:
182
214
  | `<lambda>` | Call originated from a `lambda` expression. This is Python's native `co_name` for lambdas. Only appears in `frugal.caller_function`. |
183
215
  | `<overflow>` | Cardinality cap (`FRUGAL_MAX_CALL_SITES`) exceeded. See below. |
184
216
 
217
+ > `<overflow>` is the shared overflow sentinel, not a caller-only value: it also
218
+ > appears on `gen_ai.request.model` / `gen_ai.response.model` once
219
+ > `FRUGAL_MAX_MODELS` distinct model strings have been observed. See
220
+ > [Model cap behavior](#model-cap-behavior).
221
+
185
222
  ### Cardinality cap behavior
186
223
 
187
224
  Once `FRUGAL_MAX_CALL_SITES` distinct `(file, function)` pairs have been
@@ -196,6 +233,23 @@ The first time the cap is hit per process, the library emits a single
196
233
  `frugal.gen_ai.internal_errors{stage=cardinality_cap_hit}` point so you can
197
234
  see the transition in your dashboard.
198
235
 
236
+ ### Model cap behavior
237
+
238
+ Model ids are free-form provider strings, so `FRUGAL_MAX_MODELS` (default `100`,
239
+ `0` disables) bounds them the same way. The first N distinct model strings a
240
+ process observes keep their real names; every later one is reported as
241
+ `<overflow>` on whichever of `gen_ai.request.model` / `gen_ai.response.model`
242
+ carries it. The registry is process-wide and **shared by both labels** — a model
243
+ first seen in a response is free to use as a request model, and vice versa — so
244
+ a model already in the registry is never re-charged. The default is generous
245
+ because one logical model normally occupies **two** slots: providers answer a
246
+ request alias (`claude-sonnet-4-5`) with a dated response snapshot
247
+ (`claude-sonnet-4-5-20250929`). The SDK's own sentinels — `<unknown>` and
248
+ `<overflow>` — pass through without consuming a slot. The first time the cap is
249
+ hit per process the library emits a single
250
+ `frugal.gen_ai.internal_errors{stage=model_cap_hit}` point plus one `warn`
251
+ through the debug callback.
252
+
199
253
  ### Internal-error stages
200
254
 
201
255
  `frugal.gen_ai.internal_errors` carries a `frugal.internal_error.stage`
@@ -208,13 +262,16 @@ attribute describing where our own code failed. Non-exhaustive list:
208
262
  | `emission` | Emitting a success/error metric threw |
209
263
  | `duplicate_detection` | The duplicate-request path (signature, filter, or emission) threw — including the one-time filter allocation failure; carries `frugal.internal_error.type` with the exception class |
210
264
  | `cardinality_cap_hit` | `FRUGAL_MAX_CALL_SITES` was just reached (once per process) |
265
+ | `model_cap_hit` | `FRUGAL_MAX_MODELS` was just reached (once per process); further distinct models are reported as `<overflow>` |
266
+ | `otel_cardinality_overflow` | OpenTelemetry merged an instrument's attribute sets into its `otel.metric.overflow` series (once per instrument name); carries `frugal.instrument` with the affected instrument |
211
267
 
212
268
  Unlike every other metric, `frugal.gen_ai.internal_errors` does **not** carry
213
269
  the full base attribute set — it is emitted from failure paths where the
214
270
  caller/provider/model attributes may not exist yet. Its points carry only
215
271
  `frugal.project_id`, `frugal.internal_error.stage` (plus
216
- `frugal.internal_error.type` where noted), and — when set —
217
- `frugal.customer_id` and `frugal.custom_label`.
272
+ `frugal.internal_error.type` where noted, and `frugal.instrument` on the
273
+ `otel_cardinality_overflow` stage), and — when set — `frugal.customer_id` and
274
+ `frugal.custom_label`.
218
275
 
219
276
  ## Provider coverage
220
277
 
@@ -25,11 +25,13 @@ Optional:
25
25
  | `FRUGAL_REPO_ROOT` | Absolute path to the source root; the strip prefix for `caller_file`. Defaults to the process working directory. |
26
26
  | `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | Frugal-specific OTLP collector URL. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
27
27
  | `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`. |
28
- | `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. |
28
+ | `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. The list is deduped (order-preserving) and only the first **10** entries are honored (hard cap, not configurable); the rest are dropped at startup with one debug warning. See [Cardinality caps](#cardinality-caps). |
29
29
  | `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=...`; a value outside the first 20 entries (hard cap, not configurable) is coerced to `other`. |
30
30
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip when attributing the caller (see below). |
31
31
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence. Default `60000`. |
32
- | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute before overflow. Default `500`, set to `0` to disable. See [Cardinality cap](#cardinality-cap). |
32
+ | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute before overflow. Default `500`, set to `0` to disable. See [Cardinality caps](#cardinality-caps). |
33
+ | `FRUGAL_MAX_MODELS` | Cardinality cap — max distinct model strings to attribute before `gen_ai.request.model` / `gen_ai.response.model` overflow to `<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. See [Cardinality caps](#cardinality-caps). |
34
+ | `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). See [Cardinality caps](#cardinality-caps). |
33
35
  | `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE` | Fraction of calls (0..1) where the lightweight prompt analyzer runs and emits derived metrics (`noise_ratio`, `cache_prefix_stable`, `few_shot_*`, …). Default `0.1`. Set to `0` to disable, `1` for every call. See [Prompt analysis](#prompt-analysis). |
34
36
  | `FRUGAL_DUPLICATE_REQUEST_DETECTION` | Duplicate-request detection (`frugal.gen_ai.duplicate_requests`) is **on by default**: every chat request is canonicalized and SHA-256 hashed in-process to classify it `first_seen`/`repeat_seen`. Set to `0`/`false`/`no`/`off` to turn it off entirely (no hashing, no filter memory, no metric). |
35
37
  | `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
@@ -231,6 +233,39 @@ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
231
233
  )
232
234
  ```
233
235
 
236
+ For an agent framework, manually track the enclosing invocation and report its
237
+ model-response turns:
238
+
239
+ ```python
240
+ with track_ai_call(
241
+ system="anthropic",
242
+ operation="invoke_agent",
243
+ request_model="claude-sonnet-4-20250514",
244
+ ) as span:
245
+ result = my_agent_framework_call(prompt)
246
+ span.set_usage(
247
+ input_tokens=result.usage.input_tokens,
248
+ output_tokens=result.usage.output_tokens,
249
+ agent_turns=result.turn_count,
250
+ )
251
+ ```
252
+
253
+ Provider wrappers measure individual model calls and never set `agent_turns`;
254
+ the Claude Agent SDK, OpenAI Agents SDK, LangGraph, and other agent frameworks
255
+ do not have dedicated wrappers yet. `frugal.gen_ai.history_depth` is request
256
+ message count (`messages.length`), not turns. Missing, non-integer,
257
+ non-positive, or otherwise invalid values are silently dropped without
258
+ affecting customer execution, and turn data is success-path only, like token
259
+ usage. See the [metrics reference](metrics-reference.md) for the complete
260
+ contract.
261
+
262
+ **Avoid double-counting tokens.** Only report aggregate `input_tokens` /
263
+ `output_tokens` on the `invoke_agent` call when the agent's inner
264
+ model-client calls are **not** already wrapped by a supported provider
265
+ wrapper. If they are wrapped, those wrappers already emit token usage per
266
+ inner call — report `agent_turns` only on the `invoke_agent` call in that
267
+ case, or the same tokens are double-counted.
268
+
234
269
  The `with` block times the call and emits metrics on exit — or an error metric
235
270
  if the block raises (the exception always propagates). Works the same around an
236
271
  `await` in async code. Call `span.set_usage(...)` once the response is back; if
@@ -268,7 +303,7 @@ prompt analyzer, pass any of `messages` / `system_prompt` / `tools` /
268
303
 
269
304
  **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional):
270
305
  `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
271
- `reasoning_tokens`, `response_model`.
306
+ `reasoning_tokens`, `agent_turns`, `response_model`.
272
307
 
273
308
  The same env-var configuration, safety guarantees, and exported metrics apply.
274
309
 
@@ -291,12 +326,22 @@ FRUGAL_PATH_BLOCK_LIST="src/acme_ai_helpers,libs/llm_utils"
291
326
  Paths are repo-relative (rooted at `FRUGAL_REPO_ROOT`) and match any file
292
327
  under the given prefix.
293
328
 
294
- ## Cardinality cap
329
+ ## Cardinality caps
330
+
331
+ Runaway metric cardinality — a bug that calls the SDK from thousands of source
332
+ locations, a model id with a timestamp in it, a component list that grows with
333
+ every deploy — is the one way this library can cost you real money. Four caps
334
+ bound it: **three configurable caps**, each of which takes `0` to disable, plus
335
+ a fixed one on components.
295
336
 
296
- To prevent runaway metric cardinality (e.g. a bug that calls the SDK from
297
- thousands of distinct source locations), we cap the number of distinct
298
- `(caller_file, caller_function)` pairs the library will attribute per
299
- process. Default **500**, configurable via `FRUGAL_MAX_CALL_SITES`.
337
+ | Cap | Setting | Default | What overflows |
338
+ | --- | --- | --- | --- |
339
+ | Call sites | `FRUGAL_MAX_CALL_SITES` | `500` | `frugal.caller_file` / `frugal.caller_function` → `<overflow>` |
340
+ | Models | `FRUGAL_MAX_MODELS` | `100` | `gen_ai.request.model` / `gen_ai.response.model` → `<overflow>` |
341
+ | Components | fixed, not configurable | `10` | extra `FRUGAL_COMPONENTS` entries are dropped |
342
+ | Series per instrument | `FRUGAL_MAX_SERIES_PER_INSTRUMENT` | `50000` | OpenTelemetry's own limit (see below); `0` = effectively unlimited (a 1,000,000 ceiling) |
343
+
344
+ ### Call sites (`FRUGAL_MAX_CALL_SITES`)
300
345
 
301
346
  **First-come-first-served**: the first N unique call sites observed are
302
347
  always reported with their real names. Additional call sites overflow:
@@ -316,6 +361,75 @@ To disable the cap entirely (you vouch that your cardinality is bounded):
316
361
  FRUGAL_MAX_CALL_SITES=0
317
362
  ```
318
363
 
364
+ ### Models (`FRUGAL_MAX_MODELS`)
365
+
366
+ Model ids are free-form provider strings, so nothing stops a gateway or a
367
+ fine-tune naming scheme from minting a new one per request. The first **100**
368
+ distinct model strings a process sees keep their real names; every one after
369
+ that is reported as `<overflow>`.
370
+
371
+ The registry is **shared** by `gen_ai.request.model` and
372
+ `gen_ai.response.model` — a model first seen in a response is free to use as a
373
+ request model, and vice versa — and it is process-wide, so a call site that
374
+ starts using an already-seen model is never charged for it. The default is
375
+ deliberately generous because **one logical model routinely burns two slots**:
376
+ providers answer a request alias (`claude-sonnet-4-5`) with a dated response
377
+ snapshot (`claude-sonnet-4-5-20250929`), and both labels draw on this one
378
+ registry. The SDK's own sentinels — `<unknown>` (a batch submission with no
379
+ discoverable model) and `<overflow>` itself — never consume a slot.
380
+
381
+ The first time the cap is crossed the SDK emits one
382
+ `frugal.gen_ai.internal_errors{stage=model_cap_hit}` point plus one `warn`
383
+ through the debug callback; further overflows are silent.
384
+
385
+ ### Components (`FRUGAL_COMPONENTS`)
386
+
387
+ `FRUGAL_COMPONENTS` is a static list, and every entry multiplies **every**
388
+ metric's series count (each metric is emitted once for the base project series
389
+ and once per component). The list is deduplicated first (order-preserving, first
390
+ occurrence wins), then only the first **10** entries are honored; the rest are
391
+ dropped at startup with one `warn` through the debug callback naming the deduped
392
+ and dropped counts. Like the `FRUGAL_CUSTOM_LABEL` allowlist this is a fixed constant with no
393
+ env var to change it. There is no sentinel value — nothing is emitted for a
394
+ dropped component, and the un-attributed base series still carries every call,
395
+ so project totals stay correct.
396
+
397
+ ### Series per instrument (`FRUGAL_MAX_SERIES_PER_INSTRUMENT`)
398
+
399
+ The three caps above bound individual **labels**. OpenTelemetry bounds something
400
+ different: the number of distinct label **combinations** (attribute sets) per
401
+ instrument. 400 call sites × 3 models × 2 token types × 7 history buckets is one
402
+ instrument's worth of combinations, not four labels' worth — which is why the
403
+ SDK raises that per-instrument limit to **50000** via
404
+ `FRUGAL_MAX_SERIES_PER_INSTRUMENT` (`0` = effectively unlimited; you vouch for
405
+ your cardinality).
406
+
407
+ When an instrument does exceed its limit, OTel does not drop the excess points —
408
+ it **merges them into a single overflow series with every attribute stripped**
409
+ and replaced by `otel.metric.overflow=true`. Project id, caller, model, token
410
+ type: all gone. The numbers still add up, but that instrument goes dark for
411
+ every breakdown you care about. The SDK detects that condition on the export
412
+ path and emits
413
+ `frugal.gen_ai.internal_errors{stage=otel_cardinality_overflow, frugal.instrument=…}`
414
+ once per affected instrument, plus one `error` through the debug callback.
415
+
416
+ On Python the limit kwarg is passed **only when the installed
417
+ `opentelemetry-sdk` accepts it** — as of 1.42 it does not implement the spec's
418
+ per-instrument limit at all, so nothing is merged and nothing is raised. That
419
+ omission is silent unless you set `FRUGAL_MAX_SERIES_PER_INSTRUMENT` yourself,
420
+ in which case the SDK emits one `warn` telling you the setting has no effect on
421
+ the installed OTel. The detector exists so that the day OTel Python ships the
422
+ limit, an overflow is a loud condition rather than a silent one.
423
+
424
+ > **Memory.** All 17 instruments share the same label space, but only the 3–4
425
+ > busiest (the calls counter, the token histogram, the duration histogram)
426
+ > realistically approach the limit — the rest carry a fraction of the
427
+ > combinations. At a few hundred bytes per attribute set, 50000 sets on the busy
428
+ > ones is tens of MB worst case, comparable to the duplicate filter's documented
429
+ > ceiling. The value is not clamped: the real cost of high cardinality is active
430
+ > series on the metrics server, which is why the model and component axes are
431
+ > capped rather than the limit being lowered.
432
+
319
433
  ## Prompt analysis
320
434
 
321
435
  In addition to token-level metrics, the library runs a lightweight per-request
@@ -109,7 +109,7 @@
109
109
  "targets": [
110
110
  {
111
111
  "datasource": { "type": "prometheus", "uid": "prometheus" },
112
- "expr": "sum(frugal_gen_ai_internal_errors_total{frugal_internal_error_stage=\"cardinality_cap_hit\"}) or vector(0)",
112
+ "expr": "sum(frugal_gen_ai_internal_errors_total{frugal_internal_error_stage=~\"cardinality_cap_hit|model_cap_hit|otel_cardinality_overflow\"}) or vector(0)",
113
113
  "legendFormat": "events",
114
114
  "refId": "A"
115
115
  }
@@ -11,6 +11,12 @@ from __future__ import annotations
11
11
  import threading
12
12
  from collections import OrderedDict
13
13
  from dataclasses import dataclass
14
+ from typing import Final
15
+
16
+ # Matches the default call-site cardinality cap. Also the floor: this LRU lives
17
+ # in the customer's process, so an unbounded one is a memory leak rather than a
18
+ # cardinality choice.
19
+ _DEFAULT_CAPACITY: Final[int] = 500
14
20
 
15
21
 
16
22
  @dataclass(frozen=True)
@@ -22,8 +28,12 @@ class BlockHashes:
22
28
 
23
29
 
24
30
  class BlockHashLRU:
25
- def __init__(self, capacity: int = 500) -> None:
26
- self._capacity = capacity
31
+ def __init__(self, capacity: int = _DEFAULT_CAPACITY) -> None:
32
+ # The capacity is fed from FRUGAL_MAX_CALL_SITES, where 0 means "don't
33
+ # cap the label cardinality — I vouch for it". That is a statement about
34
+ # what reaches the metrics server, NOT permission to grow a process-local
35
+ # map forever, so a non-positive value means "default capacity" here.
36
+ self._capacity = capacity if capacity > 0 else _DEFAULT_CAPACITY
27
37
  self._map: "OrderedDict[str, BlockHashes]" = OrderedDict()
28
38
  self._lock = threading.Lock()
29
39
 
@@ -42,9 +52,10 @@ class BlockHashLRU:
42
52
  self._map[call_site_id] = hashes
43
53
  return
44
54
  self._map[call_site_id] = hashes
45
- if self._capacity > 0:
46
- while len(self._map) > self._capacity:
47
- self._map.popitem(last=False)
55
+ # _capacity is normalized to a positive value in __init__, so this
56
+ # loop always runs — there is no "never evict" mode.
57
+ while len(self._map) > self._capacity:
58
+ self._map.popitem(last=False)
48
59
 
49
60
  def size(self) -> int:
50
61
  with self._lock:
@@ -65,7 +76,12 @@ def get_shared_block_hash_lru(capacity: int | None = None) -> BlockHashLRU:
65
76
  return _shared
66
77
  with _shared_lock:
67
78
  if _shared is None:
68
- _shared = BlockHashLRU(capacity if capacity is not None else 500)
79
+ # A None or non-positive capacity both mean "use the default" —
80
+ # BlockHashLRU.__init__ enforces the floor, so callers passing
81
+ # FRUGAL_MAX_CALL_SITES=0 straight through still get a bounded map.
82
+ _shared = BlockHashLRU(
83
+ capacity if capacity is not None else _DEFAULT_CAPACITY
84
+ )
69
85
  return _shared
70
86
 
71
87