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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/PKG-INFO +4 -3
  2. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/README.md +3 -2
  3. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/docs/metrics-reference.md +21 -2
  4. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/docs/quickstart.md +7 -0
  5. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/__init__.py +14 -0
  6. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/config.py +31 -1
  7. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/instrument.py +8 -2
  8. frugal_sdk_python-0.3.0/src/frugal_metrics/providers.py +219 -0
  9. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/semconv.py +10 -1
  10. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/conftest.py +2 -0
  11. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_config.py +18 -0
  12. frugal_sdk_python-0.3.0/tests/test_providers.py +180 -0
  13. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/.github/workflows/cd.yml +0 -0
  14. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/.gitignore +0 -0
  15. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/LICENSES/Apache-2.0.txt +0 -0
  16. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/REUSE.toml +0 -0
  17. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/README.md +0 -0
  18. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
  19. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/docker-compose.yml +0 -0
  20. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
  21. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
  22. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
  23. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/Dockerfile +0 -0
  24. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/package.json +0 -0
  25. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/src/server.js +0 -0
  26. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
  27. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/models.html +0 -0
  28. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
  29. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
  30. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/prometheus.yml +0 -0
  31. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/Dockerfile +0 -0
  32. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/__init__.py +0 -0
  33. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/clients.py +0 -0
  34. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
  35. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
  36. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
  37. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
  38. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
  39. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
  40. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
  41. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
  42. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
  43. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
  44. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
  45. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
  46. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/main.py +0 -0
  47. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
  48. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
  49. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/pyproject.toml +0 -0
  50. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/requirements.txt +0 -0
  51. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/scripts/verify.sh +0 -0
  52. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/pyproject.toml +0 -0
  53. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/block_hash_lru.py +0 -0
  54. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/caller.py +0 -0
  55. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/debug.py +0 -0
  56. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/otel.py +0 -0
  57. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/prompt_analyzer.py +0 -0
  58. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/safe.py +0 -0
  59. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/shutdown.py +0 -0
  60. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/streams.py +0 -0
  61. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/track.py +0 -0
  62. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
  63. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/anthropic.py +0 -0
  64. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
  65. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
  66. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/openai.py +0 -0
  67. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/__init__.py +0 -0
  68. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_bedrock_models.py +0 -0
  69. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_block_hash_lru.py +0 -0
  70. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_caller.py +0 -0
  71. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_caller_edge_cases.py +0 -0
  72. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_debug.py +0 -0
  73. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_prompt_analyzer.py +0 -0
  74. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_shutdown.py +0 -0
  75. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_track_ai_call.py +0 -0
  76. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_anthropic.py +0 -0
  77. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_bedrock.py +0 -0
  78. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_openai.py +0 -0
  79. {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_openai_analyzer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frugal-sdk-python
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Instrumentation for Cost-to-Code attribution.
5
5
  Project-URL: Homepage, https://frugal.co
6
6
  Author: Frugal AI Inc.
@@ -78,6 +78,7 @@ Set both or the wrapper silently returns the client unmodified:
78
78
  | `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL for Frugal metrics. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
79
79
  | `FRUGAL_OTEL_EXPORTER_OTLP_HEADERS` | Export auth headers (e.g. `Authorization=Bearer ...`). Overrides the header built from `FRUGAL_API_KEY`. Falls back to `OTEL_EXPORTER_OTLP_HEADERS`. |
80
80
  | `FRUGAL_COMPONENTS` | Comma-separated logical components (e.g. `ai-handler,reco`). When set, each metric is ALSO emitted once per component (tagged `frugal.component`) on top of the base project series, so usage can be viewed for the whole project or split by component. |
81
+ | `FRUGAL_CUSTOM_PROVIDERS` | Comma-separated allowlist of provider names beyond the built-in registry (e.g. `together_ai,fireworks`) that should pass through verbatim on `gen_ai.system` / `gen_ai.provider.name` instead of being coerced to `custom`. Capped at 10 (extras are coerced) to keep label cardinality bounded. |
81
82
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
82
83
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
83
84
  | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
@@ -113,9 +114,9 @@ Every metric carries this base attribute set:
113
114
 
114
115
  - `frugal.project_id` — from your env vars (`frugal.customer_id` too when set); `frugal.component` is added on the per-component series when `FRUGAL_COMPONENTS` is set
115
116
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
116
- - `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `vertex_ai`
117
+ - `gen_ai.system` + `gen_ai.provider.name` the provider, dual-emitted under both the legacy and current OTel attribute (`openai`, `anthropic`, `aws.bedrock`, `vertex_ai`/`gcp.vertex_ai`, …, or `custom`). Set via the `system` kwarg; an unrecognized value is coerced to `custom` with a one-time warning. See [`docs/metrics-reference.md`](docs/metrics-reference.md#attributes).
117
118
  - `gen_ai.request.model`, `gen_ai.response.model` — the model used
118
- - `gen_ai.operation.name` — `chat`, `text_completion`, or `embeddings`
119
+ - `gen_ai.operation.name` — `chat`, `text_completion`, `embeddings`, `generate_content`, `execute_tool`, `create_agent`, `invoke_agent`, or `other` (the sentinel an unrecognized operation coerces to)
119
120
  - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
120
121
 
121
122
  The "Additional attributes" column above lists what each metric carries on top of the base set. See [`docs/metrics-reference.md`](docs/metrics-reference.md) for the values each attribute takes, sentinel values for caller fields, and the full internal-error-stage list.
@@ -49,6 +49,7 @@ Set both or the wrapper silently returns the client unmodified:
49
49
  | `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL for Frugal metrics. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
50
50
  | `FRUGAL_OTEL_EXPORTER_OTLP_HEADERS` | Export auth headers (e.g. `Authorization=Bearer ...`). Overrides the header built from `FRUGAL_API_KEY`. Falls back to `OTEL_EXPORTER_OTLP_HEADERS`. |
51
51
  | `FRUGAL_COMPONENTS` | Comma-separated logical components (e.g. `ai-handler,reco`). When set, each metric is ALSO emitted once per component (tagged `frugal.component`) on top of the base project series, so usage can be viewed for the whole project or split by component. |
52
+ | `FRUGAL_CUSTOM_PROVIDERS` | Comma-separated allowlist of provider names beyond the built-in registry (e.g. `together_ai,fireworks`) that should pass through verbatim on `gen_ai.system` / `gen_ai.provider.name` instead of being coerced to `custom`. Capped at 10 (extras are coerced) to keep label cardinality bounded. |
52
53
  | `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
53
54
  | `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
54
55
  | `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
@@ -84,9 +85,9 @@ Every metric carries this base attribute set:
84
85
 
85
86
  - `frugal.project_id` — from your env vars (`frugal.customer_id` too when set); `frugal.component` is added on the per-component series when `FRUGAL_COMPONENTS` is set
86
87
  - `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
87
- - `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `vertex_ai`
88
+ - `gen_ai.system` + `gen_ai.provider.name` the provider, dual-emitted under both the legacy and current OTel attribute (`openai`, `anthropic`, `aws.bedrock`, `vertex_ai`/`gcp.vertex_ai`, …, or `custom`). Set via the `system` kwarg; an unrecognized value is coerced to `custom` with a one-time warning. See [`docs/metrics-reference.md`](docs/metrics-reference.md#attributes).
88
89
  - `gen_ai.request.model`, `gen_ai.response.model` — the model used
89
- - `gen_ai.operation.name` — `chat`, `text_completion`, or `embeddings`
90
+ - `gen_ai.operation.name` — `chat`, `text_completion`, `embeddings`, `generate_content`, `execute_tool`, `create_agent`, `invoke_agent`, or `other` (the sentinel an unrecognized operation coerces to)
90
91
  - `frugal.batched` — `true` for batch-API submissions, `false` otherwise
91
92
 
92
93
  The "Additional attributes" column above lists what each metric carries on top of the base set. See [`docs/metrics-reference.md`](docs/metrics-reference.md) for the values each attribute takes, sentinel values for caller fields, and the full internal-error-stage list.
@@ -45,12 +45,31 @@ calls, token usage) are 100% — never sampled.
45
45
 
46
46
  | Attribute | Example |
47
47
  | -------------------------- | ------------------------------------------ |
48
- | `gen_ai.system` | `openai` \| `anthropic` \| `aws.bedrock` \| `vertex_ai` \| `custom` |
49
- | `gen_ai.operation.name` | `chat` \| `text_completion` \| `embeddings` |
48
+ | `gen_ai.system` | `openai` \| `anthropic` \| `aws.bedrock` \| `vertex_ai` \| `az.ai.openai` \| `cohere` \| `mistral_ai` \| `deepseek` \| `groq` \| `perplexity` \| `x_ai` \| `ibm.watsonx.ai` \| `gcp.gemini` \| `custom` (legacy spelling — see below) |
49
+ | `gen_ai.provider.name` | `openai` \| `anthropic` \| `aws.bedrock` \| `gcp.vertex_ai` \| `azure.ai.openai` \| `cohere` \| `mistral_ai` \| `deepseek` \| `groq` \| `perplexity` \| `x_ai` \| `ibm.watsonx.ai` \| `gcp.gemini` \| `custom` (current OTel attribute) |
50
+ | `gen_ai.operation.name` | `chat` \| `text_completion` \| `embeddings` \| `generate_content` \| `execute_tool` \| `create_agent` \| `invoke_agent` \| `other` (sentinel for an unrecognized operation) |
50
51
  | `gen_ai.request.model` | `gpt-4o` / `claude-3-5-sonnet-latest` / … |
51
52
  | `gen_ai.response.model` | Provider-returned model id (often identical)|
52
53
  | `gen_ai.token.type` | `input` \| `output` (token usage metric only) |
53
54
 
55
+ > **Provider attribute migration.** OTel renamed `gen_ai.system` to
56
+ > `gen_ai.provider.name` (some spellings changed, e.g. `vertex_ai` →
57
+ > `gcp.vertex_ai`). The SDK **dual-emits both** during the transition: every
58
+ > metric carries the legacy `gen_ai.system` value (so existing queries/history
59
+ > keep matching) and the new `gen_ai.provider.name` value. The `system` kwarg
60
+ > accepts either spelling. A value outside the well-known set (e.g. a typo like
61
+ > `amazon-bedrock`) is **coerced to `custom`** on both labels — so a wrong label
62
+ > never reaches metrics — and surfaced once through the debug callback
63
+ > (`set_debug_callback`), per distinct value (bounded so a high-cardinality
64
+ > caller can't grow the warning set without limit). To keep a legitimate provider
65
+ > that isn't in the registry, register it via `FRUGAL_CUSTOM_PROVIDERS` (up to 10)
66
+ > and it passes through verbatim on both labels. An `operation` outside the accepted set is coerced to
67
+ > the `other` sentinel (NOT `chat`, so a typo can't inflate the real chat series) with the same
68
+ > one-time warning — the seven listed values plus an explicit `other` pass through unchanged. The
69
+ > exported `GenAiSystem` / `GenAiOperation` `Literal` aliases give strict typing for callers who want
70
+ > it — `GenAiSystem` lists both provider spellings the runtime accepts (the kwargs stay `str` so a
71
+ > free-form custom value remains legal).
72
+
54
73
  ### Frugal-added
55
74
 
56
75
  | Attribute | Value |
@@ -92,6 +92,13 @@ token counts are not (the OpenAI SDK doesn't send them).
92
92
  Use `wrap_anthropic` with the Anthropic SDK's `AnthropicBedrock` client. The
93
93
  wrapper detects it and tags metrics with `gen_ai.system = aws.bedrock`.
94
94
 
95
+ > Every metric is **dual-emitted** under both `gen_ai.system` (legacy) and
96
+ > `gen_ai.provider.name` (current OTel spelling — e.g. `aws.bedrock`,
97
+ > `gcp.vertex_ai`). A provider or operation outside the known set is coerced to
98
+ > the `custom` / `other` sentinel with a one-time warning (via
99
+ > `set_debug_callback`); register extra providers with `FRUGAL_CUSTOM_PROVIDERS`.
100
+ > See [metrics-reference.md](metrics-reference.md#attributes).
101
+
95
102
  ```python
96
103
  from anthropic import AnthropicBedrock
97
104
  from frugal_metrics import wrap_anthropic
@@ -3,6 +3,14 @@
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
 
5
5
  from frugal_metrics.debug import set_debug_callback
6
+ from frugal_metrics.providers import (
7
+ PROVIDERS,
8
+ GenAiOperation,
9
+ GenAiSystem,
10
+ ProviderEntry,
11
+ normalize_operation,
12
+ normalize_provider,
13
+ )
6
14
  from frugal_metrics.shutdown import enable_auto_shutdown, flush, shutdown
7
15
  from frugal_metrics.track import AICallSpan, start_ai_call, track_ai_call
8
16
  from frugal_metrics.wrappers.openai import wrap_openai
@@ -20,6 +28,12 @@ __all__ = [
20
28
  "flush",
21
29
  "shutdown",
22
30
  "enable_auto_shutdown",
31
+ "PROVIDERS",
32
+ "ProviderEntry",
33
+ "GenAiSystem",
34
+ "GenAiOperation",
35
+ "normalize_provider",
36
+ "normalize_operation",
23
37
  ]
24
38
  try:
25
39
  __version__ = version("frugal-sdk-python")
@@ -35,7 +35,7 @@ import os
35
35
  import threading
36
36
  from dataclasses import dataclass, field
37
37
 
38
- from frugal_metrics.debug import emit_log
38
+ from frugal_metrics.debug import emit_log, emit_log_once
39
39
 
40
40
  logger = logging.getLogger("frugal_metrics")
41
41
 
@@ -43,6 +43,7 @@ _ENV_API_KEY = "FRUGAL_API_KEY"
43
43
  _ENV_CUSTOMER_ID = "FRUGAL_CUSTOMER_ID"
44
44
  _ENV_PROJECT_ID = "FRUGAL_PROJECT_ID"
45
45
  _ENV_COMPONENTS = "FRUGAL_COMPONENTS"
46
+ _ENV_CUSTOM_PROVIDERS = "FRUGAL_CUSTOM_PROVIDERS"
46
47
  _ENV_REPO_ROOT = "FRUGAL_REPO_ROOT"
47
48
  _ENV_PATH_BLOCK_LIST = "FRUGAL_PATH_BLOCK_LIST"
48
49
  _ENV_DISABLED = "FRUGAL_DISABLED"
@@ -58,6 +59,8 @@ _ENV_PROMPT_ANALYSIS_SAMPLE_RATE = "FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE"
58
59
  _DEFAULT_OTLP_ENDPOINT = "https://metrics.frugal.co/opentelemetry/v1/metrics"
59
60
  _DEFAULT_MAX_CALL_SITES = 500
60
61
  _DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE = 0.1
62
+ # Upper bound on customer-registered provider names — keeps label cardinality bounded.
63
+ _MAX_CUSTOM_PROVIDERS = 10
61
64
 
62
65
  _warned_once = False
63
66
  _warned_lock = threading.Lock()
@@ -72,6 +75,10 @@ class Config:
72
75
  repo_root: str
73
76
  # Optional logical components; metrics fan out one extra series per entry.
74
77
  components: tuple[str, ...] = field(default_factory=tuple)
78
+ # Customer-registered provider names (beyond the built-in OTel registry) that
79
+ # pass through verbatim instead of being coerced to "custom". Capped to keep
80
+ # gen_ai.system / gen_ai.provider.name cardinality bounded.
81
+ custom_providers: tuple[str, ...] = field(default_factory=tuple)
75
82
  path_block_list: tuple[str, ...] = field(default_factory=tuple)
76
83
  otlp_endpoint: str = _DEFAULT_OTLP_ENDPOINT
77
84
  otlp_headers: str | None = None
@@ -157,6 +164,28 @@ def load_config() -> Config | None:
157
164
  entry.strip() for entry in components_raw.split(",") if entry.strip()
158
165
  )
159
166
 
167
+ # Optional comma-separated allowlist of custom provider names that pass
168
+ # through verbatim (e.g. "together_ai,fireworks"). Deduped (order-preserving)
169
+ # and capped at _MAX_CUSTOM_PROVIDERS so a misconfigured value can't blow up
170
+ # label cardinality.
171
+ custom_providers_raw = os.environ.get(_ENV_CUSTOM_PROVIDERS, "")
172
+ custom_providers_all = list(
173
+ dict.fromkeys(
174
+ entry.strip()
175
+ for entry in custom_providers_raw.split(",")
176
+ if entry.strip()
177
+ )
178
+ )
179
+ custom_providers = tuple(custom_providers_all[:_MAX_CUSTOM_PROVIDERS])
180
+ if len(custom_providers_all) > _MAX_CUSTOM_PROVIDERS:
181
+ emit_log_once(
182
+ "custom_providers_truncated",
183
+ "warn",
184
+ f"{_ENV_CUSTOM_PROVIDERS} lists {len(custom_providers_all)} entries; "
185
+ f"only the first {_MAX_CUSTOM_PROVIDERS} are honored, the rest are "
186
+ f'coerced to "custom".',
187
+ )
188
+
160
189
  otlp_endpoint = (
161
190
  os.environ.get(_ENV_FRUGAL_OTLP_ENDPOINT)
162
191
  or os.environ.get(_ENV_OTLP_ENDPOINT)
@@ -185,6 +214,7 @@ def load_config() -> Config | None:
185
214
  project_id=project_id, # type: ignore[arg-type]
186
215
  repo_root=repo_root_abs,
187
216
  components=components,
217
+ custom_providers=custom_providers,
188
218
  path_block_list=block_list,
189
219
  otlp_endpoint=otlp_endpoint,
190
220
  otlp_headers=otlp_headers,
@@ -23,6 +23,7 @@ from frugal_metrics.caller import (
23
23
  from frugal_metrics.config import Config
24
24
  from frugal_metrics.debug import emit_log_once
25
25
  from frugal_metrics.otel import Instruments, get_instruments
26
+ from frugal_metrics.providers import normalize_operation, normalize_provider
26
27
  from frugal_metrics.prompt_analyzer import (
27
28
  RequestPayload,
28
29
  analyze_request,
@@ -56,9 +57,14 @@ class UsageReport:
56
57
 
57
58
 
58
59
  def _base_attrs(config: Config, ctx: CallContext, caller: CallerInfo) -> dict[str, Any]:
60
+ # Normalize the constrained-vocabulary labels at the single chokepoint every
61
+ # metric flows through: coerce typos to the sentinel and dual-emit both the
62
+ # legacy gen_ai.system value and the new gen_ai.provider.name value.
63
+ provider = normalize_provider(ctx.system, config.custom_providers)
59
64
  attrs: dict[str, Any] = {
60
- semconv.GEN_AI_SYSTEM: ctx.system,
61
- semconv.GEN_AI_OPERATION_NAME: ctx.operation,
65
+ semconv.GEN_AI_SYSTEM: provider.system,
66
+ semconv.GEN_AI_PROVIDER_NAME: provider.provider_name,
67
+ semconv.GEN_AI_OPERATION_NAME: normalize_operation(ctx.operation),
62
68
  semconv.GEN_AI_REQUEST_MODEL: ctx.request_model,
63
69
  semconv.FRUGAL_PROJECT_ID: config.project_id,
64
70
  semconv.FRUGAL_CALLER_FILE: caller.file,
@@ -0,0 +1,219 @@
1
+ """Canonical provider registry + sentinel normalization.
2
+
3
+ ``gen_ai.system`` is the OpenTelemetry GenAI semantic-convention attribute that
4
+ identifies the provider/platform a model runs on. OTel has since renamed it to
5
+ ``gen_ai.provider.name``, with a few new value spellings (e.g. ``vertex_ai`` ->
6
+ ``gcp.vertex_ai``). We dual-emit both attributes: the legacy ``gen_ai.system``
7
+ value (unchanged for the providers we already supported, so existing queries and
8
+ historical series keep matching) plus the new ``gen_ai.provider.name`` value.
9
+ Consumers migrate to the new attribute at their own pace; the old one is dropped
10
+ afterward.
11
+
12
+ Any value outside the well-known set is a bug (a typo like ``amazon-bedrock``
13
+ silently produces a series no query ever matches). Such values are coerced to the
14
+ ``custom`` sentinel -- a wrong label never reaches metrics -- and surfaced once
15
+ via the debug channel. Everything here is pure and never raises: the SDK is safe
16
+ by construction.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import threading
22
+ from typing import Literal, NamedTuple
23
+
24
+ from frugal_metrics import semconv
25
+ from frugal_metrics.debug import emit_log
26
+
27
+
28
+ class ProviderEntry(NamedTuple):
29
+ """A provider's legacy and current attribute spellings."""
30
+
31
+ system: str # legacy gen_ai.system value (kept for back-compat)
32
+ provider_name: str # new gen_ai.provider.name value (current OTel spelling)
33
+
34
+
35
+ # Single source of truth. ``system`` preserves the spelling Frugal already
36
+ # emitted for the providers it historically supported (so existing dashboards /
37
+ # history keep matching); ``provider_name`` is the current OTel spelling. For
38
+ # providers we never emitted before, both columns are the same OTel string.
39
+ PROVIDERS: tuple[ProviderEntry, ...] = (
40
+ ProviderEntry("openai", "openai"),
41
+ ProviderEntry("anthropic", "anthropic"),
42
+ ProviderEntry("aws.bedrock", "aws.bedrock"),
43
+ ProviderEntry("vertex_ai", "gcp.vertex_ai"),
44
+ ProviderEntry("az.ai.openai", "azure.ai.openai"),
45
+ ProviderEntry("cohere", "cohere"),
46
+ ProviderEntry("mistral_ai", "mistral_ai"),
47
+ ProviderEntry("deepseek", "deepseek"),
48
+ ProviderEntry("groq", "groq"),
49
+ ProviderEntry("perplexity", "perplexity"),
50
+ ProviderEntry("x_ai", "x_ai"),
51
+ ProviderEntry("ibm.watsonx.ai", "ibm.watsonx.ai"),
52
+ ProviderEntry("gcp.gemini", "gcp.gemini"),
53
+ )
54
+
55
+ # Accepted provider values. Exported for callers who want strict typing; the
56
+ # public params stay ``str`` so a free-form custom value remains legal
57
+ # (``Literal[...] | str`` collapses to ``str`` for type checkers, so it cannot
58
+ # give both autocomplete *and* free-form). Runtime coercion is the real
59
+ # enforcement. Lists BOTH spellings (legacy ``system`` and new
60
+ # ``provider_name``) since ``normalize_provider`` accepts either — a strict-typed
61
+ # caller must be able to annotate with the new ``gcp.vertex_ai`` form. The
62
+ # test ``test_genai_system_literal_matches_registry`` guards this against drift.
63
+ GenAiSystem = Literal[
64
+ "openai",
65
+ "anthropic",
66
+ "aws.bedrock",
67
+ "vertex_ai",
68
+ "gcp.vertex_ai",
69
+ "az.ai.openai",
70
+ "azure.ai.openai",
71
+ "cohere",
72
+ "mistral_ai",
73
+ "deepseek",
74
+ "groq",
75
+ "perplexity",
76
+ "x_ai",
77
+ "ibm.watsonx.ai",
78
+ "gcp.gemini",
79
+ "custom",
80
+ ]
81
+
82
+ # Must enumerate the same values as ``_OPERATIONS`` below (a ``Literal`` can't be
83
+ # derived from a runtime ``frozenset``). Keep the two in sync.
84
+ # Lists the accepted operations plus the ``other`` coercion sentinel. Guarded
85
+ # against drift from ``_OPERATIONS_ORDERED`` by
86
+ # ``test_genai_operation_literal_matches_runtime``.
87
+ GenAiOperation = Literal[
88
+ "chat",
89
+ "text_completion",
90
+ "embeddings",
91
+ "generate_content",
92
+ "execute_tool",
93
+ "create_agent",
94
+ "invoke_agent",
95
+ "other",
96
+ ]
97
+
98
+ # Operations accepted without warning: the three defaults plus other OTel-defined
99
+ # values. Ordered to match the Node SDK's OPERATIONS tuple so both SDKs emit an
100
+ # identical "use one of" diagnostic; the frozenset is derived for O(1) membership.
101
+ # Single source of truth for ``GenAiOperation`` above too.
102
+ _OPERATIONS_ORDERED: tuple[str, ...] = (
103
+ "chat",
104
+ "text_completion",
105
+ "embeddings",
106
+ "generate_content",
107
+ "execute_tool",
108
+ "create_agent",
109
+ "invoke_agent",
110
+ )
111
+ _OPERATIONS: frozenset[str] = frozenset(_OPERATIONS_ORDERED)
112
+
113
+ _CUSTOM = ProviderEntry(semconv.SYSTEM_CUSTOM, semconv.SYSTEM_CUSTOM)
114
+
115
+ _BY_VALUE: dict[str, ProviderEntry] = {}
116
+ for _entry in PROVIDERS:
117
+ _BY_VALUE[_entry.system] = _entry
118
+ _BY_VALUE[_entry.provider_name] = _entry
119
+
120
+
121
+ # Bounded warn-once: surface each distinct invalid provider/operation value once,
122
+ # but cap how many distinct values we track so a high-cardinality caller (e.g. a
123
+ # gateway labeling calls from request data) can't grow this set without bound.
124
+ # Past the cap, further unknown values are coerced silently after a single
125
+ # generic notice. We do our own de-dup here (emit_log, not emit_log_once) so the
126
+ # cap is enforced in one place -- and so every distinct typo is surfaced, not just
127
+ # the first one.
128
+ _MAX_TRACKED_INVALID = 32
129
+ _warned_invalid: set[str] = set()
130
+ _cap_notice_emitted = False
131
+ # A metrics SDK is called from concurrent request threads, so the check-then-act
132
+ # on the shared warn state must be synchronized (mirrors debug._lock /
133
+ # config._warned_lock) or the same typo could warn twice and the set could grow
134
+ # past the cap.
135
+ _warned_lock = threading.Lock()
136
+
137
+ _CAP_NOTICE = (
138
+ f"frugal-metrics: {_MAX_TRACKED_INVALID}+ distinct invalid provider/operation "
139
+ "values seen; further unknown values are coerced to the sentinel without an "
140
+ "individual warning."
141
+ )
142
+
143
+
144
+ def _warn_invalid_once(key: str, message: str) -> None:
145
+ global _cap_notice_emitted
146
+ # Decide what (if anything) to emit while holding the lock; emit outside it so
147
+ # a slow debug callback can't serialize other threads' normalization.
148
+ emit: str | None = None
149
+ with _warned_lock:
150
+ if key in _warned_invalid:
151
+ return
152
+ if len(_warned_invalid) >= _MAX_TRACKED_INVALID:
153
+ if _cap_notice_emitted:
154
+ return
155
+ _cap_notice_emitted = True
156
+ emit = _CAP_NOTICE
157
+ else:
158
+ _warned_invalid.add(key)
159
+ emit = message
160
+ emit_log("warn", emit)
161
+
162
+
163
+ def reset_provider_warnings_for_tests() -> None:
164
+ """Test-only -- clear the bounded invalid-value warning state."""
165
+ global _cap_notice_emitted
166
+ with _warned_lock:
167
+ _warned_invalid.clear()
168
+ _cap_notice_emitted = False
169
+
170
+
171
+ def normalize_provider(
172
+ value: str | None, custom_providers: tuple[str, ...] = ()
173
+ ) -> ProviderEntry:
174
+ """Resolve a caller-supplied provider string to its canonical pair.
175
+
176
+ Accepts either spelling (legacy ``gen_ai.system`` or new
177
+ ``gen_ai.provider.name``). A value the customer registered via
178
+ ``custom_providers`` passes through verbatim (system == provider_name). Any
179
+ other unknown value is coerced to the ``custom`` sentinel and reported
180
+ (bounded warn-once). ``None`` resolves to ``custom`` silently (the intended
181
+ default). Never raises.
182
+ """
183
+ if value is None or value == semconv.SYSTEM_CUSTOM:
184
+ return _CUSTOM
185
+ entry = _BY_VALUE.get(value)
186
+ if entry is not None:
187
+ return entry
188
+ # Customer-registered passthrough: no OTel mapping, so both labels carry the
189
+ # raw name. The set is capped at config-load time, so cardinality stays bounded.
190
+ if value in custom_providers:
191
+ return ProviderEntry(value, value)
192
+ _warn_invalid_once(
193
+ f"provider:{value}",
194
+ f'unknown gen_ai provider "{value}" -- coerced to "custom". Use a '
195
+ 'well-known OpenTelemetry GenAI provider value (e.g. "aws.bedrock", '
196
+ '"vertex_ai", "anthropic", "openai"), or register it via '
197
+ "FRUGAL_CUSTOM_PROVIDERS.",
198
+ )
199
+ return _CUSTOM
200
+
201
+
202
+ def normalize_operation(value: str | None) -> str:
203
+ """Resolve a caller-supplied operation string.
204
+
205
+ Unknown values are coerced to the ``other`` sentinel -- NOT ``chat`` -- so a
206
+ typo lands in a distinguishable bucket instead of inflating the real chat
207
+ series, and reported (bounded warn-once). ``None`` resolves to ``chat`` (the
208
+ default); an explicit ``other`` passes through silently. Never raises.
209
+ """
210
+ if value is None:
211
+ return semconv.OP_CHAT
212
+ if value == semconv.OP_OTHER or value in _OPERATIONS:
213
+ return value
214
+ _warn_invalid_once(
215
+ f"operation:{value}",
216
+ f'unknown gen_ai.operation.name "{value}" -- coerced to "other". Use one '
217
+ f'of: {", ".join(_OPERATIONS_ORDERED)}.',
218
+ )
219
+ return semconv.OP_OTHER
@@ -2,12 +2,18 @@
2
2
 
3
3
  # GenAI semconv attribute names (stable as of semconv v1.28+).
4
4
  GEN_AI_SYSTEM = "gen_ai.system"
5
+ # OTel renamed gen_ai.system -> gen_ai.provider.name; we dual-emit both during
6
+ # the transition. See providers.py for the value mapping.
7
+ GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"
5
8
  GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
6
9
  GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
7
10
  GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
8
11
  GEN_AI_TOKEN_TYPE = "gen_ai.token.type"
9
12
 
10
- # GenAI semconv system identifiers.
13
+ # Legacy gen_ai.system spellings exposed as named constants for the wrapper
14
+ # modules. The full cross-provider registry (cohere, mistral_ai, deepseek,
15
+ # groq, …) lives solely in the PROVIDERS table in providers.py — the single
16
+ # source of truth — and those values are not re-declared here.
11
17
  SYSTEM_OPENAI = "openai"
12
18
  SYSTEM_ANTHROPIC = "anthropic"
13
19
  SYSTEM_AZURE_OPENAI = "az.ai.openai"
@@ -21,6 +27,9 @@ SYSTEM_CUSTOM = "custom"
21
27
  OP_CHAT = "chat"
22
28
  OP_TEXT_COMPLETION = "text_completion"
23
29
  OP_EMBEDDINGS = "embeddings"
30
+ # Sentinel for an operation outside the accepted set — parallels SYSTEM_CUSTOM so
31
+ # a typo lands in a distinguishable bucket instead of corrupting the real chat series.
32
+ OP_OTHER = "other"
24
33
 
25
34
  # GenAI semconv token types.
26
35
  TOKEN_INPUT = "input"
@@ -21,6 +21,7 @@ from frugal_metrics.block_hash_lru import (
21
21
  from frugal_metrics.caller import reset_caches_for_tests as reset_caller_caches
22
22
  from frugal_metrics.debug import reset_debug_for_tests
23
23
  from frugal_metrics.otel import Instruments
24
+ from frugal_metrics.providers import reset_provider_warnings_for_tests
24
25
 
25
26
 
26
27
  @dataclass
@@ -80,6 +81,7 @@ def required_env(monkeypatch, tmp_path) -> str:
80
81
  reset_caller_caches()
81
82
  reset_shared_block_hash_lru_for_tests()
82
83
  reset_debug_for_tests()
84
+ reset_provider_warnings_for_tests()
83
85
  return repo_root
84
86
 
85
87
 
@@ -73,6 +73,24 @@ def test_parses_components(monkeypatch):
73
73
  assert cfg.components == ("ai-handler", "reco", "trailing")
74
74
 
75
75
 
76
+ def test_parses_dedupes_and_caps_custom_providers(monkeypatch):
77
+ monkeypatch.setenv("FRUGAL_PROJECT_ID", "p")
78
+ monkeypatch.setenv("FRUGAL_OTEL_EXPORTER_OTLP_HEADERS", "Authorization=Bearer x")
79
+ monkeypatch.setenv("FRUGAL_API_KEY", "sk-test")
80
+ monkeypatch.setenv("FRUGAL_REPO_ROOT", "/app")
81
+ monkeypatch.setenv("FRUGAL_CUSTOM_PROVIDERS", "a, b ,, a") # dedupe + drop empties
82
+ cfg = config_module.load_config()
83
+ assert cfg is not None
84
+ assert cfg.custom_providers == ("a", "b")
85
+ # More than 10 distinct -> capped to the first 10.
86
+ monkeypatch.setenv(
87
+ "FRUGAL_CUSTOM_PROVIDERS", ",".join(f"p{i}" for i in range(13))
88
+ )
89
+ cfg = config_module.load_config()
90
+ assert cfg is not None
91
+ assert len(cfg.custom_providers) == 10
92
+
93
+
76
94
  def test_parses_block_list(monkeypatch):
77
95
  monkeypatch.setenv("FRUGAL_CUSTOMER_ID", "c")
78
96
  monkeypatch.setenv("FRUGAL_PROJECT_ID", "p")
@@ -0,0 +1,180 @@
1
+ """Provider/operation sentinel normalization.
2
+
3
+ Dual-emit of gen_ai.system + gen_ai.provider.name, coerce-to-custom on typos,
4
+ and de-duped warnings. Mirrors the Node providers.test.ts coverage.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from typing import get_args
12
+
13
+ from frugal_metrics import semconv
14
+ from frugal_metrics.debug import set_debug_callback
15
+ from frugal_metrics.providers import (
16
+ _OPERATIONS_ORDERED,
17
+ PROVIDERS,
18
+ GenAiOperation,
19
+ GenAiSystem,
20
+ ProviderEntry,
21
+ normalize_operation,
22
+ normalize_provider,
23
+ )
24
+ from frugal_metrics.track import track_ai_call
25
+
26
+
27
+ def _by_name(points, name):
28
+ return [p for p in points if p["name"] == name]
29
+
30
+
31
+ # --- unit ---------------------------------------------------------------------
32
+
33
+
34
+ def test_known_legacy_value_maps_to_both_spellings():
35
+ assert normalize_provider("vertex_ai") == ProviderEntry("vertex_ai", "gcp.vertex_ai")
36
+ assert normalize_provider("aws.bedrock") == ProviderEntry("aws.bedrock", "aws.bedrock")
37
+
38
+
39
+ def test_accepts_new_provider_name_spelling():
40
+ assert normalize_provider("gcp.vertex_ai") == ProviderEntry(
41
+ "vertex_ai", "gcp.vertex_ai"
42
+ )
43
+
44
+
45
+ def test_azure_dual_spelling_maps_both_ways():
46
+ # az.ai.openai is the only provider besides vertex_ai where the legacy and
47
+ # new spellings differ; both must resolve to the same canonical pair.
48
+ expected = ProviderEntry("az.ai.openai", "azure.ai.openai")
49
+ assert normalize_provider("az.ai.openai") == expected
50
+ assert normalize_provider("azure.ai.openai") == expected
51
+
52
+
53
+ def test_unknown_provider_coerces_to_custom():
54
+ assert normalize_provider("amazon-bedrock") == ProviderEntry("custom", "custom")
55
+
56
+
57
+ def test_none_and_custom_resolve_silently():
58
+ assert normalize_provider(None) == ProviderEntry("custom", "custom")
59
+ assert normalize_provider("custom") == ProviderEntry("custom", "custom")
60
+
61
+
62
+ def test_operation_coercion():
63
+ assert normalize_operation("embeddings") == "embeddings"
64
+ # Unknown -> 'other' sentinel (a distinguishable bucket), not chat.
65
+ assert normalize_operation("bogus") == "other"
66
+ # Explicit 'other' passes through; absent defaults to chat.
67
+ assert normalize_operation("other") == "other"
68
+ assert normalize_operation(None) == "chat"
69
+
70
+
71
+ def test_genai_system_literal_matches_registry():
72
+ # Guard against drift: the exported Literal must list exactly what
73
+ # normalize_provider accepts (both spellings of every registry entry + custom).
74
+ expected = (
75
+ {p.system for p in PROVIDERS}
76
+ | {p.provider_name for p in PROVIDERS}
77
+ | {"custom"}
78
+ )
79
+ assert set(get_args(GenAiSystem)) == expected
80
+
81
+
82
+ def test_genai_operation_literal_matches_runtime():
83
+ # Guard against drift: the accepted operations plus the 'other' sentinel.
84
+ assert set(get_args(GenAiOperation)) == set(_OPERATIONS_ORDERED) | {"other"}
85
+
86
+
87
+ def test_non_default_operations_pass_through():
88
+ # OTel-defined operations beyond the default trio are accepted unchanged
89
+ # (not coerced to chat).
90
+ for op in ("generate_content", "execute_tool", "create_agent", "invoke_agent"):
91
+ assert normalize_operation(op) == op
92
+
93
+
94
+ # --- integration through track_ai_call ----------------------------------------
95
+
96
+
97
+ @pytest.fixture
98
+ def warnings(required_env):
99
+ captured_warnings: list[tuple[str, str]] = []
100
+ set_debug_callback(lambda level, message, context=None: captured_warnings.append((level, message)))
101
+ yield captured_warnings
102
+ set_debug_callback(None)
103
+
104
+
105
+ def test_dual_emits_system_and_provider_name(captured, warnings):
106
+ with track_ai_call(system="vertex_ai") as span:
107
+ span.set_usage(input_tokens=1)
108
+ attrs = _by_name(captured.collect(), semconv.METRIC_CALLS)[0]["attributes"]
109
+ assert attrs[semconv.GEN_AI_SYSTEM] == "vertex_ai"
110
+ assert attrs[semconv.GEN_AI_PROVIDER_NAME] == "gcp.vertex_ai"
111
+ assert warnings == []
112
+
113
+
114
+ def test_typo_coerces_to_custom_and_warns_once(captured, warnings):
115
+ for _ in range(2):
116
+ with track_ai_call(system="amazon-bedrock") as span:
117
+ span.set_usage(input_tokens=1)
118
+ points = _by_name(captured.collect(), semconv.METRIC_CALLS)
119
+ for p in points:
120
+ assert p["attributes"][semconv.GEN_AI_SYSTEM] == "custom"
121
+ assert p["attributes"][semconv.GEN_AI_PROVIDER_NAME] == "custom"
122
+ provider_warnings = [w for w in warnings if "amazon-bedrock" in w[1]]
123
+ assert len(provider_warnings) == 1
124
+ assert provider_warnings[0][0] == "warn"
125
+
126
+
127
+ def test_unknown_operation_coerces_to_other_and_warns_once(captured, warnings):
128
+ for _ in range(2):
129
+ with track_ai_call(system="openai", operation="summarize") as span:
130
+ span.set_usage(input_tokens=1)
131
+ attrs = _by_name(captured.collect(), semconv.METRIC_CALLS)[0]["attributes"]
132
+ assert attrs[semconv.GEN_AI_OPERATION_NAME] == "other"
133
+ # Coercion runs on both calls, but the operation warning de-dupes.
134
+ assert len([w for w in warnings if "summarize" in w[1]]) == 1
135
+
136
+
137
+ # --- custom providers (FRUGAL_CUSTOM_PROVIDERS) -------------------------------
138
+
139
+
140
+ def test_registered_custom_provider_passes_through_unit():
141
+ assert normalize_provider("together_ai", ("together_ai",)) == ProviderEntry(
142
+ "together_ai", "together_ai"
143
+ )
144
+ # Same value, but not registered -> coerced to custom.
145
+ assert normalize_provider("together_ai", ()) == ProviderEntry("custom", "custom")
146
+
147
+
148
+ def test_custom_provider_emits_verbatim_through_track(captured, warnings, monkeypatch):
149
+ monkeypatch.setenv("FRUGAL_CUSTOM_PROVIDERS", "together_ai,fireworks")
150
+ with track_ai_call(system="together_ai") as span:
151
+ span.set_usage(input_tokens=1)
152
+ attrs = _by_name(captured.collect(), semconv.METRIC_CALLS)[0]["attributes"]
153
+ assert attrs[semconv.GEN_AI_SYSTEM] == "together_ai"
154
+ assert attrs[semconv.GEN_AI_PROVIDER_NAME] == "together_ai"
155
+ assert warnings == []
156
+
157
+
158
+ def test_unregistered_provider_still_coerces(captured, warnings, monkeypatch):
159
+ monkeypatch.setenv("FRUGAL_CUSTOM_PROVIDERS", "together_ai")
160
+ with track_ai_call(system="ai21") as span:
161
+ span.set_usage(input_tokens=1)
162
+ attrs = _by_name(captured.collect(), semconv.METRIC_CALLS)[0]["attributes"]
163
+ assert attrs[semconv.GEN_AI_SYSTEM] == "custom"
164
+ assert len([w for w in warnings if "ai21" in w[1]]) == 1
165
+
166
+
167
+ # --- bounded invalid-value warnings ------------------------------------------
168
+
169
+
170
+ def test_warns_once_per_distinct_invalid_provider(required_env):
171
+ # The old fixed-key dedup surfaced only the first invalid value process-wide;
172
+ # each DISTINCT value must now warn (bounded by a cap).
173
+ msgs: list[str] = []
174
+ set_debug_callback(lambda level, message, context=None: msgs.append(message))
175
+ normalize_provider("amazon-bedrock")
176
+ normalize_provider("amazon-bedrock") # duplicate -> no second warning
177
+ normalize_provider("gpt-typo") # distinct -> must still warn
178
+ set_debug_callback(None)
179
+ assert len([m for m in msgs if "amazon-bedrock" in m]) == 1
180
+ assert len([m for m in msgs if "gpt-typo" in m]) == 1