frugal-sdk-python 0.3.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 (97) hide show
  1. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/PKG-INFO +65 -16
  2. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/README.md +63 -14
  3. frugal_sdk_python-0.5.0/docs/metrics-reference.md +308 -0
  4. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/docs/quickstart.md +149 -15
  5. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +1 -1
  6. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/__init__.py +4 -0
  7. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/block_hash_lru.py +22 -6
  8. frugal_sdk_python-0.5.0/src/frugal_metrics/config.py +591 -0
  9. frugal_sdk_python-0.5.0/src/frugal_metrics/duplicate_filter.py +256 -0
  10. frugal_sdk_python-0.5.0/src/frugal_metrics/instrument.py +617 -0
  11. frugal_sdk_python-0.5.0/src/frugal_metrics/model_cap.py +103 -0
  12. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/otel.py +290 -15
  13. frugal_sdk_python-0.5.0/src/frugal_metrics/prompt_analyzer.py +554 -0
  14. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/semconv.py +51 -2
  15. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/track.py +26 -1
  16. frugal_sdk_python-0.5.0/src/frugal_metrics/wrappers/_batch.py +445 -0
  17. frugal_sdk_python-0.5.0/src/frugal_metrics/wrappers/_patching.py +51 -0
  18. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/anthropic.py +10 -0
  19. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/openai.py +44 -24
  20. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/conftest.py +29 -0
  21. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_block_hash_lru.py +31 -2
  22. frugal_sdk_python-0.5.0/tests/test_config.py +678 -0
  23. frugal_sdk_python-0.5.0/tests/test_duplicate_filter.py +234 -0
  24. frugal_sdk_python-0.5.0/tests/test_model_cap.py +413 -0
  25. frugal_sdk_python-0.5.0/tests/test_never_impact_host.py +270 -0
  26. frugal_sdk_python-0.5.0/tests/test_otel_overflow_detection.py +439 -0
  27. frugal_sdk_python-0.5.0/tests/test_prompt_analyzer.py +655 -0
  28. frugal_sdk_python-0.5.0/tests/test_track_ai_call.py +537 -0
  29. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_anthropic.py +24 -0
  30. frugal_sdk_python-0.5.0/tests/test_wrap_anthropic_batches.py +244 -0
  31. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_openai.py +16 -0
  32. frugal_sdk_python-0.5.0/tests/test_wrap_openai_analyzer.py +499 -0
  33. frugal_sdk_python-0.5.0/tests/test_wrap_openai_batches.py +199 -0
  34. frugal_sdk_python-0.3.0/docs/metrics-reference.md +0 -155
  35. frugal_sdk_python-0.3.0/src/frugal_metrics/config.py +0 -272
  36. frugal_sdk_python-0.3.0/src/frugal_metrics/instrument.py +0 -282
  37. frugal_sdk_python-0.3.0/src/frugal_metrics/prompt_analyzer.py +0 -273
  38. frugal_sdk_python-0.3.0/tests/test_config.py +0 -181
  39. frugal_sdk_python-0.3.0/tests/test_prompt_analyzer.py +0 -203
  40. frugal_sdk_python-0.3.0/tests/test_track_ai_call.py +0 -240
  41. frugal_sdk_python-0.3.0/tests/test_wrap_openai_analyzer.py +0 -188
  42. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/.github/workflows/cd.yml +0 -0
  43. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/.gitignore +0 -0
  44. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/LICENSES/Apache-2.0.txt +0 -0
  45. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/REUSE.toml +0 -0
  46. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/README.md +0 -0
  47. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
  48. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/docker-compose.yml +0 -0
  49. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
  50. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
  51. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/Dockerfile +0 -0
  52. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/package.json +0 -0
  53. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/src/server.js +0 -0
  54. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
  55. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/models.html +0 -0
  56. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
  57. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
  58. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/prometheus.yml +0 -0
  59. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/Dockerfile +0 -0
  60. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/__init__.py +0 -0
  61. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/clients.py +0 -0
  62. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
  63. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
  64. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
  65. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
  66. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
  67. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
  68. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
  69. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
  70. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
  71. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
  72. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
  73. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
  74. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/main.py +0 -0
  75. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
  76. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
  77. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/pyproject.toml +0 -0
  78. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/requirements.txt +0 -0
  79. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/examples/test-env/scripts/verify.sh +0 -0
  80. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/pyproject.toml +0 -0
  81. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/caller.py +0 -0
  82. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/debug.py +0 -0
  83. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/providers.py +0 -0
  84. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/safe.py +0 -0
  85. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/shutdown.py +0 -0
  86. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/streams.py +0 -0
  87. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
  88. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
  89. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
  90. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/__init__.py +0 -0
  91. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_bedrock_models.py +0 -0
  92. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_caller.py +0 -0
  93. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_caller_edge_cases.py +0 -0
  94. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_debug.py +0 -0
  95. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_providers.py +0 -0
  96. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_shutdown.py +0 -0
  97. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_bedrock.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: frugal-sdk-python
