frugal-sdk-python 0.2.0__tar.gz → 0.4.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 (92) hide show
  1. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/PKG-INFO +26 -13
  2. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/README.md +24 -11
  3. frugal_sdk_python-0.4.0/docs/metrics-reference.md +251 -0
  4. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/docs/quickstart.md +34 -7
  5. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/__init__.py +18 -0
  6. frugal_sdk_python-0.4.0/src/frugal_metrics/config.py +448 -0
  7. frugal_sdk_python-0.4.0/src/frugal_metrics/duplicate_filter.py +256 -0
  8. frugal_sdk_python-0.4.0/src/frugal_metrics/instrument.py +520 -0
  9. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/otel.py +20 -0
  10. frugal_sdk_python-0.4.0/src/frugal_metrics/prompt_analyzer.py +554 -0
  11. frugal_sdk_python-0.4.0/src/frugal_metrics/providers.py +219 -0
  12. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/semconv.py +48 -1
  13. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/track.py +21 -1
  14. frugal_sdk_python-0.4.0/src/frugal_metrics/wrappers/_batch.py +445 -0
  15. frugal_sdk_python-0.4.0/src/frugal_metrics/wrappers/_patching.py +51 -0
  16. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/anthropic.py +10 -0
  17. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/openai.py +44 -24
  18. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/conftest.py +22 -0
  19. frugal_sdk_python-0.4.0/tests/test_config.py +357 -0
  20. frugal_sdk_python-0.4.0/tests/test_duplicate_filter.py +234 -0
  21. frugal_sdk_python-0.4.0/tests/test_prompt_analyzer.py +655 -0
  22. frugal_sdk_python-0.4.0/tests/test_providers.py +180 -0
  23. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_track_ai_call.py +167 -0
  24. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_wrap_anthropic.py +24 -0
  25. frugal_sdk_python-0.4.0/tests/test_wrap_anthropic_batches.py +244 -0
  26. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_wrap_openai.py +16 -0
  27. frugal_sdk_python-0.4.0/tests/test_wrap_openai_analyzer.py +499 -0
  28. frugal_sdk_python-0.4.0/tests/test_wrap_openai_batches.py +199 -0
  29. frugal_sdk_python-0.2.0/docs/metrics-reference.md +0 -136
  30. frugal_sdk_python-0.2.0/src/frugal_metrics/config.py +0 -242
  31. frugal_sdk_python-0.2.0/src/frugal_metrics/instrument.py +0 -276
  32. frugal_sdk_python-0.2.0/src/frugal_metrics/prompt_analyzer.py +0 -273
  33. frugal_sdk_python-0.2.0/tests/test_config.py +0 -163
  34. frugal_sdk_python-0.2.0/tests/test_prompt_analyzer.py +0 -203
  35. frugal_sdk_python-0.2.0/tests/test_wrap_openai_analyzer.py +0 -188
  36. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/.github/workflows/cd.yml +0 -0
  37. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/.gitignore +0 -0
  38. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/LICENSES/Apache-2.0.txt +0 -0
  39. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/REUSE.toml +0 -0
  40. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/README.md +0 -0
  41. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
  42. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/docker-compose.yml +0 -0
  43. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
  44. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
  45. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
  46. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/Dockerfile +0 -0
  47. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/package.json +0 -0
  48. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/src/server.js +0 -0
  49. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
  50. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/models.html +0 -0
  51. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
  52. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
  53. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/prometheus.yml +0 -0
  54. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/Dockerfile +0 -0
  55. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/__init__.py +0 -0
  56. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/clients.py +0 -0
  57. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
  58. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
  59. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
  60. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
  61. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
  62. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
  63. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
  64. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
  65. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
  66. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
  67. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
  68. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
  69. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/main.py +0 -0
  70. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
  71. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
  72. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/pyproject.toml +0 -0
  73. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/requirements.txt +0 -0
  74. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/scripts/verify.sh +0 -0
  75. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/pyproject.toml +0 -0
  76. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/block_hash_lru.py +0 -0
  77. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/caller.py +0 -0
  78. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/debug.py +0 -0
  79. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/safe.py +0 -0
  80. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/shutdown.py +0 -0
  81. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/streams.py +0 -0
  82. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
  83. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
  84. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
  85. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/__init__.py +0 -0
  86. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_bedrock_models.py +0 -0
  87. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_block_hash_lru.py +0 -0
  88. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_caller.py +0 -0
  89. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_caller_edge_cases.py +0 -0
  90. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_debug.py +0 -0
  91. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_shutdown.py +0 -0
  92. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.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.2.0
3
+ Version: 0.4.0
4
4
  Summary: Instrumentation for Cost-to-Code attribution.
5
5
  Project-URL: Homepage, https://frugal.co
6
6
  Author: Frugal AI Inc.
