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.
Files changed (189) hide show
  1. {llmshim-0.5.0 → llmshim-0.6.0}/Cargo.lock +1 -1
  2. {llmshim-0.5.0 → llmshim-0.6.0}/Cargo.toml +1 -1
  3. {llmshim-0.5.0 → llmshim-0.6.0}/PKG-INFO +1 -1
  4. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/proxy/scaling.md +24 -3
  5. {llmshim-0.5.0 → llmshim-0.6.0}/src/cost.rs +8 -0
  6. {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/auth.rs +11 -0
  7. {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/http.rs +56 -5
  8. {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/metrics.rs +4 -0
  9. {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/quota.rs +163 -17
  10. {llmshim-0.5.0 → llmshim-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {llmshim-0.5.0 → llmshim-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  12. {llmshim-0.5.0 → llmshim-0.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  13. {llmshim-0.5.0 → llmshim-0.6.0}/.github/workflows/catalog-refresh.yml +0 -0
  14. {llmshim-0.5.0 → llmshim-0.6.0}/.github/workflows/pages.yml +0 -0
  15. {llmshim-0.5.0 → llmshim-0.6.0}/.github/workflows/release.yml +0 -0
  16. {llmshim-0.5.0 → llmshim-0.6.0}/.gitignore +0 -0
  17. {llmshim-0.5.0 → llmshim-0.6.0}/CLAUDE.md +0 -0
  18. {llmshim-0.5.0 → llmshim-0.6.0}/CODE_OF_CONDUCT.md +0 -0
  19. {llmshim-0.5.0 → llmshim-0.6.0}/CONTRIBUTING.md +0 -0
  20. {llmshim-0.5.0 → llmshim-0.6.0}/LICENSE-APACHE +0 -0
  21. {llmshim-0.5.0 → llmshim-0.6.0}/LICENSE-MIT +0 -0
  22. {llmshim-0.5.0 → llmshim-0.6.0}/NOTICE +0 -0
  23. {llmshim-0.5.0 → llmshim-0.6.0}/README.md +0 -0
  24. {llmshim-0.5.0 → llmshim-0.6.0}/SECURITY.md +0 -0
  25. {llmshim-0.5.0 → llmshim-0.6.0}/benchmarks/bench.rs +0 -0
  26. {llmshim-0.5.0 → llmshim-0.6.0}/benchmarks/bench_python.py +0 -0
  27. {llmshim-0.5.0 → llmshim-0.6.0}/benchmarks/gateway_loadtest.rs +0 -0
  28. {llmshim-0.5.0 → llmshim-0.6.0}/benchmarks/loadtest.rs +0 -0
  29. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/Cargo.toml +0 -0
  30. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/LICENSE-APACHE +0 -0
  31. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/LICENSE-MIT +0 -0
  32. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/README.md +0 -0
  33. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/data/LICENSE.models.dev +0 -0
  34. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/data/README.md +0 -0
  35. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/data/models.dev.json +0 -0
  36. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/aliases.rs +0 -0
  37. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/builtin.rs +0 -0
  38. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/capabilities.rs +0 -0
  39. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/lib.rs +0 -0
  40. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/merge.rs +0 -0
  41. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/parse.rs +0 -0
  42. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/refresh.rs +0 -0
  43. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/src/types.rs +0 -0
  44. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/tests/catalog.rs +0 -0
  45. {llmshim-0.5.0 → llmshim-0.6.0}/crates/llmshim-catalog/tests/refresh.rs +0 -0
  46. {llmshim-0.5.0 → llmshim-0.6.0}/docs/.gitignore +0 -0
  47. {llmshim-0.5.0 → llmshim-0.6.0}/docs/book.toml +0 -0
  48. {llmshim-0.5.0 → llmshim-0.6.0}/docs/mermaid-init.js +0 -0
  49. {llmshim-0.5.0 → llmshim-0.6.0}/docs/mermaid.min.js +0 -0
  50. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/SUMMARY.md +0 -0
  51. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/contracts.md +0 -0
  52. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/conversations.md +0 -0
  53. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/portability.md +0 -0
  54. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/routing.md +0 -0
  55. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/concepts/translation-flow.md +0 -0
  56. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/caching.md +0 -0
  57. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/capabilities.md +0 -0
  58. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/fallbacks.md +0 -0
  59. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/images.md +0 -0
  60. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/native-controls.md +0 -0
  61. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/reasoning.md +0 -0
  62. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/schemas.md +0 -0
  63. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/streaming.md +0 -0
  64. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/guides/tools.md +0 -0
  65. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/introduction.md +0 -0
  66. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/proxy/deployment.md +0 -0
  67. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/proxy/http-api.md +0 -0
  68. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/proxy/native-apis.md +0 -0
  69. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/api.md +0 -0
  70. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/cli.md +0 -0
  71. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/configuration.md +0 -0
  72. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/errors.md +0 -0
  73. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/models.md +0 -0
  74. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/providers.md +0 -0
  75. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/request-fields.md +0 -0
  76. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/reference/surfaces.md +0 -0
  77. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/choose.md +0 -0
  78. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/cli.md +0 -0
  79. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/clients.md +0 -0
  80. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/configure.md +0 -0
  81. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/proxy.md +0 -0
  82. {llmshim-0.5.0 → llmshim-0.6.0}/docs/src/start/rust.md +0 -0
  83. {llmshim-0.5.0 → llmshim-0.6.0}/examples/chat.rs +0 -0
  84. {llmshim-0.5.0 → llmshim-0.6.0}/examples/stream.rs +0 -0
  85. {llmshim-0.5.0 → llmshim-0.6.0}/llmshim/__init__.py +0 -0
  86. {llmshim-0.5.0 → llmshim-0.6.0}/llmshim/_client.py +0 -0
  87. {llmshim-0.5.0 → llmshim-0.6.0}/llmshim/_server.py +0 -0
  88. {llmshim-0.5.0 → llmshim-0.6.0}/llmshim/types.py +0 -0
  89. {llmshim-0.5.0 → llmshim-0.6.0}/pyproject.toml +0 -0
  90. {llmshim-0.5.0 → llmshim-0.6.0}/src/breaker.rs +0 -0
  91. {llmshim-0.5.0 → llmshim-0.6.0}/src/cache.rs +0 -0
  92. {llmshim-0.5.0 → llmshim-0.6.0}/src/cli.rs +0 -0
  93. {llmshim-0.5.0 → llmshim-0.6.0}/src/client.rs +0 -0
  94. {llmshim-0.5.0 → llmshim-0.6.0}/src/config.rs +0 -0
  95. {llmshim-0.5.0 → llmshim-0.6.0}/src/env.rs +0 -0
  96. {llmshim-0.5.0 → llmshim-0.6.0}/src/error/normalize.rs +0 -0
  97. {llmshim-0.5.0 → llmshim-0.6.0}/src/error.rs +0 -0
  98. {llmshim-0.5.0 → llmshim-0.6.0}/src/fallback.rs +0 -0
  99. {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/distributed.rs +0 -0
  100. {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/idempotency.rs +0 -0
  101. {llmshim-0.5.0 → llmshim-0.6.0}/src/gateway/mod.rs +0 -0
  102. {llmshim-0.5.0 → llmshim-0.6.0}/src/lib.rs +0 -0
  103. {llmshim-0.5.0 → llmshim-0.6.0}/src/log.rs +0 -0
  104. {llmshim-0.5.0 → llmshim-0.6.0}/src/main.rs +0 -0
  105. {llmshim-0.5.0 → llmshim-0.6.0}/src/models.rs +0 -0
  106. {llmshim-0.5.0 → llmshim-0.6.0}/src/provider.rs +0 -0
  107. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/anthropic.rs +0 -0
  108. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/anthropic_reasoning.rs +0 -0
  109. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/anthropic_signature.rs +0 -0
  110. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/chatgpt/auth.rs +0 -0
  111. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/chatgpt/mod.rs +0 -0
  112. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/chatgpt/streaming.rs +0 -0
  113. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/gemini.rs +0 -0
  114. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/mod.rs +0 -0
  115. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/openai.rs +0 -0
  116. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/openai_compat.rs +0 -0
  117. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/openrouter.rs +0 -0
  118. {llmshim-0.5.0 → llmshim-0.6.0}/src/providers/xai.rs +0 -0
  119. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/convert.rs +0 -0
  120. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/error.rs +0 -0
  121. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/handlers.rs +0 -0
  122. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/health.rs +0 -0
  123. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/mod.rs +0 -0
  124. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/ratelimit.rs +0 -0
  125. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/types.rs +0 -0
  126. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/wire/mod.rs +0 -0
  127. {llmshim-0.5.0 → llmshim-0.6.0}/src/proxy/wire/receipts.rs +0 -0
  128. {llmshim-0.5.0 → llmshim-0.6.0}/src/reasoning/normalize.rs +0 -0
  129. {llmshim-0.5.0 → llmshim-0.6.0}/src/reasoning.rs +0 -0
  130. {llmshim-0.5.0 → llmshim-0.6.0}/src/router.rs +0 -0
  131. {llmshim-0.5.0 → llmshim-0.6.0}/src/schema/memo.rs +0 -0
  132. {llmshim-0.5.0 → llmshim-0.6.0}/src/schema/mod.rs +0 -0
  133. {llmshim-0.5.0 → llmshim-0.6.0}/src/schema/validate.rs +0 -0
  134. {llmshim-0.5.0 → llmshim-0.6.0}/src/schema/walk.rs +0 -0
  135. {llmshim-0.5.0 → llmshim-0.6.0}/src/shim.rs +0 -0
  136. {llmshim-0.5.0 → llmshim-0.6.0}/src/streaming.rs +0 -0
  137. {llmshim-0.5.0 → llmshim-0.6.0}/src/toolcall/streaming.rs +0 -0
  138. {llmshim-0.5.0 → llmshim-0.6.0}/src/toolcall.rs +0 -0
  139. {llmshim-0.5.0 → llmshim-0.6.0}/src/usage.rs +0 -0
  140. {llmshim-0.5.0 → llmshim-0.6.0}/src/vision.rs +0 -0
  141. {llmshim-0.5.0 → llmshim-0.6.0}/tests/fixtures/chatgpt-red.png +0 -0
  142. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration.rs +0 -0
  143. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_chatgpt.rs +0 -0
  144. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_chatgpt_proxy.rs +0 -0
  145. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_current_models.rs +0 -0
  146. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_fallback.rs +0 -0
  147. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_gemini.rs +0 -0
  148. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_gemini_tools.rs +0 -0
  149. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_long_context.rs +0 -0
  150. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_multimodel.rs +0 -0
  151. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_openrouter.rs +0 -0
  152. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_proxy.rs +0 -0
  153. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_sglang.rs +0 -0
  154. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_thinking.rs +0 -0
  155. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_tool_roundtrip.rs +0 -0
  156. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_vision.rs +0 -0
  157. {llmshim-0.5.0 → llmshim-0.6.0}/tests/integration_xai.rs +0 -0
  158. {llmshim-0.5.0 → llmshim-0.6.0}/tests/support/completion_status.rs +0 -0
  159. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_advertised_models.rs +0 -0
  160. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_anthropic.rs +0 -0
  161. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_cache.rs +0 -0
  162. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_chatgpt.rs +0 -0
  163. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_cli.rs +0 -0
  164. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_fable.rs +0 -0
  165. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_fallback.rs +0 -0
  166. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_fast_mode.rs +0 -0
  167. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_gemini.rs +0 -0
  168. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_log.rs +0 -0
  169. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_models.rs +0 -0
  170. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_multimodel.rs +0 -0
  171. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_openai.rs +0 -0
  172. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_openai_compat.rs +0 -0
  173. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_openrouter.rs +0 -0
  174. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_provider_contracts.rs +0 -0
  175. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_proxy.rs +0 -0
  176. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_proxy_convert.rs +0 -0
  177. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_reasoning.rs +0 -0
  178. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_reasoning_profile.rs +0 -0
  179. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_router.rs +0 -0
  180. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_schema.rs +0 -0
  181. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_shim.rs +0 -0
  182. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_signature.rs +0 -0
  183. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_sse.rs +0 -0
  184. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_toolcall.rs +0 -0
  185. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_tools.rs +0 -0
  186. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_usage.rs +0 -0
  187. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_vision.rs +0 -0
  188. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_wire.rs +0 -0
  189. {llmshim-0.5.0 → llmshim-0.6.0}/tests/unit_xai.rs +0 -0
@@ -1180,7 +1180,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
1180
1180
 
1181
1181
  [[package]]
1182
1182
  name = "llmshim"
1183
- version = "0.5.0"
1183
+ version = "0.6.0"
1184
1184
  dependencies = [
1185
1185
  "async-stream",
1186
1186
  "async-trait",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "llmshim"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  edition = "2021"
5
5
  description = "Blazing fast LLM API translation layer in pure Rust"
6
6
  license = "MIT OR Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llmshim
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -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. A response the catalog
111
- cannot price (`cost_usd: null`) is **not** charged — recording zero would let an
112
- unpriced model run forever under a budget, so a hard cap requires priced models.
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
- match self.spend.check(identity).await {
330
- Ok(()) => Ok(()),
331
- Err(retry) => {
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.enforce_budget(&identity, &provider_name).await?;
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
- if let Err(e) = state.enforce_budget(&identity, &provider_name).await {
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. `Err(retry_after)` once the
210
- /// window's spend has reached the cap, where the wait is the window reset.
211
- pub async fn check(&self, identity: &crate::gateway::auth::Identity) -> Result<(), Duration> {
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. That spend is
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. Operators who need a hard cap must ensure their
230
- /// models are priced; `cost_usd: null` in the response is the signal.
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!(cap.check(&acme).await.is_ok(), "under budget admits");
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
- let retry = cap.check(&acme).await.expect_err("over budget must reject");
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
- retry > Duration::ZERO,
299
- "rejection must say when to come back"
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
- // Another tenant's ledger is its own.
303
- assert!(cap.check(&capped("other", 1.0)).await.is_ok());
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
- cap.check(&frozen).await.is_err(),
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