llmshim 0.5.0__tar.gz → 0.6.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.
- {llmshim-0.5.0 → llmshim-0.6.0}/Cargo.lock +1 -1
- {llmshim-0.5.0 → llmshim-0.6.0}/Cargo.toml +1 -1
- {llmshim-0.5.0 → llmshim-0.6.0}/PKG-INFO +1 -1
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/proxy/scaling.md +24 -3
- {llmshim-0.5.0 → llmshim-0.6.0}/src/cost.rs +8 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/auth.rs +11 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/http.rs +56 -5
- {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/metrics.rs +4 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/quota.rs +163 -17
- {llmshim-0.5.0 → llmshim-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/.github/workflows/catalog-refresh.yml +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/.github/workflows/pages.yml +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/.github/workflows/release.yml +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/.gitignore +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/CLAUDE.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/CODE_OF_CONDUCT.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/CONTRIBUTING.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/LICENSE-APACHE +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/LICENSE-MIT +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/NOTICE +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/README.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/SECURITY.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/benchmarks/bench.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/benchmarks/bench_python.py +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/benchmarks/gateway_loadtest.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/benchmarks/loadtest.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/Cargo.toml +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/LICENSE-APACHE +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/LICENSE-MIT +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/README.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/data/LICENSE.models.dev +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/data/README.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/data/models.dev.json +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/aliases.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/builtin.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/capabilities.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/lib.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/merge.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/parse.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/refresh.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/types.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/tests/catalog.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/tests/refresh.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/.gitignore +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/book.toml +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/mermaid-init.js +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/mermaid.min.js +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/SUMMARY.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/contracts.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/conversations.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/portability.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/routing.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/translation-flow.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/caching.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/capabilities.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/fallbacks.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/images.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/native-controls.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/reasoning.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/schemas.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/streaming.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/tools.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/introduction.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/proxy/deployment.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/proxy/http-api.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/proxy/native-apis.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/api.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/cli.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/configuration.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/errors.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/models.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/providers.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/request-fields.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/surfaces.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/choose.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/cli.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/clients.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/configure.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/proxy.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/rust.md +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/examples/chat.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/examples/stream.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/llmshim/__init__.py +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/llmshim/_client.py +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/llmshim/_server.py +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/llmshim/types.py +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/pyproject.toml +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/breaker.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/cache.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/cli.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/client.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/config.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/env.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/error/normalize.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/error.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/fallback.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/distributed.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/idempotency.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/mod.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/lib.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/log.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/main.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/models.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/provider.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/anthropic.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/anthropic_reasoning.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/anthropic_signature.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/chatgpt/auth.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/chatgpt/mod.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/chatgpt/streaming.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/gemini.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/mod.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/openai.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/openai_compat.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/openrouter.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/xai.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/convert.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/error.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/handlers.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/health.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/mod.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/ratelimit.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/types.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/wire/mod.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/wire/receipts.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/reasoning/normalize.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/reasoning.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/router.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/schema/memo.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/schema/mod.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/schema/validate.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/schema/walk.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/shim.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/streaming.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/toolcall/streaming.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/toolcall.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/usage.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/src/vision.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/fixtures/chatgpt-red.png +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_chatgpt.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_chatgpt_proxy.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_current_models.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_fallback.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_gemini.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_gemini_tools.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_long_context.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_multimodel.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_openrouter.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_proxy.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_sglang.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_thinking.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_tool_roundtrip.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_vision.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_xai.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/support/completion_status.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_advertised_models.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_anthropic.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_cache.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_chatgpt.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_cli.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_fable.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_fallback.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_fast_mode.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_gemini.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_log.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_models.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_multimodel.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_openai.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_openai_compat.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_openrouter.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_provider_contracts.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_proxy.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_proxy_convert.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_reasoning.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_reasoning_profile.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_router.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_schema.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_shim.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_signature.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_sse.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_toolcall.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_tools.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_usage.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_vision.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_wire.rs +0 -0
- {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_xai.rs +0 -0
|
@@ -107,9 +107,30 @@ buckets. A gateway key's identity may carry `budget_usd` and an optional
|
|
|
107
107
|
```
|
|
108
108
|
|
|
109
109
|
Cost is only knowable after a response, so the cap is checked before dispatch
|
|
110
|
-
and charged after: one in-flight request can overshoot.
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
and charged after: one in-flight request can overshoot.
|
|
111
|
+
|
|
112
|
+
A response the catalog cannot price is **not** charged — recording zero would let
|
|
113
|
+
an unpriced model run forever under a budget. So that a cap cannot silently stop
|
|
114
|
+
binding, a request whose target has **no catalog price is refused before it runs**
|
|
115
|
+
when a budget is set:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
400 {"error":{"code":"unpriceable_under_budget","param":"model", …}}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
It is deliberately not a `429`: retrying never clears it. Three ways forward —
|
|
122
|
+
use a priced model, add a local price override in the catalog, or accept the risk
|
|
123
|
+
explicitly per key:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{"sk-example": {"tenant": "acme", "budget_usd": 100, "budget_allow_unpriced": true}}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`budget_allow_unpriced` defaults to `false`. With it set, those requests run and
|
|
130
|
+
are not charged, and each one logs a warning and increments
|
|
131
|
+
`llmshim_gateway_unpriced_under_cap_total{provider,model}` — a non-zero counter
|
|
132
|
+
means the budget is not binding for that target. An accepted risk should stay
|
|
133
|
+
measurable rather than become an assumption.
|
|
113
134
|
|
|
114
135
|
## One replica or a coordinated fleet
|
|
115
136
|
|
|
@@ -80,6 +80,14 @@ pub fn cost_usd(provider: &str, model: &str, usage: &Value) -> Option<f64> {
|
|
|
80
80
|
price(usage, &for_target(provider, model)?)
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/// Whether this target can be priced at all, asked *before* the request runs.
|
|
84
|
+
///
|
|
85
|
+
/// Pricing after the fact cannot enforce a budget: by then the money is spent.
|
|
86
|
+
/// A cap therefore needs this question answered up front.
|
|
87
|
+
pub fn is_priceable(provider: &str, model: &str) -> bool {
|
|
88
|
+
for_target(provider, model).is_some()
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
/// Read a cost already stamped onto a normalized usage object. A stamped
|
|
84
92
|
/// `null` and an unstamped body both mean "not known".
|
|
85
93
|
pub fn stamped(usage: &Value) -> Option<f64> {
|
|
@@ -35,6 +35,15 @@ pub struct Identity {
|
|
|
35
35
|
/// Window the cap applies to, in seconds. Defaults to one day.
|
|
36
36
|
#[serde(default)]
|
|
37
37
|
pub budget_window_secs: Option<u64>,
|
|
38
|
+
/// Permit requests the catalog cannot price while a budget is set.
|
|
39
|
+
///
|
|
40
|
+
/// Defaults to **false**, which refuses them. An unpriced response cannot be
|
|
41
|
+
/// charged, so under a cap it is spend the ledger never sees — the budget
|
|
42
|
+
/// silently stops binding and nothing says so. Setting this to `true` is an
|
|
43
|
+
/// operator accepting that risk knowingly; the requests are still counted and
|
|
44
|
+
/// reported so the hole is visible rather than assumed absent.
|
|
45
|
+
#[serde(default)]
|
|
46
|
+
pub budget_allow_unpriced: bool,
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/// Authentication failure — both map to HTTP 401.
|
|
@@ -103,6 +112,7 @@ impl KeyStore {
|
|
|
103
112
|
tpm: None,
|
|
104
113
|
budget_usd: None,
|
|
105
114
|
budget_window_secs: None,
|
|
115
|
+
budget_allow_unpriced: false,
|
|
106
116
|
}),
|
|
107
117
|
KeyStore::Enforced(map) => {
|
|
108
118
|
let key = bearer_token(headers).ok_or(AuthError::MissingKey)?;
|
|
@@ -171,6 +181,7 @@ mod tests {
|
|
|
171
181
|
tpm: None,
|
|
172
182
|
budget_usd: None,
|
|
173
183
|
budget_window_secs: None,
|
|
184
|
+
budget_allow_unpriced: false,
|
|
174
185
|
},
|
|
175
186
|
);
|
|
176
187
|
let store = KeyStore::enforced(keys);
|
|
@@ -325,16 +325,56 @@ impl GatewayState {
|
|
|
325
325
|
&self,
|
|
326
326
|
identity: &crate::gateway::auth::Identity,
|
|
327
327
|
provider: &str,
|
|
328
|
+
model: &str,
|
|
328
329
|
) -> Result<(), ApiError> {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
330
|
+
use crate::gateway::quota::BudgetRefusal;
|
|
331
|
+
match self.spend.check(identity, provider, model).await {
|
|
332
|
+
Ok(()) => {
|
|
333
|
+
// An operator who opted in still gets told, every time. An
|
|
334
|
+
// accepted risk that stops being visible becomes an assumption.
|
|
335
|
+
if crate::gateway::quota::SpendCap::is_unpriced_under_cap(identity, provider, model)
|
|
336
|
+
{
|
|
337
|
+
crate::gateway::metrics::incr(
|
|
338
|
+
crate::gateway::metrics::UNPRICED_UNDER_CAP,
|
|
339
|
+
&[("provider", provider), ("model", model)],
|
|
340
|
+
);
|
|
341
|
+
eprintln!(
|
|
342
|
+
"gateway: tenant {} running {provider}/{model} unpriced under a spend \
|
|
343
|
+
cap; this spend is NOT charged against the budget \
|
|
344
|
+
(budget_allow_unpriced is set)",
|
|
345
|
+
identity.tenant
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
Ok(())
|
|
349
|
+
}
|
|
350
|
+
Err(BudgetRefusal::Exhausted(retry)) => {
|
|
332
351
|
crate::gateway::metrics::incr(
|
|
333
352
|
crate::gateway::metrics::REJECTED,
|
|
334
353
|
&[("provider", provider), ("reason", "tenant_budget")],
|
|
335
354
|
);
|
|
336
355
|
Err(ApiError::RateLimited(retry))
|
|
337
356
|
}
|
|
357
|
+
Err(BudgetRefusal::Unpriceable) => {
|
|
358
|
+
crate::gateway::metrics::incr(
|
|
359
|
+
crate::gateway::metrics::REJECTED,
|
|
360
|
+
&[
|
|
361
|
+
("provider", provider),
|
|
362
|
+
("reason", "unpriceable_under_budget"),
|
|
363
|
+
],
|
|
364
|
+
);
|
|
365
|
+
// Not a 429: retrying never clears this. The catalog has no
|
|
366
|
+
// price for the target, so the cap cannot be enforced against it.
|
|
367
|
+
Err(ApiError::Shim(crate::error::ShimError::ProviderError {
|
|
368
|
+
status: 400,
|
|
369
|
+
body: format!(
|
|
370
|
+
"{{\"error\":{{\"message\":\"no catalog price for '{provider}/{model}', \
|
|
371
|
+
so it cannot be charged against this key's spend budget. Use a priced \
|
|
372
|
+
model, add a local price override, or set budget_allow_unpriced on the \
|
|
373
|
+
key to run it uncharged.\",\"type\":\"invalid_request_error\",\
|
|
374
|
+
\"param\":\"model\",\"code\":\"unpriceable_under_budget\"}}}}"
|
|
375
|
+
),
|
|
376
|
+
}))
|
|
377
|
+
}
|
|
338
378
|
}
|
|
339
379
|
}
|
|
340
380
|
}
|
|
@@ -410,7 +450,10 @@ async fn chat(
|
|
|
410
450
|
.map(str::to_string);
|
|
411
451
|
let (provider_name, gw, identity) = build_request(&state, &headers, &req)?;
|
|
412
452
|
state.enforce_quota(&identity, &provider_name, gw.permits)?;
|
|
413
|
-
state.
|
|
453
|
+
let (_, budget_model) = state.router.resolve(&req.model)?;
|
|
454
|
+
state
|
|
455
|
+
.enforce_budget(&identity, &provider_name, &budget_model)
|
|
456
|
+
.await?;
|
|
414
457
|
|
|
415
458
|
// Retry-safety: a repeated Idempotency-Key returns the first result.
|
|
416
459
|
if let Some(key) = &idem_key {
|
|
@@ -461,7 +504,14 @@ async fn chat_stream_inner(
|
|
|
461
504
|
if let Err(e) = state.enforce_quota(&identity, &provider_name, gw.permits) {
|
|
462
505
|
return e.into_response();
|
|
463
506
|
}
|
|
464
|
-
|
|
507
|
+
let budget_model = match state.router.resolve(&req.model) {
|
|
508
|
+
Ok((_, m)) => m,
|
|
509
|
+
Err(e) => return ApiError::from(e).into_response(),
|
|
510
|
+
};
|
|
511
|
+
if let Err(e) = state
|
|
512
|
+
.enforce_budget(&identity, &provider_name, &budget_model)
|
|
513
|
+
.await
|
|
514
|
+
{
|
|
465
515
|
return e.into_response();
|
|
466
516
|
}
|
|
467
517
|
|
|
@@ -673,6 +723,7 @@ mod native_tests {
|
|
|
673
723
|
tpm: None,
|
|
674
724
|
budget_usd: None,
|
|
675
725
|
budget_window_secs: None,
|
|
726
|
+
budget_allow_unpriced: false,
|
|
676
727
|
},
|
|
677
728
|
)]);
|
|
678
729
|
let state = Arc::new(GatewayState {
|
|
@@ -17,6 +17,10 @@ pub const REQUESTS: &str = "llmshim_gateway_requests_total";
|
|
|
17
17
|
pub const DISPATCHED: &str = "llmshim_gateway_dispatched_total";
|
|
18
18
|
/// Requests that did not dispatch (labels: provider, reason).
|
|
19
19
|
pub const REJECTED: &str = "llmshim_gateway_rejected_total";
|
|
20
|
+
|
|
21
|
+
/// Requests permitted to run without a price while a spend cap is configured.
|
|
22
|
+
/// Non-zero means a budget is not binding for that provider/model.
|
|
23
|
+
pub const UNPRICED_UNDER_CAP: &str = "llmshim_gateway_unpriced_under_cap_total";
|
|
20
24
|
/// In-flight upstream calls (gauge, label: provider).
|
|
21
25
|
pub const INFLIGHT: &str = "llmshim_gateway_inflight";
|
|
22
26
|
/// Time a job waited in the queue before dispatch (histogram, label: provider).
|
|
@@ -184,6 +184,23 @@ impl Default for SpendCap {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/// Why a budgeted request was refused.
|
|
188
|
+
///
|
|
189
|
+
/// The two are kept apart because the correct answer to the caller differs.
|
|
190
|
+
/// `Exhausted` is a wait — the window resets and the same request succeeds.
|
|
191
|
+
/// `Unpriceable` is a deployment fact: the catalog has no price for this target,
|
|
192
|
+
/// so the cap cannot be enforced against it and no amount of retrying changes
|
|
193
|
+
/// that. Collapsing them into one 429 would tell an operator to wait for a
|
|
194
|
+
/// condition that never clears.
|
|
195
|
+
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
196
|
+
pub enum BudgetRefusal {
|
|
197
|
+
/// The window's spend has reached the cap. Carries the time until reset.
|
|
198
|
+
Exhausted(Duration),
|
|
199
|
+
/// The catalog cannot price this target, and the identity has not opted in
|
|
200
|
+
/// to running unpriced under a cap.
|
|
201
|
+
Unpriceable,
|
|
202
|
+
}
|
|
203
|
+
|
|
187
204
|
impl SpendCap {
|
|
188
205
|
/// A cap governing this instance only.
|
|
189
206
|
pub fn in_memory() -> Self {
|
|
@@ -206,28 +223,65 @@ impl SpendCap {
|
|
|
206
223
|
)
|
|
207
224
|
}
|
|
208
225
|
|
|
209
|
-
/// A no-op when the identity carries no budget.
|
|
210
|
-
///
|
|
211
|
-
|
|
226
|
+
/// A no-op when the identity carries no budget.
|
|
227
|
+
///
|
|
228
|
+
/// Two ways a budgeted request is refused, and they are not the same event:
|
|
229
|
+
/// [`BudgetRefusal::Exhausted`] is temporary and clears at the window reset;
|
|
230
|
+
/// [`BudgetRefusal::Unpriceable`] never clears on its own, because the
|
|
231
|
+
/// catalog has no price for the target and retrying changes nothing.
|
|
232
|
+
pub async fn check(
|
|
233
|
+
&self,
|
|
234
|
+
identity: &crate::gateway::auth::Identity,
|
|
235
|
+
provider: &str,
|
|
236
|
+
model: &str,
|
|
237
|
+
) -> Result<(), BudgetRefusal> {
|
|
212
238
|
// `budget_usd: 0` freezes the key rather than unlimiting it: an
|
|
213
239
|
// operator typing zero means "spend nothing", and the opposite reading
|
|
214
240
|
// is the expensive one to be wrong about.
|
|
215
241
|
let Some(budget) = identity.budget_usd else {
|
|
216
242
|
return Ok(());
|
|
217
243
|
};
|
|
244
|
+
|
|
245
|
+
// A response the catalog cannot price is spend the ledger never sees.
|
|
246
|
+
// Allowing it under a cap does not merely lose one charge — it makes the
|
|
247
|
+
// cap stop binding for every later request too, silently. Refusing is the
|
|
248
|
+
// only outcome that keeps "a budget is set" and "the budget is enforced"
|
|
249
|
+
// the same statement.
|
|
250
|
+
if !crate::cost::is_priceable(provider, model) && !identity.budget_allow_unpriced {
|
|
251
|
+
return Err(BudgetRefusal::Unpriceable);
|
|
252
|
+
}
|
|
253
|
+
|
|
218
254
|
let window = Self::window(identity);
|
|
219
255
|
if self.store.spent(&identity.tenant, window).await >= budget {
|
|
220
|
-
return Err(window_reset(window));
|
|
256
|
+
return Err(BudgetRefusal::Exhausted(window_reset(window)));
|
|
221
257
|
}
|
|
222
258
|
Ok(())
|
|
223
259
|
}
|
|
224
260
|
|
|
261
|
+
/// Whether this request is about to run unpriced under a cap.
|
|
262
|
+
///
|
|
263
|
+
/// True only when the operator opted in. Callers report it so an accepted
|
|
264
|
+
/// risk stays measurable instead of becoming an assumption.
|
|
265
|
+
#[must_use]
|
|
266
|
+
pub fn is_unpriced_under_cap(
|
|
267
|
+
identity: &crate::gateway::auth::Identity,
|
|
268
|
+
provider: &str,
|
|
269
|
+
model: &str,
|
|
270
|
+
) -> bool {
|
|
271
|
+
identity.budget_usd.is_some() && !crate::cost::is_priceable(provider, model)
|
|
272
|
+
}
|
|
273
|
+
|
|
225
274
|
/// Charge a completed response.
|
|
226
275
|
///
|
|
227
|
-
/// `None` means the catalog could not price the model
|
|
276
|
+
/// `None` means the catalog could not price the model, and that spend is
|
|
228
277
|
/// **not** recorded — charging zero would quietly let an unpriced model run
|
|
229
|
-
/// forever under a budget.
|
|
230
|
-
///
|
|
278
|
+
/// forever under a budget.
|
|
279
|
+
///
|
|
280
|
+
/// This used to be the whole story, and it was a hole: the cap silently
|
|
281
|
+
/// stopped binding and the only signal was a `null` in a response body an
|
|
282
|
+
/// operator had to notice. [`SpendCap::check`] now refuses unpriceable
|
|
283
|
+
/// targets under a budget before they run, so reaching here with `None`
|
|
284
|
+
/// means the operator set `budget_allow_unpriced` and accepted it.
|
|
231
285
|
pub async fn record(&self, identity: &crate::gateway::auth::Identity, usd: Option<f64>) {
|
|
232
286
|
let Some(usd) = usd.filter(|u| *u > 0.0) else {
|
|
233
287
|
return;
|
|
@@ -280,27 +334,115 @@ mod tests {
|
|
|
280
334
|
tpm: None,
|
|
281
335
|
budget_usd: Some(budget),
|
|
282
336
|
budget_window_secs: Some(3600),
|
|
337
|
+
budget_allow_unpriced: false,
|
|
283
338
|
}
|
|
284
339
|
}
|
|
285
340
|
|
|
341
|
+
/// A target the catalog prices. Asserted rather than assumed: if the catalog
|
|
342
|
+
/// stops pricing it, these tests must fail loudly rather than quietly start
|
|
343
|
+
/// exercising the unpriceable path instead of the budget path.
|
|
344
|
+
const PRICED: (&str, &str) = ("anthropic", "claude-sonnet-4-6");
|
|
345
|
+
|
|
346
|
+
/// A model the catalog does not price. Deliberately implausible so it cannot
|
|
347
|
+
/// start being priced by a catalog refresh and quietly neuter these tests.
|
|
348
|
+
const UNPRICED: &str = "definitely-not-a-model-xyz";
|
|
349
|
+
|
|
286
350
|
#[tokio::test]
|
|
287
351
|
async fn a_budget_trips_once_the_window_is_spent() {
|
|
288
352
|
let cap = SpendCap::in_memory();
|
|
289
353
|
let acme = capped("acme", 1.0);
|
|
290
354
|
|
|
291
|
-
assert!(
|
|
355
|
+
assert!(
|
|
356
|
+
crate::cost::is_priceable(PRICED.0, PRICED.1),
|
|
357
|
+
"test fixture must be priced or this tests the wrong path"
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
let (p, m) = PRICED;
|
|
361
|
+
assert!(cap.check(&acme, p, m).await.is_ok(), "under budget admits");
|
|
292
362
|
cap.record(&acme, Some(0.75)).await;
|
|
293
|
-
assert!(cap.check(&acme).await.is_ok(), "still under budget");
|
|
363
|
+
assert!(cap.check(&acme, p, m).await.is_ok(), "still under budget");
|
|
294
364
|
cap.record(&acme, Some(0.30)).await;
|
|
295
365
|
|
|
296
|
-
|
|
366
|
+
match cap.check(&acme, p, m).await {
|
|
367
|
+
Err(BudgetRefusal::Exhausted(retry)) => assert!(
|
|
368
|
+
retry > Duration::ZERO,
|
|
369
|
+
"rejection must say when to come back"
|
|
370
|
+
),
|
|
371
|
+
other => panic!("over budget must reject as Exhausted, got {other:?}"),
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Another tenant's ledger is its own.
|
|
375
|
+
assert!(cap.check(&capped("other", 1.0), p, m).await.is_ok());
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/// A target with no catalog price cannot be charged. Under a cap that is not
|
|
379
|
+
/// a lost charge, it is a cap that stops binding — so it must be refused, and
|
|
380
|
+
/// refused *differently* from being out of budget.
|
|
381
|
+
#[tokio::test]
|
|
382
|
+
async fn an_unpriceable_model_is_refused_under_a_budget() {
|
|
383
|
+
let cap = SpendCap::in_memory();
|
|
384
|
+
let acme = capped("acme", 100.0);
|
|
385
|
+
assert!(
|
|
386
|
+
!crate::cost::is_priceable("anthropic", UNPRICED),
|
|
387
|
+
"fixture must be unpriced or this tests nothing"
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
let refusal = cap
|
|
391
|
+
.check(&acme, "anthropic", UNPRICED)
|
|
392
|
+
.await
|
|
393
|
+
.expect_err("an unpriceable model under a cap must be refused");
|
|
394
|
+
assert_eq!(
|
|
395
|
+
refusal,
|
|
396
|
+
BudgetRefusal::Unpriceable,
|
|
397
|
+
"must not masquerade as Exhausted: retrying never clears this"
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// The budget is nowhere near spent — the refusal is about priceability.
|
|
401
|
+
assert!(cap.check(&acme, PRICED.0, PRICED.1).await.is_ok());
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/// Opting in is allowed, because a new model can outrun the catalog. It is
|
|
405
|
+
/// explicit, per-key, and defaults to off.
|
|
406
|
+
#[tokio::test]
|
|
407
|
+
async fn an_operator_can_opt_in_to_running_unpriced() {
|
|
408
|
+
let cap = SpendCap::in_memory();
|
|
409
|
+
let mut acme = capped("acme", 100.0);
|
|
410
|
+
acme.budget_allow_unpriced = true;
|
|
411
|
+
|
|
412
|
+
assert!(
|
|
413
|
+
cap.check(&acme, "anthropic", UNPRICED).await.is_ok(),
|
|
414
|
+
"explicit opt-in must admit"
|
|
415
|
+
);
|
|
297
416
|
assert!(
|
|
298
|
-
|
|
299
|
-
"
|
|
417
|
+
SpendCap::is_unpriced_under_cap(&acme, "anthropic", UNPRICED),
|
|
418
|
+
"and the caller must be able to see it, so the hole stays measurable"
|
|
300
419
|
);
|
|
420
|
+
assert!(
|
|
421
|
+
!SpendCap::is_unpriced_under_cap(&acme, PRICED.0, PRICED.1),
|
|
422
|
+
"a priced target is not a hole"
|
|
423
|
+
);
|
|
424
|
+
}
|
|
301
425
|
|
|
302
|
-
|
|
303
|
-
|
|
426
|
+
/// With no cap there is nothing to enforce, so priceability is irrelevant.
|
|
427
|
+
/// Refusing here would break every unbudgeted key the moment a model is new.
|
|
428
|
+
#[tokio::test]
|
|
429
|
+
async fn without_a_budget_an_unpriceable_model_is_fine() {
|
|
430
|
+
let cap = SpendCap::in_memory();
|
|
431
|
+
let free = crate::gateway::auth::Identity {
|
|
432
|
+
tenant: "free".into(),
|
|
433
|
+
tier: 0,
|
|
434
|
+
rpm: None,
|
|
435
|
+
tpm: None,
|
|
436
|
+
budget_usd: None,
|
|
437
|
+
budget_window_secs: None,
|
|
438
|
+
budget_allow_unpriced: false,
|
|
439
|
+
};
|
|
440
|
+
assert!(cap.check(&free, "anthropic", UNPRICED).await.is_ok());
|
|
441
|
+
assert!(!SpendCap::is_unpriced_under_cap(
|
|
442
|
+
&free,
|
|
443
|
+
"anthropic",
|
|
444
|
+
UNPRICED
|
|
445
|
+
));
|
|
304
446
|
}
|
|
305
447
|
|
|
306
448
|
#[tokio::test]
|
|
@@ -308,7 +450,10 @@ mod tests {
|
|
|
308
450
|
let cap = SpendCap::in_memory();
|
|
309
451
|
let frozen = capped("frozen", 0.0);
|
|
310
452
|
assert!(
|
|
311
|
-
|
|
453
|
+
matches!(
|
|
454
|
+
cap.check(&frozen, PRICED.0, PRICED.1).await,
|
|
455
|
+
Err(BudgetRefusal::Exhausted(_))
|
|
456
|
+
),
|
|
312
457
|
"budget_usd: 0 must mean spend nothing, not spend anything"
|
|
313
458
|
);
|
|
314
459
|
}
|
|
@@ -323,10 +468,11 @@ mod tests {
|
|
|
323
468
|
tpm: None,
|
|
324
469
|
budget_usd: None,
|
|
325
470
|
budget_window_secs: None,
|
|
471
|
+
budget_allow_unpriced: false,
|
|
326
472
|
};
|
|
327
473
|
for _ in 0..100 {
|
|
328
474
|
cap.record(&uncapped, Some(1_000.0)).await;
|
|
329
|
-
assert!(cap.check(&uncapped).await.is_ok());
|
|
475
|
+
assert!(cap.check(&uncapped, PRICED.0, PRICED.1).await.is_ok());
|
|
330
476
|
}
|
|
331
477
|
|
|
332
478
|
// An unpriceable response cannot be charged; it must not read as free
|
|
@@ -337,7 +483,7 @@ mod tests {
|
|
|
337
483
|
cap.store.spent("acme", Duration::from_secs(3600)).await,
|
|
338
484
|
0.0
|
|
339
485
|
);
|
|
340
|
-
assert!(cap.check(&acme).await.is_ok());
|
|
486
|
+
assert!(cap.check(&acme, PRICED.0, PRICED.1).await.is_ok());
|
|
341
487
|
}
|
|
342
488
|
|
|
343
489
|
#[tokio::test(start_paused = true)]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|