@@ -78,10 +78,17 @@ Set both or the wrapper silently returns the client unmodified:
78
78
  | `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL for Frugal metrics. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
79
79
  | `FRUGAL_OTEL_EXPORTER_OTLP_HEADERS` | Export auth headers (e.g. `Authorization=Bearer ...`). Overrides the header built from `FRUGAL_API_KEY`. Falls back to `OTEL_EXPORTER_OTLP_HEADERS`. |
80
80
  | `FRUGAL_COMPONENTS` | Comma-separated logical components (e.g. `ai-handler,reco`). When set, each metric is ALSO emitted once per component (tagged `frugal.component`) on top of the base project series, so usage can be viewed for the whole project or split by component. |
81
+ | `FRUGAL_CUSTOM_PROVIDERS` | Comma-separated allowlist of provider names beyond the built-in registry (e.g. `together_ai,fireworks`) that should pass through verbatim on `gen_ai.system` / `gen_ai.provider.name` instead of being coerced to `custom`. Capped at 10 (extras are coerced) to keep label cardinality bounded. |
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`. |
81
83
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
82
84
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
83
85
  | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
84
86
  | `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
+ | `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
+ | `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
89
+ | `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. |
90
+ | `FRUGAL_DUPLICATE_FP_RATE` | Target false-positive rate of the duplicate filter. Default `0.01`, clamped to `[1e-6, 0.5]`. |
91
+ | `FRUGAL_DUPLICATE_GENERATIONS` | Rotation granularity of the duplicate filter's time window. Default `2`, max `16`. |
85
92
  | `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
86
93
 
87
94
  ## What gets captured
@@ -91,32 +98,38 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
91
98
  | Metric | Kind | Description | Additional attributes |
92
99
  | --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
93
100
  | `gen_ai.client.operation.duration` | histogram | Call latency in seconds | — |
94
- | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type` |
95
- | `frugal.gen_ai.calls` | counter | Number of calls per site | — |
101
+ | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type`, `frugal.history_bucket` |
102
+ | `frugal.gen_ai.calls` | counter | Number of calls per site | `frugal.history_bucket`, `frugal.structured_output_mode` |
96
103
  | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
97
104
  | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
98
105
  | `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
99
106
  | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
100
107
  | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
101
108
  | `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
102
- | `frugal.gen_ai.history_depth` | histogram | `messages.length` (analyzer-sampled) | — |
109
+ | `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
110
+ | `frugal.gen_ai.duplicate_requests` | counter | Repeat-request detection — every chat call (100%, not sampled; disable via `FRUGAL_DUPLICATE_REQUEST_DETECTION`) | `frugal.dup_state` |
103
111
  | `frugal.gen_ai.few_shot_count` | histogram | Few-shot exemplar pairs before final user (analyzer-sampled) | — |
112
+ | `frugal.gen_ai.few_shot_tokens` | histogram | Approx token size of the few-shot example block (analyzer-sampled) | — |
104
113
  | `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
105
114
  | `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