3
- Version: 0.3.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,20 @@ 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
+ | `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`. |
82
83
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
83
84
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
84
- | `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. |
85
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. |
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. |
90
+ | `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
91
+ | `FRUGAL_DUPLICATE_CAPACITY` | Expected distinct request signatures per window (sizes the in-memory filter). Default `10000`, max `1000000`. Raise it if the SDK warns the filter is saturated. |
92
+ | `FRUGAL_DUPLICATE_FP_RATE` | Target false-positive rate of the duplicate filter. Default `0.01`, clamped to `[1e-6, 0.5]`. |
93
+ | `FRUGAL_DUPLICATE_GENERATIONS` | Rotation granularity of the duplicate filter's time window. Default `2`, max `16`. |
86
94
  | `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
87
95
 
88
96
  ## What gets captured
@@ -92,32 +100,40 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
92
100
  | Metric | Kind | Description | Additional attributes |
93
101
  | --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
94
102
  | `gen_ai.client.operation.duration` | histogram | Call latency in seconds | — |
95
- | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type` |
96
- | `frugal.gen_ai.calls` | counter | Number of calls per site | — |
103
+ | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type`, `frugal.history_bucket` |
104
+ | `frugal.gen_ai.calls` | counter | Number of calls per site | `frugal.history_bucket`, `frugal.structured_output_mode` |
97
105
  | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
98
106
  | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
99
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) | — |
100
109
  | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
101
- | `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` |
102
111
  | `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
103
- | `frugal.gen_ai.history_depth` | histogram | `messages.length` (analyzer-sampled) | — |
112
+ | `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
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` |
104
114
  | `frugal.gen_ai.few_shot_count` | histogram | Few-shot exemplar pairs before final user (analyzer-sampled) | — |
115
+ | `frugal.gen_ai.few_shot_tokens` | histogram | Approx token size of the few-shot example block (analyzer-sampled) | — |
105
116
  | `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
106
117
  | `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
107
118
  | `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
108
119
 
109
- The bottom block of analyzer-sampled metrics fires on a fraction of calls
110
- (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics are
111
- 100% — never sampled.
120
+ The analyzer-sampled metrics fire on a fraction of calls
121
+ (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics,
122
+ `history_depth`, and `duplicate_requests` are 100% — never sampled.
112
123
 
113
- Every metric carries this base attribute set:
124
+ Every metric carries this base attribute set (except
125
+ `frugal.gen_ai.internal_errors`, which is emitted from failure paths where
126
+ these attributes may not exist yet and carries only `frugal.project_id`, the
127
+ stage/type, `frugal.instrument` on the `otel_cardinality_overflow` stage, and
128
+ `frugal.customer_id` / `frugal.custom_label` when set):
114
129
 
115
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
116
131
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
117
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).
118
- - `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
119
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)
120
- - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
135
+ - `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
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`
121
137
 
122
138
  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.
123
139
 
@@ -157,6 +173,39 @@ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
157
173
  )
158
174
  ```
159
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
+
160
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.
161
210
 
162
211
  For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
@@ -179,9 +228,9 @@ except BaseException as exc:
179
228
 
180
229
  `success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
