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.
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/PKG-INFO +4 -3
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/README.md +3 -2
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/docs/metrics-reference.md +21 -2
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/docs/quickstart.md +7 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/__init__.py +14 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/config.py +31 -1
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/instrument.py +8 -2
- frugal_sdk_python-0.3.0/src/frugal_metrics/providers.py +219 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/semconv.py +10 -1
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/conftest.py +2 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_config.py +18 -0
- frugal_sdk_python-0.3.0/tests/test_providers.py +180 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/.github/workflows/cd.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/.gitignore +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/LICENSES/Apache-2.0.txt +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/REUSE.toml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/README.md +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/docker-compose.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/Dockerfile +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/package.json +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/src/server.js +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/models.html +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/prometheus.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/Dockerfile +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/clients.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
- {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
- {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
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
- {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
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/main.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
- {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
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/pyproject.toml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/requirements.txt +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/scripts/verify.sh +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/pyproject.toml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/block_hash_lru.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/caller.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/debug.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/otel.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/prompt_analyzer.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/safe.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/shutdown.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/streams.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/track.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/anthropic.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/openai.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_bedrock_models.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_block_hash_lru.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_caller.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_caller_edge_cases.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_debug.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_prompt_analyzer.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_shutdown.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_track_ai_call.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_anthropic.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_bedrock.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_openai.py +0 -0
- {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.
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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.
|
|
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:
|
|
61
|
-
semconv.
|
|
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
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/sites.html
RENAMED
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/prometheus.yml
RENAMED
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/__init__.py
RENAMED
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/clients.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/main.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/pyproject.toml
RENAMED
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/requirements.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/anthropic.py
RENAMED
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.2.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock_models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|