106
115
  | `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
107
116
 
108
- The bottom block of analyzer-sampled metrics fires on a fraction of calls
109
- (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics are
110
- 100% — never sampled.
117
+ The analyzer-sampled metrics fire on a fraction of calls
118
+ (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics,
119
+ `history_depth`, and `duplicate_requests` are 100% — never sampled.
111
120
 
112
- Every metric carries this base attribute set:
121
+ Every metric carries this base attribute set (except
122
+ `frugal.gen_ai.internal_errors`, which is emitted from failure paths where
123
+ 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):
113
125
 
114
126
  - `frugal.project_id` — from your env vars (`frugal.customer_id` too when set); `frugal.component` is added on the per-component series when `FRUGAL_COMPONENTS` is set
115
127
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
116
- - `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `vertex_ai`
128
+ - `gen_ai.system` + `gen_ai.provider.name` — the provider, dual-emitted under both the legacy and current OTel attribute (`openai`, `anthropic`, `aws.bedrock`, `vertex_ai`/`gcp.vertex_ai`, …, or `custom`). Set via the `system` kwarg; an unrecognized value is coerced to `custom` with a one-time warning. See [`docs/metrics-reference.md`](docs/metrics-reference.md#attributes).
117
129
  - `gen_ai.request.model`, `gen_ai.response.model` — the model used
118
- - `gen_ai.operation.name` — `chat`, `text_completion`, or `embeddings`
119
- - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
130
+ - `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
+ - `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
132
+ - `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`
120
133
 
121
134
  The "Additional attributes" column above lists what each metric carries on top of the base set. See [`docs/metrics-reference.md`](docs/metrics-reference.md) for the values each attribute takes, sentinel values for caller fields, and the full internal-error-stage list.
122
135
 
@@ -178,7 +191,7 @@ except BaseException as exc:
178
191
 
179
192
  `success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
180
193
 
181
- **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`.
194
+ **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`.
182
195
 
183
196
  **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
184
197
 
@@ -49,10 +49,17 @@ Set both or the wrapper silently returns the client unmodified:
49
49
  | `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL for Frugal metrics. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
50
50
  | `FRUGAL_OTEL_EXPORTER_OTLP_HEADERS` | Export auth headers (e.g. `Authorization=Bearer ...`). Overrides the header built from `FRUGAL_API_KEY`. Falls back to `OTEL_EXPORTER_OTLP_HEADERS`. |
51
51
  | `FRUGAL_COMPONENTS` | Comma-separated logical components (e.g. `ai-handler,reco`). When set, each metric is ALSO emitted once per component (tagged `frugal.component`) on top of the base project series, so usage can be viewed for the whole project or split by component. |
52
+ | `FRUGAL_CUSTOM_PROVIDERS` | Comma-separated allowlist of provider names beyond the built-in registry (e.g. `together_ai,fireworks`) that should pass through verbatim on `gen_ai.system` / `gen_ai.provider.name` instead of being coerced to `custom`. Capped at 10 (extras are coerced) to keep label cardinality bounded. |
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`. |
52
54
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
53
55
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
54
56
  | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
55
57
  | `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
+ | `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
+ | `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
60
+ | `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. |
61
+ | `FRUGAL_DUPLICATE_FP_RATE` | Target false-positive rate of the duplicate filter. Default `0.01`, clamped to `[1e-6, 0.5]`. |
62
+ | `FRUGAL_DUPLICATE_GENERATIONS` | Rotation granularity of the duplicate filter's time window. Default `2`, max `16`. |
56
63
  | `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
57
64
 
58
65
  ## What gets captured
@@ -62,32 +69,38 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
62
69
  | Metric | Kind | Description | Additional attributes |
63
70
  | --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
64
71
  | `gen_ai.client.operation.duration` | histogram | Call latency in seconds | — |
65
- | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type` |
66
- | `frugal.gen_ai.calls` | counter | Number of calls per site | — |
72
+ | `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type`, `frugal.history_bucket` |
73
+ | `frugal.gen_ai.calls` | counter | Number of calls per site | `frugal.history_bucket`, `frugal.structured_output_mode` |
67
74
  | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
68
75
  | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
69
76
  | `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
70
77
  | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
71
78
  | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
72
79
  | `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
73
- | `frugal.gen_ai.history_depth` | histogram | `messages.length` (analyzer-sampled) | — |
80
+ | `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
81
+ | `frugal.gen_ai.duplicate_requests` | counter | Repeat-request detection — every chat call (100%, not sampled; disable via `FRUGAL_DUPLICATE_REQUEST_DETECTION`) | `frugal.dup_state` |
74
82
  | `frugal.gen_ai.few_shot_count` | histogram | Few-shot exemplar pairs before final user (analyzer-sampled) | — |
83
+ | `frugal.gen_ai.few_shot_tokens` | histogram | Approx token size of the few-shot example block (analyzer-sampled) | — |
75
84
  | `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
76
85
  | `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
77
86
  | `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
78
87
 
79
- The bottom block of analyzer-sampled metrics fires on a fraction of calls
80
- (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics are
81
- 100% — never sampled.
88
+ The analyzer-sampled metrics fire on a fraction of calls
89
+ (`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics,
90
+ `history_depth`, and `duplicate_requests` are 100% — never sampled.
82
91
 
83
- Every metric carries this base attribute set:
92
+ Every metric carries this base attribute set (except
93
+ `frugal.gen_ai.internal_errors`, which is emitted from failure paths where
94
+ 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):
84
96
 
85
97
  - `frugal.project_id` — from your env vars (`frugal.customer_id` too when set); `frugal.component` is added on the per-component series when `FRUGAL_COMPONENTS` is set
86
98
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
87
- - `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `vertex_ai`
99
+ - `gen_ai.system` + `gen_ai.provider.name` — the provider, dual-emitted under both the legacy and current OTel attribute (`openai`, `anthropic`, `aws.bedrock`, `vertex_ai`/`gcp.vertex_ai`, …, or `custom`). Set via the `system` kwarg; an unrecognized value is coerced to `custom` with a one-time warning. See [`docs/metrics-reference.md`](docs/metrics-reference.md#attributes).
88
100
  - `gen_ai.request.model`, `gen_ai.response.model` — the model used
89
- - `gen_ai.operation.name` — `chat`, `text_completion`, or `embeddings`
90
- - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
101
+ - `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
+ - `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
103
+ - `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`
91
104
 
92
105
  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.
93
106
 
@@ -149,7 +162,7 @@ except BaseException as exc:
149
162
 
150
163
  `success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
151
164
 
152
- **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`.
165
+ **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`.
153
166
 
154
167
  **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
155
168
 
@@ -0,0 +1,251 @@
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.errors` | counter | `{error}` | when the wrapped call raises | `frugal.error.type` |
24
+ | `frugal.gen_ai.internal_errors` | counter | `{error}` | frugal-metrics internal failures (cardinality cap, instrumentation bugs) | `frugal.internal_error.stage` |
25
+ | `frugal.gen_ai.output_cap_utilization`| histogram | `1` | when request had `max_tokens` (ratio 0..1) | `frugal.structured_output_mode` |
26
+ | `frugal.gen_ai.history_depth` | histogram | `{message}` | every chat call (`messages.length`) — 100%, not sampled | — |
27
+ | `frugal.gen_ai.few_shot_count` | histogram | `{example}` | analyzer-sampled single-turn chat (count of `(user, assistant)` pairs before final user) | — |
28
+ | `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` | — |
29
+ | `frugal.gen_ai.noise_ratio` | histogram | `1` | analyzer-sampled chat call (HTML / base64 / whitespace fraction of input) | — |
30
+ | `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` |
31
+ | `frugal.gen_ai.structured_output_hint`| counter | `{call}` | analyzer-sampled chat call where the prompt asks for JSON without `response_format` or `tools` | — |
32
+ | `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` |
33
+
34
+ `gen_ai.client.token.usage` is split across input and output via the
35
+ `gen_ai.token.type` attribute (`input` | `output`).
36
+
37
+ The analyzer-sampled metrics are gated by `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`
38
+ (default `0.1`). Sampling is deterministic per `(caller_file, caller_function,
39
+ minute)` — within a minute a given call site is either fully sampled or fully
40
+ skipped, so the LRU comparisons that drive `cache_prefix_stable` see contiguous
41
+ data when active. The token-side metrics (cap utilization, batched label,
42
+ calls, token usage) are 100% — never sampled. `frugal.gen_ai.history_depth` and
43
+ the `frugal.history_bucket` label are likewise 100% — `messages.length` is a
44
+ trivially-cheap integer needing no prompt analysis, so it is emitted on every chat
45
+ call (`few_shot_count`, `few_shot_tokens`, `noise_ratio`, `cache_prefix_stable`, and
46
+ `structured_output_hint` remain analyzer-sampled). `few_shot_tokens` is emitted from
47
+ the same analyzer pass as `few_shot_count` (identifying which turns are examples
48
+ needs prompt inspection, so there is no cheap 100% path); its token count is an
49
+ approximate chars/4 estimate over the example block.
50
+
51
+ Label placement and cardinality: `frugal.history_bucket` rides BOTH the
52
+ `frugal.gen_ai.calls` counter and the `gen_ai.client.token.usage` histogram —
53
+ on token.usage deliberately, so spend can be cut by conversation depth.
54
+ Worst-case that multiplies the token.usage series by 7 buckets × 2 token types
55
+ per call site (in practice a site occupies 1–3 buckets, so expect 2–6× — but
56
+ provision for 14×). `frugal.structured_output_mode` rides ONLY `calls` (and
57
+ `output_cap_utilization`), NOT token.usage — the full 7×4 bucket×mode
58
+ cross-tab lives on `calls` alone, where worst-case it is 28 series per call
59
+ site.
60
+
61
+ `frugal.gen_ai.duplicate_requests` is also 100% (independent of
62
+ `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`). Each chat call's request is canonicalized
63
+ (`model + system + messages + tools + response_format + max_tokens` — plus
64
+ `previous_response_id` for stateful OpenAI Responses-API calls — with the call
65
+ site folded in) and hashed; the hash is checked against a shared, time-and-size-
66
+ limited rotating Bloom filter and classified `first_seen` / `repeat_seen`. Errored
67
+ calls are classified too — a failed-then-retried identical request reads
68
+ `repeat_seen`, keeping retry storms visible. The raw signature never leaves the
69
+ SDK — only the 2-value state is exported. Hashing is incremental and hard-capped
70
+ at 256 KiB of canonicalized request per call: the small discriminators (call
71
+ site, model, system, tools, response_format, max_tokens, previous_response_id)
72
+ are hashed FIRST, then `messages` under the remaining budget — so an over-budget
73
+ history truncates the tail of the conversation (plus a truncation marker), never
74
+ the fields that keep distinct call sites or models from colliding. Binary/image
75
+ content contributes only its type and length — never its bytes — covering raw
76
+ `bytes` blocks AND the base64-string forms providers actually send (OpenAI
77
+ `data:` URLs, Anthropic base64 source blocks). If the filter saturates (actual
78
+ traffic far above `FRUGAL_DUPLICATE_CAPACITY`, generation union over 50% full),
79
+ a one-time `warn` diagnostic is emitted through the debug callback. If the
80
+ filter's lazy bit-array allocation fails (`MemoryError`), duplicate detection is
81
+ **disabled for the rest of the process**: a one-time `error` diagnostic is
82
+ emitted, the first failing call records a
83
+ `frugal.gen_ai.internal_errors{stage=duplicate_detection}` point, and every
84
+ subsequent call skips detection silently (no `duplicate_requests` metric).
85
+ Controls:
86
+
87
+ - `FRUGAL_DUPLICATE_REQUEST_DETECTION` (default `true`) — kill switch. When
88
+ `false`/`0`/`no`/`off`, no filter is allocated, no signature is hashed, and the
89
+ metric is not emitted. Any other value keeps detection enabled and emits a
90
+ one-time `warn` diagnostic naming the unrecognized value.
91
+ - `FRUGAL_DUPLICATE_WINDOW_SECONDS` (default `3600`, clamped to `604800` — 7
92
+ days) — retention window. Because the filter rotates in `generations` slots,
93
+ a signature is remembered for **at least**
94
+ `window × (generations−1)/generations` and **at most** `window` seconds
95
+ (30–60 min at defaults) — treat the counterfactual cache TTL as that range,
96
+ not a flat window.
97
+ - `FRUGAL_DUPLICATE_CAPACITY` (default `10000`, clamped to `1000000`) — expected
98
+ distinct signatures per window (Bloom sizing target).
99
+ - `FRUGAL_DUPLICATE_FP_RATE` (default `0.01`, clamped to `[1e-6, 0.5]`) —
100
+ target false-positive rate.
101
+ - `FRUGAL_DUPLICATE_GENERATIONS` (default `2`, clamped to `16`) — rotating-Bloom
102
+ generation count.
103
+
104
+ Out-of-range or non-finite values for any of these fall back to the default (or
105
+ clamp to the bound) with a one-time named `warn` through the debug callback.
106
+
107
+ Reading `duplicate_requests` correctly:
108
+
109
+ - **Denominator.** Coverage is a per-endpoint subset of `calls` (embeddings,
110
+ legacy text completions, bare `track_ai_call` without messages, and other
111
+ non-chat surfaces emit no duplicate signal), so compute repeat rates as
112
+ `repeat_seen / (first_seen + repeat_seen)` — never against `calls`.
113
+ - **Retries count as repeats.** Errored calls insert into the filter, so a
114
+ retried-after-failure request reads `repeat_seen`. That keeps retry storms
115
+ visible, but it also means `repeat_seen` includes required retry traffic that
116
+ a response cache would not eliminate — net it against
117
+ `frugal.gen_ai.errors` when estimating cacheable waste.
118
+ - **Sampling params are ignored.** The signature deliberately excludes
119
+ `temperature`, `top_p`, `seed`, `n`, and `tool_choice`, so best-of-N /
120
+ self-consistency workloads read as 100% duplicates. Fine for spotting
121
+ caching opportunities; wrong for assuming every repeat is waste.
122
+ - **Why a separate counter, not a label on `calls`:** the metric has its own
123
+ kill switch, covers only a subset of calls, and its 2-value state would
124
+ otherwise double every `calls` series; keeping it separate keeps `calls`
125
+ stable when detection is toggled.
126
+
127
+ ## Attributes
128
+
129
+ ### From the GenAI semantic convention
130
+
131
+ | Attribute | Example |
132
+ | -------------------------- | ------------------------------------------ |
133
+ | `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) |
134
+ | `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) |
135
+ | `gen_ai.operation.name` | `chat` \| `text_completion` \| `embeddings` \| `generate_content` \| `execute_tool` \| `create_agent` \| `invoke_agent` \| `other` (sentinel for an unrecognized operation) |
136
+ | `gen_ai.request.model` | `gpt-4o` / `claude-3-5-sonnet-latest` / … |
137
+ | `gen_ai.response.model` | Provider-returned model id (often identical)|
138
+ | `gen_ai.token.type` | `input` \| `output` (token usage metric only) |
139
+
140
+ > **Provider attribute migration.** OTel renamed `gen_ai.system` to
141
+ > `gen_ai.provider.name` (some spellings changed, e.g. `vertex_ai` →
142
+ > `gcp.vertex_ai`). The SDK **dual-emits both** during the transition: every
143
+ > metric carries the legacy `gen_ai.system` value (so existing queries/history
144
+ > keep matching) and the new `gen_ai.provider.name` value. The `system` kwarg
145
+ > accepts either spelling. A value outside the well-known set (e.g. a typo like
146
+ > `amazon-bedrock`) is **coerced to `custom`** on both labels — so a wrong label
147
+ > never reaches metrics — and surfaced once through the debug callback
148
+ > (`set_debug_callback`), per distinct value (bounded so a high-cardinality
149
+ > caller can't grow the warning set without limit). To keep a legitimate provider
150
+ > that isn't in the registry, register it via `FRUGAL_CUSTOM_PROVIDERS` (up to 10)
151
+ > and it passes through verbatim on both labels. An `operation` outside the accepted set is coerced to
152
+ > the `other` sentinel (NOT `chat`, so a typo can't inflate the real chat series) with the same
153
+ > one-time warning — the seven listed values plus an explicit `other` pass through unchanged. The
154
+ > exported `GenAiSystem` / `GenAiOperation` `Literal` aliases give strict typing for callers who want
155
+ > it — `GenAiSystem` lists both provider spellings the runtime accepts (the kwargs stay `str` so a
156
+ > free-form custom value remains legal).
157
+
158
+ ### Frugal-added
159
+
160
+ | Attribute | Value |
161
+ | ------------------------- | ---------------------------------------------------------- |
162
+ | `frugal.customer_id` | From `FRUGAL_CUSTOMER_ID` |
163
+ | `frugal.project_id` | From `FRUGAL_PROJECT_ID` |
164
+ | `frugal.component` | From each entry in `FRUGAL_COMPONENTS` — present only on the per-component series, absent on the base project series |
165
+ | `frugal.caller_file` | Source file repo-relative to `FRUGAL_REPO_ROOT` |
166
+ | `frugal.caller_function` | Function/method name at the call site |
167
+ | `frugal.error.type` | Exception class name (error-path metrics only) |
168
+ | `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. |
169
+ | `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). |
170
+ | `frugal.block` | `system` \| `user` \| `tools` \| `schema`. Only on `frugal.gen_ai.cache_prefix_stable`. |
171
+ | `frugal.stable` | `true` when the block hash matches the previous call at this site. Only on `frugal.gen_ai.cache_prefix_stable`. |
172
+ | `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). |
173
+ | `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. |
174
+ | `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`. |
175
+
176
+ ### Sentinel values for `frugal.caller_file` / `frugal.caller_function`
177
+
178
+ | Value | Meaning |
179
+ | -------------- | ---------------------------------------------------------- |
180
+ | `<external>` | Caller frame resolved outside `FRUGAL_REPO_ROOT` |
181
+ | `<unknown>` | Could not resolve a caller frame (should not happen in practice) |
182
+ | `<lambda>` | Call originated from a `lambda` expression. This is Python's native `co_name` for lambdas. Only appears in `frugal.caller_function`. |
183
+ | `<overflow>` | Cardinality cap (`FRUGAL_MAX_CALL_SITES`) exceeded. See below. |
184
+
185
+ ### Cardinality cap behavior
186
+
187
+ Once `FRUGAL_MAX_CALL_SITES` distinct `(file, function)` pairs have been
188
+ observed, new call sites overflow. Two shapes are possible:
189
+
190
+ | Attribution | Meaning |
191
+ | --- | --- |
192
+ | `(real/file.py, <overflow>)` | The file was already seen in the first N pairs; the new function inside that file is bucketed into `<overflow>` |
193
+ | `(<overflow>, <overflow>)` | Neither the file nor the function was seen before the cap was reached |
194
+
195
+ The first time the cap is hit per process, the library emits a single
196
+ `frugal.gen_ai.internal_errors{stage=cardinality_cap_hit}` point so you can
197
+ see the transition in your dashboard.
198
+
199
+ ### Internal-error stages
200
+
201
+ `frugal.gen_ai.internal_errors` carries a `frugal.internal_error.stage`
202
+ attribute describing where our own code failed. Non-exhaustive list:
203
+
204
+ | Stage | When it fires |
205
+ | ----------------------- | ---------------------------------------------------------------------- |
206
+ | `setup_failed` | Our wrapper setup (OTel init, caller resolution) threw; original call continued normally |
207
+ | `stream_proxy_failed` | Constructing the stream wrapper threw; original response returned unwrapped |
208
+ | `emission` | Emitting a success/error metric threw |
209
+ | `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
+ | `cardinality_cap_hit` | `FRUGAL_MAX_CALL_SITES` was just reached (once per process) |
211
+
212
+ Unlike every other metric, `frugal.gen_ai.internal_errors` does **not** carry
213
+ the full base attribute set — it is emitted from failure paths where the
214
+ caller/provider/model attributes may not exist yet. Its points carry only
215
+ `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`.
218
+
219
+ ## Provider coverage
220
+
221
+ | Provider | Wrapper | `gen_ai.system` |
222
+ | ------------------------------------------------------- | ------------------------------------ | ---------------- |
223
+ | OpenAI | `wrap_openai` | `openai` |
224
+ | Anthropic (direct API) | `wrap_anthropic` | `anthropic` |
225
+ | Claude on AWS Bedrock (via Anthropic SDK) | `wrap_anthropic(AnthropicBedrock())` | `aws.bedrock` |
226
+ | AWS Bedrock — any model, AWS SDKs directly | `wrap_bedrock` | `aws.bedrock` |
227
+ | Claude on Google Vertex | `wrap_anthropic(AnthropicVertex())` | `vertex_ai` |
228
+ | Custom / manual call sites | `track_ai_call` / `start_ai_call` | `custom` (override via `system=`) |
229
+
230
+ `wrap_bedrock` covers all four `bedrock-runtime` operations (`InvokeModel`,
231
+ `InvokeModelWithResponseStream`, `Converse`, `ConverseStream`) on every model
232
+ hosted on Bedrock — Anthropic Claude, OpenAI gpt-oss, Amazon Nova, Meta Llama,
233
+ Mistral, Cohere, AI21 Jamba, DeepSeek, Qwen, Kimi, Twelvelabs, Nvidia, etc.
234
+ Per-provider response parsing handles the major families (Anthropic /
235
+ OpenAI / Nova / Llama / Mistral) for accurate cache + reasoning token
236
+ extraction; everything else falls back to the universal
237
+ `amazon-bedrock-invocationMetrics` (streaming) /
238
+ `x-amzn-bedrock-*-token-count` (non-streaming) headers that Bedrock attaches
239
+ to every response.
240
+
241
+ > **Reasoning tokens on Bedrock.** Anthropic's `InvokeModel` path exposes
242
+ > `usage.thinking_tokens` for extended-thinking calls; we surface it as
243
+ > `frugal.gen_ai.reasoning_tokens`. Bedrock's `Converse` API normalizes
244
+ > reasoning tokens into `outputTokens` rather than breaking them out, so
245
+ > calls made via `Converse` lose reasoning-token visibility.
246
+
247
+ Azure OpenAI clients currently fall through the OpenAI wrapper and are tagged
248
+ as `openai`; dedicated Azure detection is deferred.
249
+
250
+ Native Vertex SDKs (`google-cloud-aiplatform`) for non-Claude models are not
251
+ yet instrumented.
@@ -26,10 +26,16 @@ Optional:
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
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. |
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`. |
29
30
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip when attributing the caller (see below). |
30
31
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence. Default `60000`. |
31
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_PROMPT_ANALYSIS_SAMPLE_RATE` | Fraction of calls (0..1) where the lightweight prompt analyzer runs and emits derived metrics (`history_depth`, `noise_ratio`, `cache_prefix_stable`, …). Default `0.1`. Set to `0` to disable, `1` for every call. See [Prompt analysis](#prompt-analysis). |
33
+ | `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
+ | `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
+ | `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
36
+ | `FRUGAL_DUPLICATE_CAPACITY` | Expected distinct request signatures per window — sizes the in-memory duplicate filter. Default `10000`, max `1000000`. Raise it if the debug callback warns the filter is saturated. If the filter's memory can't be allocated at all, detection disables itself for the rest of the process (one-time `error` diagnostic + one `internal_errors` point). |
37
+ | `FRUGAL_DUPLICATE_FP_RATE` | Target false-positive rate of the duplicate filter. Default `0.01`, clamped to `[1e-6, 0.5]`. |
38
+ | `FRUGAL_DUPLICATE_GENERATIONS` | Rotation granularity of the duplicate filter's time window. Default `2`, max `16`. |
33
39
  | `FRUGAL_DISABLED` | Kill switch. Set to `1` to disable without uninstalling. |
34
40
 
35
41
  Standard `OTEL_EXPORTER_OTLP_*` env vars are honored as fallbacks if the
@@ -92,6 +98,13 @@ token counts are not (the OpenAI SDK doesn't send them).
92
98
  Use `wrap_anthropic` with the Anthropic SDK's `AnthropicBedrock` client. The
93
99
  wrapper detects it and tags metrics with `gen_ai.system = aws.bedrock`.
94
100
 
101
+ > Every metric is **dual-emitted** under both `gen_ai.system` (legacy) and
102
+ > `gen_ai.provider.name` (current OTel spelling — e.g. `aws.bedrock`,
103
+ > `gcp.vertex_ai`). A provider or operation outside the known set is coerced to
104
+ > the `custom` / `other` sentinel with a one-time warning (via
105
+ > `set_debug_callback`); register extra providers with `FRUGAL_CUSTOM_PROVIDERS`.
106
+ > See [metrics-reference.md](metrics-reference.md#attributes).
107
+
95
108
  ```python
96
109
  from anthropic import AnthropicBedrock
97
110
  from frugal_metrics import wrap_anthropic
@@ -247,7 +260,9 @@ extra calls are ignored.
247
260
 
248
261
  **Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional):