181
230
 
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`.
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`.
183
232
 
184
- **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`.
185
234
 
186
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.
187
236
 
@@ -48,12 +48,20 @@ 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
+ | `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`. |
53
54
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
54
55
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
55
- | `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. |
56
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. |
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. |
61
+ | `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
62
+ | `FRUGAL_DUPLICATE_CAPACITY` | Expected distinct request signatures per window (sizes the in-memory filter). Default `10000`, max `1000000`. Raise it if the SDK warns the filter is saturated. |
63
+ | `FRUGAL_DUPLICATE_FP_RATE` | Target false-positive rate of the duplicate filter. Default `0.01`, clamped to `[1e-6, 0.5]`. |
64
+ | `FRUGAL_DUPLICATE_GENERATIONS` | Rotation granularity of the duplicate filter's time window. Default `2`, max `16`. |
57
65
  | `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
58
66
 
59
67
  ## What gets captured
@@ -63,32 +71,40 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
63
71
  | Metric | Kind | Description | Additional attributes |
64
72
  | --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
65
73
  | `gen_ai.client.operation.duration` | histogram | Call latency in seconds | — |
66
- | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type` |
67
- | `frugal.gen_ai.calls` | counter | Number of calls per site | — |
74
+ | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type`, `frugal.history_bucket` |
75
+ | `frugal.gen_ai.calls` | counter | Number of calls per site | `frugal.history_bucket`, `frugal.structured_output_mode` |
68
76
  | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
69
77
  | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
70
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) | — |
71
80
  | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
72
- | `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` |
73
82
  | `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
74
- | `frugal.gen_ai.history_depth` | histogram | `messages.length` (analyzer-sampled) | — |
83
+ | `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
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` |
75
85
  | `frugal.gen_ai.few_shot_count` | histogram | Few-shot exemplar pairs before final user (analyzer-sampled) | — |
86
+ | `frugal.gen_ai.few_shot_tokens` | histogram | Approx token size of the few-shot example block (analyzer-sampled) | — |
76
87
  | `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
77
88
  | `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
78
89
  | `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
79
90
 
