frugal-sdk-python 0.3.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 (90) hide show
  1. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/PKG-INFO +23 -11
  2. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/README.md +21 -9
  3. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/docs/metrics-reference.md +101 -5
  4. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/docs/quickstart.md +27 -7
  5. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/__init__.py +4 -0
  6. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/config.py +176 -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.3.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.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/semconv.py +38 -0
  12. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/track.py +21 -1
  13. frugal_sdk_python-0.4.0/src/frugal_metrics/wrappers/_batch.py +445 -0
  14. frugal_sdk_python-0.4.0/src/frugal_metrics/wrappers/_patching.py +51 -0
  15. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/anthropic.py +10 -0
  16. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/openai.py +44 -24
  17. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/conftest.py +20 -0
  18. frugal_sdk_python-0.4.0/tests/test_config.py +357 -0
  19. frugal_sdk_python-0.4.0/tests/test_duplicate_filter.py +234 -0
  20. frugal_sdk_python-0.4.0/tests/test_prompt_analyzer.py +655 -0
  21. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_track_ai_call.py +167 -0
  22. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_wrap_anthropic.py +24 -0
  23. frugal_sdk_python-0.4.0/tests/test_wrap_anthropic_batches.py +244 -0
  24. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_wrap_openai.py +16 -0
  25. frugal_sdk_python-0.4.0/tests/test_wrap_openai_analyzer.py +499 -0
  26. frugal_sdk_python-0.4.0/tests/test_wrap_openai_batches.py +199 -0
  27. frugal_sdk_python-0.3.0/src/frugal_metrics/instrument.py +0 -282
  28. frugal_sdk_python-0.3.0/src/frugal_metrics/prompt_analyzer.py +0 -273
  29. frugal_sdk_python-0.3.0/tests/test_config.py +0 -181
  30. frugal_sdk_python-0.3.0/tests/test_prompt_analyzer.py +0 -203
  31. frugal_sdk_python-0.3.0/tests/test_wrap_openai_analyzer.py +0 -188
  32. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/.github/workflows/cd.yml +0 -0
  33. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/.gitignore +0 -0
  34. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/LICENSES/Apache-2.0.txt +0 -0
  35. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/REUSE.toml +0 -0
  36. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/README.md +0 -0
  37. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
  38. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/docker-compose.yml +0 -0
  39. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
  40. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
  41. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
  42. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/Dockerfile +0 -0
  43. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/package.json +0 -0
  44. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/src/server.js +0 -0
  45. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
  46. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/models.html +0 -0
  47. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
  48. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
  49. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/prometheus.yml +0 -0
  50. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/Dockerfile +0 -0
  51. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/__init__.py +0 -0
  52. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/clients.py +0 -0
  53. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
  54. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
  55. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
  56. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
  57. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
  58. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
  59. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
  60. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
  61. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
  62. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
  63. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
  64. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
  65. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/main.py +0 -0
  66. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
  67. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
  68. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/pyproject.toml +0 -0
  69. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/requirements.txt +0 -0
  70. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/examples/test-env/scripts/verify.sh +0 -0
  71. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/pyproject.toml +0 -0
  72. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/block_hash_lru.py +0 -0
  73. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/caller.py +0 -0
  74. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/debug.py +0 -0
  75. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/providers.py +0 -0
  76. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/safe.py +0 -0
  77. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/shutdown.py +0 -0
  78. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/streams.py +0 -0
  79. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
  80. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
  81. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
  82. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/__init__.py +0 -0
  83. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_bedrock_models.py +0 -0
  84. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_block_hash_lru.py +0 -0
  85. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_caller.py +0 -0
  86. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_caller_edge_cases.py +0 -0
  87. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_debug.py +0 -0
  88. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_providers.py +0 -0
  89. {frugal_sdk_python-0.3.0 → frugal_sdk_python-0.4.0}/tests/test_shutdown.py +0 -0
  90. {frugal_sdk_python-0.3.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.3.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.
@@ -79,10 +79,16 @@ Set both or the wrapper silently returns the client unmodified:
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
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
85
  | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
85
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`. |
86
92
  | `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
87
93
 
88
94
  ## What gets captured
@@ -92,32 +98,38 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
92
98
  | Metric | Kind | Description | Additional attributes |
93
99
  | --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
94
100
  | `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 | — |
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` |
97
103
  | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
98
104
  | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
99
105
  | `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
100
106
  | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
101
107
  | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
102
108
  | `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) | — |
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` |
104
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) | — |
105
113
  | `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