249
262
  `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default
250
- `"chat"`), `request_model` (default `"<unknown>"`). To also run the sampled
263
+ `"chat"`), `request_model` (default `"<unknown>"`), `custom_label` (→ the
264
+ `frugal.custom_label` label on every metric of the call; values outside the
265
+ `FRUGAL_CUSTOM_LABEL` allowlist become `other`). To also run the sampled
251
266
  prompt analyzer, pass any of `messages` / `system_prompt` / `tools` /
252
267
  `response_format` / `max_tokens`.
253
268
 
@@ -309,17 +324,28 @@ detect common AI cost traps:
309
324
 
310
325
  | Signal | Metric | Cost trap |
311
326
  | --- | --- | --- |
312
- | Conversation depth | `frugal.gen_ai.history_depth` | unbounded history re-sent on every turn |
313
327
  | Few-shot example count | `frugal.gen_ai.few_shot_count` | over-provisioned exemplars where accuracy plateaus |
328
+ | Few-shot example size | `frugal.gen_ai.few_shot_tokens` | oversized exemplar blocks re-sent on every call |
314
329
  | Input noise (HTML tags, base64 runs, large whitespace) | `frugal.gen_ai.noise_ratio` | filterable boilerplate paying token cost |
315
330
  | Per-block cache stability | `frugal.gen_ai.cache_prefix_stable` | system / tools / schema churn breaking provider prompt cache |
316
331
  | Structured-output keyword in prose | `frugal.gen_ai.structured_output_hint` | "return JSON" prompts that should use `response_format` / `tools` |
317
332
 
333
+ Two related signals run on **every** chat call (100%, independent of the
334
+ analyzer sample rate): `frugal.gen_ai.history_depth` (the `messages.length`
335
+ count — unbounded history re-sent on every turn) and
336
+ `frugal.gen_ai.duplicate_requests` (repeat-request detection; disable via
337
+ `FRUGAL_DUPLICATE_REQUEST_DETECTION`).
338
+
318
339
  > **What we read vs what we send.** The analyzer inspects the request's
319
340
  > `messages`, `tools`, `response_format`, `system`, and `max_tokens` kwargs to
320
- > compute the signals above. We never send raw request content over the wire —
321
- > only stats (counts, ratios) and SHA-256 hashes used in-process for the
322
- > stability comparisons. The hashes never leave the process.
341
+ > compute the signals above. Duplicate detection additionally canonicalizes and
342
+ > SHA-256 hashes those same fields on every chat call (capped at 256 KiB per
343
+ > request; binary/image content — raw bytes and the base64 strings providers
344
+ > actually send — contributes only its type and length). We never send raw
345
+ > request content over the wire — only stats (counts, ratios); the hashes are
346
+ > used in-process for the stability comparisons and the duplicate filter, and
347
+ > never leave the process. For `duplicate_requests`, only the two-value
348
+ > `first_seen`/`repeat_seen` state is exported.
323
349
 
324
350
  Sampling defaults to **10% per call site** and is deterministic by call site +
325
351
  minute, so a given call site is either fully sampled or fully skipped within a
@@ -328,7 +354,8 @@ paying analyzer CPU cost on every call. Tune via
328
354
  `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`.
329
355
 
330
356
  The token-side metrics (`gen_ai.client.token.usage`, `frugal.gen_ai.calls`,
331
- `frugal.gen_ai.cache_*`, `frugal.gen_ai.output_cap_utilization`) are 100% —
357
+ `frugal.gen_ai.cache_*`, `frugal.gen_ai.output_cap_utilization`) plus
358
+ `frugal.gen_ai.history_depth` and `frugal.gen_ai.duplicate_requests` are 100% —
332
359
  never sampled.
333
360
 
334
361
  ## See why nothing is exporting (debug callback)
@@ -3,6 +3,15 @@
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
 
5
5
  from frugal_metrics.debug import set_debug_callback
6
+ from frugal_metrics.semconv import DupState, HistoryBucket, StructuredOutputMode
7
+ from frugal_metrics.providers import (
8
+ PROVIDERS,
9
+ GenAiOperation,
10
+ GenAiSystem,
11
+ ProviderEntry,
12
+ normalize_operation,
13
+ normalize_provider,
14
+ )
6
15
  from frugal_metrics.shutdown import enable_auto_shutdown, flush, shutdown
7
16
  from frugal_metrics.track import AICallSpan, start_ai_call, track_ai_call
8
17
  from frugal_metrics.wrappers.openai import wrap_openai
@@ -20,6 +29,15 @@ __all__ = [
20
29
  "flush",
21
30
  "shutdown",
22
31
  "enable_auto_shutdown",
32
+ "PROVIDERS",
33
+ "ProviderEntry",
34
+ "GenAiSystem",
35
+ "GenAiOperation",
36
+ "HistoryBucket",
37
+ "StructuredOutputMode",
38
+ "DupState",
39
+ "normalize_provider",
40
+ "normalize_operation",
23
41
  ]
24
42
  try:
25
43
  __version__ = version("frugal-sdk-python")