80
- The bottom block of analyzer-sampled metrics fires on a fraction of calls
81
- (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics are
82
- 100% — never sampled.
91
+ The analyzer-sampled metrics fire on a fraction of calls
92
+ (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics,
93
+ `history_depth`, and `duplicate_requests` are 100% — never sampled.
83
94
 
84
- Every metric carries this base attribute set:
95
+ Every metric carries this base attribute set (except
96
+ `frugal.gen_ai.internal_errors`, which is emitted from failure paths where
97
+ these attributes may not exist yet and carries only `frugal.project_id`, the
98
+ stage/type, `frugal.instrument` on the `otel_cardinality_overflow` stage, and
99
+ `frugal.customer_id` / `frugal.custom_label` when set):
85
100
 
86
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
87
102
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
88
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).
89
- - `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
90
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)
91
- - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
106
+ - `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
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`
92
108
 
93
109
  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.
94
110
 
@@ -128,6 +144,39 @@ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
128
144
  )
129
145
  ```
130
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
+
131
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.
132
181
 
133
182
  For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
@@ -150,9 +199,9 @@ except BaseException as exc:
150
199
 
151
200
  `success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
152
201
 
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`.
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`.
154
203
 
155
- **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`.
156
205
 
157
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.
158
207
 
@@ -0,0 +1,308 @@
1
+ # Metrics reference
2
+
3
+ All metrics follow the OpenTelemetry GenAI semantic convention where a name
4
+ exists; Frugal-namespaced extensions (`frugal.*`) cover signals semconv does
5
+ not yet define.
6
+
7
+ Every metric point carries the attribute set listed under
8
+ [Attributes](#attributes) below.
9
+
10
+ ## Metrics
11
+
12
+ In addition to the base attribute set (see [Attributes](#attributes)), each
13
+ metric may carry the extra attributes shown in the rightmost column.
14
+
15
+ | Name | Kind | Unit | Emitted by | Additional attributes |
16
+ | ------------------------------------- | --------- | ----------- | ----------------------------------------------------- | ---------------------------------------------- |
17
+ | `gen_ai.client.operation.duration` | histogram | `s` | every call, success or error | — |
18
+ | `gen_ai.client.token.usage` | histogram | `{token}` | every call with a usage payload | `gen_ai.token.type` |
19
+ | `frugal.gen_ai.calls` | counter | `{call}` | every call, success or error | `frugal.history_bucket` (chat calls), `frugal.structured_output_mode` (with payload) |
20
+ | `frugal.gen_ai.cache_read_tokens` | histogram | `{token}` | when provider reports cache reads | — |
21
+ | `frugal.gen_ai.cache_write_tokens` | histogram | `{token}` | when provider reports cache writes | — |
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) | — |
24
+ | `frugal.gen_ai.errors` | counter | `{error}` | when the wrapped call raises | `frugal.error.type` |
25
+ | `frugal.gen_ai.internal_errors` | counter | `{error}` | frugal-metrics internal failures (cardinality cap, instrumentation bugs) | `frugal.internal_error.stage` |
26
+ | `frugal.gen_ai.output_cap_utilization`| histogram | `1` | when request had `max_tokens` (ratio 0..1) | `frugal.structured_output_mode` |
27
+ | `frugal.gen_ai.history_depth` | histogram | `{message}` | every chat call (`messages.length`) — 100%, not sampled | — |
28
+ | `frugal.gen_ai.few_shot_count` | histogram | `{example}` | analyzer-sampled single-turn chat (count of `(user, assistant)` pairs before final user) | — |
29
+ | `frugal.gen_ai.few_shot_tokens` | histogram | `{token}` | analyzer-sampled single-turn chat — approx token size of the `(user, assistant)` example block, beside `few_shot_count` | — |
30
+ | `frugal.gen_ai.noise_ratio` | histogram | `1` | analyzer-sampled chat call (HTML / base64 / whitespace fraction of input) | — |
31
+ | `frugal.gen_ai.cache_prefix_stable` | counter | `{call}` | analyzer-sampled chat call, when previous call to the same call site existed (one point per block, see Attributes) | `frugal.block`, `frugal.stable` |
32
+ | `frugal.gen_ai.structured_output_hint`| counter | `{call}` | analyzer-sampled chat call where the prompt asks for JSON without `response_format` or `tools` | — |
33
+ | `frugal.gen_ai.duplicate_requests` | counter | `{call}` | every chat call with a canonicalizable request, success or error — 100%, not sampled; off when `FRUGAL_DUPLICATE_REQUEST_DETECTION=false` | `frugal.dup_state` |
34
+
35
+ `gen_ai.client.token.usage` is split across input and output via the
36
+ `gen_ai.token.type` attribute (`input` | `output`).
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
+
69
+ The analyzer-sampled metrics are gated by `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`
70
+ (default `0.1`). Sampling is deterministic per `(caller_file, caller_function,
71
+ minute)` — within a minute a given call site is either fully sampled or fully
72
+ skipped, so the LRU comparisons that drive `cache_prefix_stable` see contiguous
73
+ data when active. The token-side metrics (cap utilization, batched label,
74
+ calls, token usage) are 100% — never sampled. `frugal.gen_ai.history_depth` and
75
+ the `frugal.history_bucket` label are likewise 100% — `messages.length` is a
76
+ trivially-cheap integer needing no prompt analysis, so it is emitted on every chat
77
+ call (`few_shot_count`, `few_shot_tokens`, `noise_ratio`, `cache_prefix_stable`, and
78
+ `structured_output_hint` remain analyzer-sampled). `few_shot_tokens` is emitted from
79
+ the same analyzer pass as `few_shot_count` (identifying which turns are examples
80
+ needs prompt inspection, so there is no cheap 100% path); its token count is an
81
+ approximate chars/4 estimate over the example block.
82
+
83
+ Label placement and cardinality: `frugal.history_bucket` rides BOTH the
84
+ `frugal.gen_ai.calls` counter and the `gen_ai.client.token.usage` histogram —
85
+ on token.usage deliberately, so spend can be cut by conversation depth.
86
+ Worst-case that multiplies the token.usage series by 7 buckets × 2 token types
87
+ per call site (in practice a site occupies 1–3 buckets, so expect 2–6× — but
88
+ provision for 14×). `frugal.structured_output_mode` rides ONLY `calls` (and
89
+ `output_cap_utilization`), NOT token.usage — the full 7×4 bucket×mode
90
+ cross-tab lives on `calls` alone, where worst-case it is 28 series per call
91
+ site.
92
+
93
+ `frugal.gen_ai.duplicate_requests` is also 100% (independent of
94
+ `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`). Each chat call's request is canonicalized
95
+ (`model + system + messages + tools + response_format + max_tokens` — plus
96
+ `previous_response_id` for stateful OpenAI Responses-API calls — with the call
97
+ site folded in) and hashed; the hash is checked against a shared, time-and-size-
98
+ limited rotating Bloom filter and classified `first_seen` / `repeat_seen`. Errored
99
+ calls are classified too — a failed-then-retried identical request reads
100
+ `repeat_seen`, keeping retry storms visible. The raw signature never leaves the
101
+ SDK — only the 2-value state is exported. Hashing is incremental and hard-capped
102
+ at 256 KiB of canonicalized request per call: the small discriminators (call
103
+ site, model, system, tools, response_format, max_tokens, previous_response_id)
104
+ are hashed FIRST, then `messages` under the remaining budget — so an over-budget
105
+ history truncates the tail of the conversation (plus a truncation marker), never
106
+ the fields that keep distinct call sites or models from colliding. Binary/image
107
+ content contributes only its type and length — never its bytes — covering raw
108
+ `bytes` blocks AND the base64-string forms providers actually send (OpenAI
109
+ `data:` URLs, Anthropic base64 source blocks). If the filter saturates (actual
110
+ traffic far above `FRUGAL_DUPLICATE_CAPACITY`, generation union over 50% full),
111
+ a one-time `warn` diagnostic is emitted through the debug callback. If the
112
+ filter's lazy bit-array allocation fails (`MemoryError`), duplicate detection is
113
+ **disabled for the rest of the process**: a one-time `error` diagnostic is
114
+ emitted, the first failing call records a
115
+ `frugal.gen_ai.internal_errors{stage=duplicate_detection}` point, and every
116
+ subsequent call skips detection silently (no `duplicate_requests` metric).
117
+ Controls:
118
+
119
+ - `FRUGAL_DUPLICATE_REQUEST_DETECTION` (default `true`) — kill switch. When
120
+ `false`/`0`/`no`/`off`, no filter is allocated, no signature is hashed, and the
121
+ metric is not emitted. Any other value keeps detection enabled and emits a
122
+ one-time `warn` diagnostic naming the unrecognized value.
123
+ - `FRUGAL_DUPLICATE_WINDOW_SECONDS` (default `3600`, clamped to `604800` — 7
124
+ days) — retention window. Because the filter rotates in `generations` slots,
125
+ a signature is remembered for **at least**
126
+ `window × (generations−1)/generations` and **at most** `window` seconds
127
+ (30–60 min at defaults) — treat the counterfactual cache TTL as that range,
128
+ not a flat window.
129
+ - `FRUGAL_DUPLICATE_CAPACITY` (default `10000`, clamped to `1000000`) — expected
130
+ distinct signatures per window (Bloom sizing target).
131
+ - `FRUGAL_DUPLICATE_FP_RATE` (default `0.01`, clamped to `[1e-6, 0.5]`) —
132
+ target false-positive rate.
133
+ - `FRUGAL_DUPLICATE_GENERATIONS` (default `2`, clamped to `16`) — rotating-Bloom
134
+ generation count.
135
+
136
+ Out-of-range or non-finite values for any of these fall back to the default (or
137
+ clamp to the bound) with a one-time named `warn` through the debug callback.
138
+
139
+ Reading `duplicate_requests` correctly:
140
+
141
+ - **Denominator.** Coverage is a per-endpoint subset of `calls` (embeddings,
142
+ legacy text completions, bare `track_ai_call` without messages, and other
143
+ non-chat surfaces emit no duplicate signal), so compute repeat rates as
144
+ `repeat_seen / (first_seen + repeat_seen)` — never against `calls`.
145
+ - **Retries count as repeats.** Errored calls insert into the filter, so a
146
+ retried-after-failure request reads `repeat_seen`. That keeps retry storms
147
+ visible, but it also means `repeat_seen` includes required retry traffic that
148
+ a response cache would not eliminate — net it against
149
+ `frugal.gen_ai.errors` when estimating cacheable waste.
150
+ - **Sampling params are ignored.** The signature deliberately excludes
151
+ `temperature`, `top_p`, `seed`, `n`, and `tool_choice`, so best-of-N /
152
+ self-consistency workloads read as 100% duplicates. Fine for spotting
153
+ caching opportunities; wrong for assuming every repeat is waste.
154
+ - **Why a separate counter, not a label on `calls`:** the metric has its own
155
+ kill switch, covers only a subset of calls, and its 2-value state would
156
+ otherwise double every `calls` series; keeping it separate keeps `calls`
157
+ stable when detection is toggled.
158
+
159
+ ## Attributes
160
+
161
+ ### From the GenAI semantic convention
162
+
163
+ | Attribute | Example |
164
+ | -------------------------- | ------------------------------------------ |
165
+ | `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) |
166
+ | `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) |
167
+ | `gen_ai.operation.name` | `chat` \| `text_completion` \| `embeddings` \| `generate_content` \| `execute_tool` \| `create_agent` \| `invoke_agent` \| `other` (sentinel for an unrecognized operation) |
168
+ | `gen_ai.request.model` | `gpt-4o` / `claude-3-5-sonnet-latest` / … |
169
+ | `gen_ai.response.model` | Provider-returned model id (often identical)|
170
+ | `gen_ai.token.type` | `input` \| `output` (token usage metric only) |
171
+
172
+ > **Provider attribute migration.** OTel renamed `gen_ai.system` to
173
+ > `gen_ai.provider.name` (some spellings changed, e.g. `vertex_ai` →
174
+ > `gcp.vertex_ai`). The SDK **dual-emits both** during the transition: every
175
+ > metric carries the legacy `gen_ai.system` value (so existing queries/history
176
+ > keep matching) and the new `gen_ai.provider.name` value. The `system` kwarg
177
+ > accepts either spelling. A value outside the well-known set (e.g. a typo like
178
+ > `amazon-bedrock`) is **coerced to `custom`** on both labels — so a wrong label
179
+ > never reaches metrics — and surfaced once through the debug callback
180
+ > (`set_debug_callback`), per distinct value (bounded so a high-cardinality
181
+ > caller can't grow the warning set without limit). To keep a legitimate provider
182
+ > that isn't in the registry, register it via `FRUGAL_CUSTOM_PROVIDERS` (up to 10)
183
+ > and it passes through verbatim on both labels. An `operation` outside the accepted set is coerced to
184
+ > the `other` sentinel (NOT `chat`, so a typo can't inflate the real chat series) with the same
185
+ > one-time warning — the seven listed values plus an explicit `other` pass through unchanged. The
186
+ > exported `GenAiSystem` / `GenAiOperation` `Literal` aliases give strict typing for callers who want
187
+ > it — `GenAiSystem` lists both provider spellings the runtime accepts (the kwargs stay `str` so a
188
+ > free-form custom value remains legal).
189
+
190
+ ### Frugal-added
191
+
192
+ | Attribute | Value |
193
+ | ------------------------- | ---------------------------------------------------------- |
194
+ | `frugal.customer_id` | From `FRUGAL_CUSTOMER_ID` |
195
+ | `frugal.project_id` | From `FRUGAL_PROJECT_ID` |
196
+ | `frugal.component` | From each entry in `FRUGAL_COMPONENTS` — present only on the per-component series, absent on the base project series |
197
+ | `frugal.caller_file` | Source file repo-relative to `FRUGAL_REPO_ROOT` |
198
+ | `frugal.caller_function` | Function/method name at the call site |
199
+ | `frugal.error.type` | Exception class name (error-path metrics only) |
200
+ | `frugal.batched` | `true` when the metric came from a provider batch-API path, `false` otherwise. Present on every metric. Batch submissions (OpenAI `batches.create`, Anthropic `messages.batches.create`) emit `frugal.gen_ai.calls` with `batched="true"` — one point per request for Anthropic (the requests array is in hand, grouped by model), one per submission for OpenAI (per-request counts are not reported at submission time, so OpenAI batched call volume is a floor, never an overcount). Iterating Anthropic `messages.batches.results()` additionally emits per-entry `gen_ai.client.token.usage` (+ cache metrics) with `batched="true"`, attributed to the call site that submitted the batch when the submission happened in the same process (bounded in-process memory; falls back to the results call site). OpenAI batch token usage lives in the output file read through the generic files API and is not intercepted. The manual track API accepts a `batched` flag for hand-instrumented batch paths. An app on an SDK predating batch support reads `false` on genuinely batched traffic — the label reflects what the deployed SDK can see. |
201
+ | `frugal.structured_output_mode` | `none` \| `json_object` \| `json_schema` \| `tools`. Derived from request params by precedence: non-empty `tools` → `tools`; else `response_format.type` `json_object` / `json_schema` → the matching value; else `none`. On `frugal.gen_ai.output_cap_utilization` (when `max_tokens` set), and on `frugal.gen_ai.calls` for every call with a request payload (omitted when there is no payload). |
202
+ | `frugal.block` | `system` \| `user` \| `tools` \| `schema`. Only on `frugal.gen_ai.cache_prefix_stable`. |
203
+ | `frugal.stable` | `true` when the block hash matches the previous call at this site. Only on `frugal.gen_ai.cache_prefix_stable`. |
204
+ | `frugal.history_bucket` | Bounded bucket of `messages.length`: `0` \| `1-2` \| `3-5` \| `6-10` \| `11-20` \| `21-50` \| `51+`. On `frugal.gen_ai.calls` and `gen_ai.client.token.usage`, and only for chat calls (payload has a `messages` array); omitted otherwise. 100% (not analyzer-sampled). |
205
+ | `frugal.custom_label` | Custom label (e.g. a business unit) passed as `custom_label=` to `track_ai_call` / `start_ai_call` — never set by the provider wrappers. When passed, it rides EVERY metric of that call (success and error paths alike). The value is trimmed of surrounding whitespace (matching the Node SDK), then must match one of the first 20 entries of `FRUGAL_CUSTOM_LABEL` (hard cap, not configurable) or it is coerced to `other`. Omitted when not passed. |
206
+ | `frugal.dup_state` | `first_seen` \| `repeat_seen` — whether this request's canonical signature was already seen within the duplicate-detection window. Only on `frugal.gen_ai.duplicate_requests`. |
207
+
208
+ ### Sentinel values for `frugal.caller_file` / `frugal.caller_function`
209
+
210
+ | Value | Meaning |
211
+ | -------------- | ---------------------------------------------------------- |
212
+ | `<external>` | Caller frame resolved outside `FRUGAL_REPO_ROOT` |
213
+ | `<unknown>` | Could not resolve a caller frame (should not happen in practice) |
214
+ | `<lambda>` | Call originated from a `lambda` expression. This is Python's native `co_name` for lambdas. Only appears in `frugal.caller_function`. |
215
+ | `<overflow>` | Cardinality cap (`FRUGAL_MAX_CALL_SITES`) exceeded. See below. |
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
+
222
+ ### Cardinality cap behavior
223
+
224
+ Once `FRUGAL_MAX_CALL_SITES` distinct `(file, function)` pairs have been
225
+ observed, new call sites overflow. Two shapes are possible:
226
+
227
+ | Attribution | Meaning |
228
+ | --- | --- |
229
+ | `(real/file.py, <overflow>)` | The file was already seen in the first N pairs; the new function inside that file is bucketed into `<overflow>` |
230
+ | `(<overflow>, <overflow>)` | Neither the file nor the function was seen before the cap was reached |
231
+
232
+ The first time the cap is hit per process, the library emits a single
233
+ `frugal.gen_ai.internal_errors{stage=cardinality_cap_hit}` point so you can
234
+ see the transition in your dashboard.
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
+
253
+ ### Internal-error stages
254
+
255
+ `frugal.gen_ai.internal_errors` carries a `frugal.internal_error.stage`
256
+ attribute describing where our own code failed. Non-exhaustive list:
257
+
258
+ | Stage | When it fires |
259
+ | ----------------------- | ---------------------------------------------------------------------- |
260
+ | `setup_failed` | Our wrapper setup (OTel init, caller resolution) threw; original call continued normally |
261
+ | `stream_proxy_failed` | Constructing the stream wrapper threw; original response returned unwrapped |
262
+ | `emission` | Emitting a success/error metric threw |
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 |
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 |
267
+
268
+ Unlike every other metric, `frugal.gen_ai.internal_errors` does **not** carry
269
+ the full base attribute set — it is emitted from failure paths where the
270
+ caller/provider/model attributes may not exist yet. Its points carry only
271
+ `frugal.project_id`, `frugal.internal_error.stage` (plus
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`.
275
+
276
+ ## Provider coverage
277
+
278
+ | Provider | Wrapper | `gen_ai.system` |
279
+ | ------------------------------------------------------- | ------------------------------------ | ---------------- |
280
+ | OpenAI | `wrap_openai` | `openai` |
281
+ | Anthropic (direct API) | `wrap_anthropic` | `anthropic` |
282
+ | Claude on AWS Bedrock (via Anthropic SDK) | `wrap_anthropic(AnthropicBedrock())` | `aws.bedrock` |
283
+ | AWS Bedrock — any model, AWS SDKs directly | `wrap_bedrock` | `aws.bedrock` |
284
+ | Claude on Google Vertex | `wrap_anthropic(AnthropicVertex())` | `vertex_ai` |
285
+ | Custom / manual call sites | `track_ai_call` / `start_ai_call` | `custom` (override via `system=`) |
286
+
287
+ `wrap_bedrock` covers all four `bedrock-runtime` operations (`InvokeModel`,
288
+ `InvokeModelWithResponseStream`, `Converse`, `ConverseStream`) on every model
289
+ hosted on Bedrock — Anthropic Claude, OpenAI gpt-oss, Amazon Nova, Meta Llama,
290
+ Mistral, Cohere, AI21 Jamba, DeepSeek, Qwen, Kimi, Twelvelabs, Nvidia, etc.
291
+ Per-provider response parsing handles the major families (Anthropic /
292
+ OpenAI / Nova / Llama / Mistral) for accurate cache + reasoning token
293
+ extraction; everything else falls back to the universal
294
+ `amazon-bedrock-invocationMetrics` (streaming) /
295
+ `x-amzn-bedrock-*-token-count` (non-streaming) headers that Bedrock attaches
296
+ to every response.
297
+
298
+ > **Reasoning tokens on Bedrock.** Anthropic's `InvokeModel` path exposes
299
+ > `usage.thinking_tokens` for extended-thinking calls; we surface it as
300
+ > `frugal.gen_ai.reasoning_tokens`. Bedrock's `Converse` API normalizes
301
+ > reasoning tokens into `outputTokens` rather than breaking them out, so
302
+ > calls made via `Converse` lose reasoning-token visibility.
303
+
304
+ Azure OpenAI clients currently fall through the OpenAI wrapper and are tagged
305
+ as `openai`; dedicated Azure detection is deferred.
306
+
307
+ Native Vertex SDKs (`google-cloud-aiplatform`) for non-Claude models are not
308
+ yet instrumented.