106
114
  | `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
107
115
  | `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
108
116
 
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.
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.
112
120
 
113
- 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):
114
125
 
115
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
116
127
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
117
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).
118
129
  - `gen_ai.request.model`, `gen_ai.response.model` — the model used
119
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)
120
- - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
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`
121
133
 
122
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.
123
135
 
@@ -179,7 +191,7 @@ except BaseException as exc:
179
191
 
180
192
  `success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
181
193
 
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`.
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`.
183
195
 
184
196
  **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
185
197
 
@@ -50,10 +50,16 @@ Set both or the wrapper silently returns the client unmodified:
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
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
56
  | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
56
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`. |
57
63
  | `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
58
64
 
59
65
  ## What gets captured
@@ -63,32 +69,38 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
63
69
  | Metric | Kind | Description | Additional attributes |
64
70
  | --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
65
71
  | `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 | — |
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` |
68
74
  | `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
69
75
  | `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
70
76
  | `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
71
77
  | `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
72
78
  | `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
73
79
  | `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) | — |
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` |
75
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) | — |
76
84
  | `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
77
85
  | `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
78
86
  | `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
79
87
 
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.
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.
83
91
 
84
- 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):
85
96
 
86
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
87
98
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
88
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).
89
100
  - `gen_ai.request.model`, `gen_ai.response.model` — the model used
90
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)
91
- - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
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`
92
104
 
93
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.
94
106
 
@@ -150,7 +162,7 @@ except BaseException as exc:
150
162
 
151
163
  `success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
152
164
 
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`.
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`.
154
166
 
155
167
  **Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
156
168
 
@@ -16,18 +16,20 @@ metric may carry the extra attributes shown in the rightmost column.
16
16
  | ------------------------------------- | --------- | ----------- | ----------------------------------------------------- | ---------------------------------------------- |
17
17
  | `gen_ai.client.operation.duration` | histogram | `s` | every call, success or error | — |
18
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 | — |
19
+ | `frugal.gen_ai.calls` | counter | `{call}` | every call, success or error | `frugal.history_bucket` (chat calls), `frugal.structured_output_mode` (with payload) |
20
20
  | `frugal.gen_ai.cache_read_tokens` | histogram | `{token}` | when provider reports cache reads | — |
21
21
  | `frugal.gen_ai.cache_write_tokens` | histogram | `{token}` | when provider reports cache writes | — |
22
22
  | `frugal.gen_ai.reasoning_tokens` | histogram | `{token}` | OpenAI o-series reasoning models | — |
23
23
  | `frugal.gen_ai.errors` | counter | `{error}` | when the wrapped call raises | `frugal.error.type` |
24
24
  | `frugal.gen_ai.internal_errors` | counter | `{error}` | frugal-metrics internal failures (cardinality cap, instrumentation bugs) | `frugal.internal_error.stage` |
25
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}` | analyzer-sampled chat call (`messages.length`) | — |
26
+ | `frugal.gen_ai.history_depth` | histogram | `{message}` | every chat call (`messages.length`) — 100%, not sampled | — |
27
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` | — |
28
29
  | `frugal.gen_ai.noise_ratio` | histogram | `1` | analyzer-sampled chat call (HTML / base64 / whitespace fraction of input) | — |
29
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` |
30
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` |
31
33
 
32
34
  `gen_ai.client.token.usage` is split across input and output via the
33
35
  `gen_ai.token.type` attribute (`input` | `output`).
@@ -37,7 +39,90 @@ The analyzer-sampled metrics are gated by `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`
37
39
  minute)` — within a minute a given call site is either fully sampled or fully
38
40
  skipped, so the LRU comparisons that drive `cache_prefix_stable` see contiguous
39
41
  data when active. The token-side metrics (cap utilization, batched label,
40
- calls, token usage) are 100% — never sampled.
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.
41
126
 
42
127
  ## Attributes
43
128
 
@@ -80,10 +165,13 @@ calls, token usage) are 100% — never sampled.
80
165
  | `frugal.caller_file` | Source file repo-relative to `FRUGAL_REPO_ROOT` |
81
166
  | `frugal.caller_function` | Function/method name at the call site |
82
167
  | `frugal.error.type` | Exception class name (error-path metrics only) |
83
- | `frugal.batched` | `true` when emitted from a batch-API method, `false` otherwise. Present on every metric. The batch-API wrap paths themselves are a follow-up milestone — until then this attribute is always `false`. |
84
- | `frugal.structured_output_mode` | `none` \| `json_object` \| `json_schema` \| `tools`. Only on `frugal.gen_ai.output_cap_utilization`. |
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). |
85
170
  | `frugal.block` | `system` \| `user` \| `tools` \| `schema`. Only on `frugal.gen_ai.cache_prefix_stable`. |
86
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`. |
87
175
 
88
176
  ### Sentinel values for `frugal.caller_file` / `frugal.caller_function`
89
177
 
@@ -118,8 +206,16 @@ attribute describing where our own code failed. Non-exhaustive list:
118
206
  | `setup_failed` | Our wrapper setup (OTel init, caller resolution) threw; original call continued normally |
119
207
  | `stream_proxy_failed` | Constructing the stream wrapper threw; original response returned unwrapped |
120
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 |
121
210
  | `cardinality_cap_hit` | `FRUGAL_MAX_CALL_SITES` was just reached (once per process) |
122
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
+
123
219
  ## Provider coverage
124
220
 
125
221
  | Provider | Wrapper | `gen_ai.system` |
@@ -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
@@ -254,7 +260,9 @@ extra calls are ignored.
254
260
 
255
261
  **Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional):
256
262
  `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default
257
- `"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
258
266
  prompt analyzer, pass any of `messages` / `system_prompt` / `tools` /
259
267
  `response_format` / `max_tokens`.
260
268
 
@@ -316,17 +324,28 @@ detect common AI cost traps:
316
324
 
317
325
  | Signal | Metric | Cost trap |
318
326
  | --- | --- | --- |
319
- | Conversation depth | `frugal.gen_ai.history_depth` | unbounded history re-sent on every turn |
320
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 |
321
329
  | Input noise (HTML tags, base64 runs, large whitespace) | `frugal.gen_ai.noise_ratio` | filterable boilerplate paying token cost |
322
330
  | Per-block cache stability | `frugal.gen_ai.cache_prefix_stable` | system / tools / schema churn breaking provider prompt cache |
323
331
  | Structured-output keyword in prose | `frugal.gen_ai.structured_output_hint` | "return JSON" prompts that should use `response_format` / `tools` |
324
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
+
325
339
  > **What we read vs what we send.** The analyzer inspects the request's
326
340
  > `messages`, `tools`, `response_format`, `system`, and `max_tokens` kwargs to
327
- > compute the signals above. We never send raw request content over the wire —
328
- > only stats (counts, ratios) and SHA-256 hashes used in-process for the
329
- > 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.
330
349
 
331
350
  Sampling defaults to **10% per call site** and is deterministic by call site +
332
351
  minute, so a given call site is either fully sampled or fully skipped within a
@@ -335,7 +354,8 @@ paying analyzer CPU cost on every call. Tune via
335
354
  `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`.
336
355
 
337
356
  The token-side metrics (`gen_ai.client.token.usage`, `frugal.gen_ai.calls`,
338
- `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% —
339
359
  never sampled.
340
360
 
341
361
  ## See why nothing is exporting (debug callback)
@@ -3,6 +3,7 @@
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
6
7
  from frugal_metrics.providers import (
7
8
  PROVIDERS,
8
9
  GenAiOperation,
@@ -32,6 +33,9 @@ __all__ = [
32
33
  "ProviderEntry",
33
34
  "GenAiSystem",
34
35
  "GenAiOperation",
36
+ "HistoryBucket",
37
+ "StructuredOutputMode",
38
+ "DupState",
35
39
  "normalize_provider",
36
40
  "normalize_operation",
37
41
  ]