llmshim 0.7.0__tar.gz → 0.7.2__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 (191) hide show
  1. llmshim-0.7.2/.github/scripts/npm-verify-served.sh +37 -0
  2. {llmshim-0.7.0 → llmshim-0.7.2}/.github/workflows/release.yml +18 -0
  3. {llmshim-0.7.0 → llmshim-0.7.2}/CLAUDE.md +10 -2
  4. {llmshim-0.7.0 → llmshim-0.7.2}/Cargo.lock +1 -1
  5. {llmshim-0.7.0 → llmshim-0.7.2}/Cargo.toml +1 -1
  6. {llmshim-0.7.0 → llmshim-0.7.2}/PKG-INFO +1 -1
  7. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/routing.md +22 -0
  8. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/caching.md +8 -1
  9. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/models.md +4 -1
  10. {llmshim-0.7.0 → llmshim-0.7.2}/src/cache.rs +55 -0
  11. {llmshim-0.7.0 → llmshim-0.7.2}/src/client.rs +76 -1
  12. {llmshim-0.7.0 → llmshim-0.7.2}/src/fallback.rs +3 -6
  13. {llmshim-0.7.0 → llmshim-0.7.2}/src/lib.rs +11 -14
  14. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/anthropic.rs +11 -0
  15. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/gemini.rs +6 -0
  16. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/openai.rs +7 -0
  17. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/openai_compat.rs +10 -0
  18. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/openrouter.rs +8 -0
  19. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/xai.rs +7 -0
  20. {llmshim-0.7.0 → llmshim-0.7.2}/src/router.rs +43 -7
  21. llmshim-0.7.2/tests/unit_client_breaker.rs +118 -0
  22. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_provider_contracts.rs +53 -0
  23. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_router.rs +60 -0
  24. {llmshim-0.7.0 → llmshim-0.7.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  25. {llmshim-0.7.0 → llmshim-0.7.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {llmshim-0.7.0 → llmshim-0.7.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {llmshim-0.7.0 → llmshim-0.7.2}/.github/workflows/catalog-refresh.yml +0 -0
  28. {llmshim-0.7.0 → llmshim-0.7.2}/.github/workflows/pages.yml +0 -0
  29. {llmshim-0.7.0 → llmshim-0.7.2}/.gitignore +0 -0
  30. {llmshim-0.7.0 → llmshim-0.7.2}/CODE_OF_CONDUCT.md +0 -0
  31. {llmshim-0.7.0 → llmshim-0.7.2}/CONTRIBUTING.md +0 -0
  32. {llmshim-0.7.0 → llmshim-0.7.2}/LICENSE-APACHE +0 -0
  33. {llmshim-0.7.0 → llmshim-0.7.2}/LICENSE-MIT +0 -0
  34. {llmshim-0.7.0 → llmshim-0.7.2}/NOTICE +0 -0
  35. {llmshim-0.7.0 → llmshim-0.7.2}/README.md +0 -0
  36. {llmshim-0.7.0 → llmshim-0.7.2}/SECURITY.md +0 -0
  37. {llmshim-0.7.0 → llmshim-0.7.2}/benchmarks/bench.rs +0 -0
  38. {llmshim-0.7.0 → llmshim-0.7.2}/benchmarks/bench_python.py +0 -0
  39. {llmshim-0.7.0 → llmshim-0.7.2}/benchmarks/gateway_loadtest.rs +0 -0
  40. {llmshim-0.7.0 → llmshim-0.7.2}/benchmarks/loadtest.rs +0 -0
  41. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/Cargo.toml +0 -0
  42. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/LICENSE-APACHE +0 -0
  43. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/LICENSE-MIT +0 -0
  44. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/README.md +0 -0
  45. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/data/LICENSE.models.dev +0 -0
  46. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/data/README.md +0 -0
  47. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/data/models.dev.json +0 -0
  48. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/aliases.rs +0 -0
  49. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/builtin.rs +0 -0
  50. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/capabilities.rs +0 -0
  51. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/lib.rs +0 -0
  52. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/merge.rs +0 -0
  53. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/parse.rs +0 -0
  54. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/refresh.rs +0 -0
  55. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/types.rs +0 -0
  56. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/tests/catalog.rs +0 -0
  57. {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/tests/refresh.rs +0 -0
  58. {llmshim-0.7.0 → llmshim-0.7.2}/docs/.gitignore +0 -0
  59. {llmshim-0.7.0 → llmshim-0.7.2}/docs/book.toml +0 -0
  60. {llmshim-0.7.0 → llmshim-0.7.2}/docs/mermaid-init.js +0 -0
  61. {llmshim-0.7.0 → llmshim-0.7.2}/docs/mermaid.min.js +0 -0
  62. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/SUMMARY.md +0 -0
  63. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/contracts.md +0 -0
  64. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/conversations.md +0 -0
  65. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/portability.md +0 -0
  66. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/translation-flow.md +0 -0
  67. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/capabilities.md +0 -0
  68. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/fallbacks.md +0 -0
  69. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/images.md +0 -0
  70. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/native-controls.md +0 -0
  71. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/reasoning.md +0 -0
  72. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/schemas.md +0 -0
  73. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/streaming.md +0 -0
  74. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/tools.md +0 -0
  75. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/introduction.md +0 -0
  76. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/proxy/deployment.md +0 -0
  77. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/proxy/http-api.md +0 -0
  78. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/proxy/native-apis.md +0 -0
  79. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/proxy/scaling.md +0 -0
  80. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/api.md +0 -0
  81. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/cli.md +0 -0
  82. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/configuration.md +0 -0
  83. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/errors.md +0 -0
  84. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/providers.md +0 -0
  85. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/request-fields.md +0 -0
  86. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/surfaces.md +0 -0
  87. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/choose.md +0 -0
  88. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/cli.md +0 -0
  89. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/clients.md +0 -0
  90. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/configure.md +0 -0
  91. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/proxy.md +0 -0
  92. {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/rust.md +0 -0
  93. {llmshim-0.7.0 → llmshim-0.7.2}/examples/chat.rs +0 -0
  94. {llmshim-0.7.0 → llmshim-0.7.2}/examples/stream.rs +0 -0
  95. {llmshim-0.7.0 → llmshim-0.7.2}/llmshim/__init__.py +0 -0
  96. {llmshim-0.7.0 → llmshim-0.7.2}/llmshim/_client.py +0 -0
  97. {llmshim-0.7.0 → llmshim-0.7.2}/llmshim/_server.py +0 -0
  98. {llmshim-0.7.0 → llmshim-0.7.2}/llmshim/types.py +0 -0
  99. {llmshim-0.7.0 → llmshim-0.7.2}/pyproject.toml +0 -0
  100. {llmshim-0.7.0 → llmshim-0.7.2}/src/breaker.rs +0 -0
  101. {llmshim-0.7.0 → llmshim-0.7.2}/src/cli.rs +0 -0
  102. {llmshim-0.7.0 → llmshim-0.7.2}/src/config.rs +0 -0
  103. {llmshim-0.7.0 → llmshim-0.7.2}/src/cost.rs +0 -0
  104. {llmshim-0.7.0 → llmshim-0.7.2}/src/env.rs +0 -0
  105. {llmshim-0.7.0 → llmshim-0.7.2}/src/error/normalize.rs +0 -0
  106. {llmshim-0.7.0 → llmshim-0.7.2}/src/error.rs +0 -0
  107. {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/auth.rs +0 -0
  108. {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/distributed.rs +0 -0
  109. {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/http.rs +0 -0
  110. {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/idempotency.rs +0 -0
  111. {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/metrics.rs +0 -0
  112. {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/mod.rs +0 -0
  113. {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/quota.rs +0 -0
  114. {llmshim-0.7.0 → llmshim-0.7.2}/src/log.rs +0 -0
  115. {llmshim-0.7.0 → llmshim-0.7.2}/src/main.rs +0 -0
  116. {llmshim-0.7.0 → llmshim-0.7.2}/src/models.rs +0 -0
  117. {llmshim-0.7.0 → llmshim-0.7.2}/src/provider.rs +0 -0
  118. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/anthropic_reasoning.rs +0 -0
  119. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/anthropic_signature.rs +0 -0
  120. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/chatgpt/auth.rs +0 -0
  121. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/chatgpt/mod.rs +0 -0
  122. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/chatgpt/streaming.rs +0 -0
  123. {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/mod.rs +0 -0
  124. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/convert.rs +0 -0
  125. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/error.rs +0 -0
  126. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/handlers.rs +0 -0
  127. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/health.rs +0 -0
  128. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/mod.rs +0 -0
  129. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/ratelimit.rs +0 -0
  130. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/types.rs +0 -0
  131. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/wire/mod.rs +0 -0
  132. {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/wire/receipts.rs +0 -0
  133. {llmshim-0.7.0 → llmshim-0.7.2}/src/reasoning/normalize.rs +0 -0
  134. {llmshim-0.7.0 → llmshim-0.7.2}/src/reasoning.rs +0 -0
  135. {llmshim-0.7.0 → llmshim-0.7.2}/src/schema/memo.rs +0 -0
  136. {llmshim-0.7.0 → llmshim-0.7.2}/src/schema/mod.rs +0 -0
  137. {llmshim-0.7.0 → llmshim-0.7.2}/src/schema/validate.rs +0 -0
  138. {llmshim-0.7.0 → llmshim-0.7.2}/src/schema/walk.rs +0 -0
  139. {llmshim-0.7.0 → llmshim-0.7.2}/src/shim.rs +0 -0
  140. {llmshim-0.7.0 → llmshim-0.7.2}/src/streaming.rs +0 -0
  141. {llmshim-0.7.0 → llmshim-0.7.2}/src/toolcall/streaming.rs +0 -0
  142. {llmshim-0.7.0 → llmshim-0.7.2}/src/toolcall.rs +0 -0
  143. {llmshim-0.7.0 → llmshim-0.7.2}/src/usage.rs +0 -0
  144. {llmshim-0.7.0 → llmshim-0.7.2}/src/vision.rs +0 -0
  145. {llmshim-0.7.0 → llmshim-0.7.2}/tests/fixtures/chatgpt-red.png +0 -0
  146. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration.rs +0 -0
  147. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_chatgpt.rs +0 -0
  148. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_chatgpt_proxy.rs +0 -0
  149. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_current_models.rs +0 -0
  150. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_fallback.rs +0 -0
  151. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_gemini.rs +0 -0
  152. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_gemini_tools.rs +0 -0
  153. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_long_context.rs +0 -0
  154. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_multimodel.rs +0 -0
  155. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_openrouter.rs +0 -0
  156. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_proxy.rs +0 -0
  157. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_sglang.rs +0 -0
  158. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_thinking.rs +0 -0
  159. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_tool_roundtrip.rs +0 -0
  160. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_vision.rs +0 -0
  161. {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_xai.rs +0 -0
  162. {llmshim-0.7.0 → llmshim-0.7.2}/tests/support/completion_status.rs +0 -0
  163. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_advertised_models.rs +0 -0
  164. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_anthropic.rs +0 -0
  165. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_cache.rs +0 -0
  166. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_chatgpt.rs +0 -0
  167. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_cli.rs +0 -0
  168. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_fable.rs +0 -0
  169. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_fallback.rs +0 -0
  170. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_fast_mode.rs +0 -0
  171. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_gemini.rs +0 -0
  172. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_log.rs +0 -0
  173. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_models.rs +0 -0
  174. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_multimodel.rs +0 -0
  175. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_openai.rs +0 -0
  176. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_openai_compat.rs +0 -0
  177. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_openrouter.rs +0 -0
  178. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_proxy.rs +0 -0
  179. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_proxy_convert.rs +0 -0
  180. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_reasoning.rs +0 -0
  181. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_reasoning_profile.rs +0 -0
  182. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_schema.rs +0 -0
  183. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_shim.rs +0 -0
  184. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_signature.rs +0 -0
  185. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_sse.rs +0 -0
  186. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_toolcall.rs +0 -0
  187. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_tools.rs +0 -0
  188. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_usage.rs +0 -0
  189. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_vision.rs +0 -0
  190. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_wire.rs +0 -0
  191. {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_xai.rs +0 -0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # `npm publish` exiting 0 means the registry *accepted* the tarball, not that it
3
+ # serves it. 0.7.1 was accepted, logged to Sigstore, answered "may take a few
4
+ # minutes to become available" — and half an hour later still served nothing,
5
+ # behind a green job. So a publish is not done until `npm view` returns the
6
+ # version. Run from the package's directory, after its publish step.
7
+ #
8
+ # Bound: 20 polls, 30 s apart — about ten minutes. npm's "few minutes" has been
9
+ # minutes, never seconds, and a version still unserved after ten is stuck in
10
+ # npm's processing, which more waiting here will not fix.
11
+ #
12
+ # Compares `npm view`'s output to the version rather than its exit status: for a
13
+ # package that exists with a version that does not, npm's exit status has
14
+ # differed across releases. `--prefer-online` revalidates on every poll, so the
15
+ # first poll's packument — cached without the new version — cannot answer for
16
+ # the rest of the window.
17
+ set -euo pipefail
18
+
19
+ ATTEMPTS=20
20
+ INTERVAL=30
21
+ NAME=$(node -p "require('./package.json').name")
22
+ VER=$(node -p "require('./package.json').version")
23
+
24
+ for attempt in $(seq 1 "$ATTEMPTS"); do
25
+ served=$(npm view --prefer-online "$NAME@$VER" version 2>/dev/null || true)
26
+ if [ "$served" = "$VER" ]; then
27
+ echo "$NAME@$VER is served by the registry."
28
+ exit 0
29
+ fi
30
+ echo "poll $attempt/$ATTEMPTS: $NAME@$VER not served yet"
31
+ if [ "$attempt" -lt "$ATTEMPTS" ]; then
32
+ sleep "$INTERVAL"
33
+ fi
34
+ done
35
+
36
+ echo "::error::$NAME@$VER was accepted by npm but is still not served after $((ATTEMPTS * INTERVAL / 60)) minutes. The publish step exited 0 and its output carries the Sigstore provenance line, so the tarball is stuck in npm's processing, not missing from this run. Look at https://www.npmjs.com/package/$NAME?activeTab=versions and https://status.npmjs.org; once it appears, re-run this job — the publish step sees it and skips. If it never appears, npm will refuse a second publish of the same version and support has to release it."
37
+ exit 1
@@ -223,6 +223,9 @@ jobs:
223
223
  else
224
224
  npm publish --access public --provenance
225
225
  fi
226
+ - name: Verify npm serves llmshim-darwin-arm64
227
+ working-directory: clients/typescript/packages/llmshim-darwin-arm64
228
+ run: bash "$GITHUB_WORKSPACE/.github/scripts/npm-verify-served.sh"
226
229
  - name: Publish llmshim-darwin-x64
227
230
  working-directory: clients/typescript/packages/llmshim-darwin-x64
228
231
  run: |
@@ -234,6 +237,9 @@ jobs:
234
237
  else
235
238
  npm publish --access public --provenance
236
239
  fi
240
+ - name: Verify npm serves llmshim-darwin-x64
241
+ working-directory: clients/typescript/packages/llmshim-darwin-x64
242
+ run: bash "$GITHUB_WORKSPACE/.github/scripts/npm-verify-served.sh"
237
243
 
238
244
  npm-linux-x64-binary:
239
245
  needs: test
@@ -274,6 +280,9 @@ jobs:
274
280
  else
275
281
  npm publish --access public --provenance
276
282
  fi
283
+ - name: Verify npm serves llmshim-linux-x64
284
+ working-directory: clients/typescript/packages/llmshim-linux-x64
285
+ run: bash "$GITHUB_WORKSPACE/.github/scripts/npm-verify-served.sh"
277
286
 
278
287
  # Native arm64 runner (GA, free on public repos) — no cross-compilation needed.
279
288
  npm-linux-arm64-binary:
@@ -315,6 +324,9 @@ jobs:
315
324
  else
316
325
  npm publish --access public --provenance
317
326
  fi
327
+ - name: Verify npm serves llmshim-linux-arm64
328
+ working-directory: clients/typescript/packages/llmshim-linux-arm64
329
+ run: bash "$GITHUB_WORKSPACE/.github/scripts/npm-verify-served.sh"
318
330
 
319
331
  npm-windows-binary:
320
332
  needs: test
@@ -358,6 +370,10 @@ jobs:
358
370
  else
359
371
  npm publish --access public --provenance
360
372
  fi
373
+ - name: Verify npm serves llmshim-win32-x64
374
+ shell: bash
375
+ working-directory: clients/typescript/packages/llmshim-win32-x64
376
+ run: bash "$GITHUB_WORKSPACE/.github/scripts/npm-verify-served.sh"
361
377
 
362
378
  # --- Publish TypeScript client to npm ---
363
379
  # First release must be published manually once (npm's trusted-publisher UI
@@ -410,6 +426,8 @@ jobs:
410
426
  else
411
427
  npm publish --access public --provenance
412
428
  fi
429
+ - name: Verify npm serves the TypeScript client
430
+ run: bash "$GITHUB_WORKSPACE/.github/scripts/npm-verify-served.sh"
413
431
 
414
432
  # --- Publish Ruby client to RubyGems ---
415
433
  # Uses a "pending trusted publisher" — configurable on rubygems.org before
@@ -228,8 +228,16 @@ own: sliding failure window, open state, and a single half-open probe admitted
228
228
  after the cooldown. Config: `LLMSHIM_BREAKER_WINDOW_SECS` (60),
229
229
  `LLMSHIM_BREAKER_TRIP_THRESHOLD` (3; `0` disables), `LLMSHIM_BREAKER_COOLDOWN_SECS` (30).
230
230
 
231
- The breaker hangs on the `Router` (`Router::breaker()` / `with_breaker`). Every
232
- dispatch path *observes* outcomes so health accrues from ordinary traffic; only
231
+ The breaker hangs on the `Router` (`Router::breaker()` / `with_breaker`), but
232
+ the *counting* happens in `ShimClient`: `ShimClient::with_breaker` attaches one,
233
+ and `completion` / `stream` / `stream_owned` observe their final result exactly
234
+ once. The top-level entry points bind the router's breaker to the shared client
235
+ per call (`lib.rs::bound_client`), so `llmshim::completion`, `stream`,
236
+ `completion_with_fallback` and a caller that resolves its own provider and dials
237
+ `ShimClient` directly all feed the same breaker — the last one only if it opted
238
+ in with `ShimClient::new().with_breaker(router.breaker().clone())`; a bare
239
+ `ShimClient::new()` reports to nobody. Do not add a second `.observe` around a
240
+ client call: one call, one observation (`tests/unit_client_breaker.rs`). Only
233
241
  `fallback.rs` *refuses*, and it checks before every attempt rather than once per
234
242
  chain entry — the attempt that opens a circuit is usually the chain's own, so a
235
243
  per-entry check would still retry into a target it just watched die. A single-target call is still dispatched:
@@ -1180,7 +1180,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
1180
1180
 
1181
1181
  [[package]]
1182
1182
  name = "llmshim"
1183
- version = "0.7.0"
1183
+ version = "0.7.2"
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.7.0"
3
+ version = "0.7.2"
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.7.0
3
+ Version: 0.7.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -136,3 +136,25 @@ let router = llmshim::router::Router::from_env();
136
136
 
137
137
  Applications that manage secrets themselves can call `Router::from_env()`
138
138
  directly or construct a Router by registering provider implementations.
139
+
140
+ ## Catalog refresh is the daemon's default, not the embedder's
141
+
142
+ `Router::from_env()` also schedules one background fetch of the model catalog
143
+ (prices, context windows, capabilities). That is right for the proxy, which
144
+ starts once and runs for days. A program that starts many times a day, or runs
145
+ air-gapped, should build its router with
146
+ `Router::from_env_without_catalog_refresh()` — identical, except that
147
+ constructing it makes no network call — and refresh only when it decides to:
148
+
149
+ ```rust
150
+ let router = llmshim::router::Router::from_env_without_catalog_refresh();
151
+ if user_asked_for_it {
152
+ // Rides the caller's Tokio runtime; `None` when there is none, when
153
+ // LLMSHIM_CATALOG_OFFLINE=1 is set, or when the local catalog is invalid.
154
+ router.refresh_catalog_in_background();
155
+ }
156
+ ```
157
+
158
+ The offline router still resolves and prices every model in the vendored
159
+ snapshot, any earlier cached download, and the local override files; its data
160
+ is simply never newer than the disk.
@@ -22,7 +22,14 @@ llmshim translates the declaration into the provider's caching mechanism.
22
22
  }
23
23
  ```
24
24
 
25
- `upto_message` is a zero-based index into the supplied messages. Labels are
25
+ `upto_message` is a zero-based index into the `messages` array exactly as you
26
+ sent it — system and developer messages count, and each `role: "tool"` result
27
+ counts as its own entry — not into the provider-native array (Anthropic hoists
28
+ the system prompt out, so native numbering differs). A caller whose own message
29
+ model expands into more wire messages than it holds must remap before copying
30
+ an index across. On Anthropic an index past the end is rejected with a 400,
31
+ and one that lands too early silently caches less than intended; on every
32
+ other provider segments are parsed but neither checked nor placed. Labels are
26
33
  informational and are never used to guess prompt semantics. Keep stable sections
27
34
  before volatile sections. The proxy accepts the same top-level `x-cache` field;
28
35
  Python and Ruby provide a `cache=`/`cache:` keyword, Go has `ChatRequest.Cache`,
@@ -28,7 +28,10 @@ compile-time API for curated and historical facts.
28
28
 
29
29
  Startup reads the vendored floor, cached data, and local overrides without
30
30
  waiting for network. Refresh uses ETags and a 24-hour TTL; errors keep the old
31
- snapshot available. Offline mode uses only vendored and local layers. User
31
+ snapshot available. `Router::from_env()` schedules one such refresh;
32
+ `Router::from_env_without_catalog_refresh()` schedules none and leaves it to
33
+ `Router::refresh_catalog_in_background()`. Offline mode uses only vendored and
34
+ local layers. User
32
35
  overrides live in `~/.config/llmshim/models.toml`, project overrides in
33
36
  `.llmshim/models.toml`, and cached data in `~/.cache/llmshim/models.dev.json`.
34
37
  Local overrides win; verified builtin assertions win over models.dev data.
@@ -10,20 +10,75 @@ use std::collections::BTreeMap;
10
10
 
11
11
  pub const ANTHROPIC_BREAKPOINT_LIMIT: usize = 4;
12
12
 
13
+ /// How long the caller expects a prefix to stay byte-stable. Only the caller
14
+ /// knows; llmshim never infers it.
13
15
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14
16
  #[serde(rename_all = "lowercase")]
15
17
  pub enum Stability {
18
+ /// Stable across sessions — a one-hour breakpoint on Anthropic.
16
19
  Static,
20
+ /// Stable for this session — a five-minute breakpoint on Anthropic.
17
21
  Session,
22
+ /// The volatile tail. Places no marker; it exists so a caller can name
23
+ /// where stability ends.
18
24
  Turn,
19
25
  }
26
+
27
+ /// One caller-declared stability boundary: every message up to and including
28
+ /// `upto_message` is expected to stay byte-stable for as long as `stability`
29
+ /// says.
30
+ ///
31
+ /// **`upto_message` indexes the request's own `messages` array, exactly as the
32
+ /// caller sent it.** Zero-based, and every entry counts — `system` and
33
+ /// `developer` messages included, and each `role: "tool"` result as its own
34
+ /// entry — because the annotation is applied before any adapter hoists the
35
+ /// system prompt out or reshapes tool results into native turns. It is *not*
36
+ /// an index into the provider-native array Anthropic receives, where the
37
+ /// system message is gone and the numbering has shifted.
38
+ ///
39
+ /// A caller whose own message model is richer than the wire's — one entry that
40
+ /// expands into a leading system message plus one wire message per tool
41
+ /// result, say — must remap to the index of the wire message it actually sent.
42
+ /// Copied across unchanged, the boundary lands on the wrong message: too far
43
+ /// and the request is rejected (`400 invalid x-cache: segment message index is
44
+ /// out of range`); too near and less of the prefix is cached than was hashed,
45
+ /// with nothing to say so.
46
+ ///
47
+ /// llmshim's own insertions never move this index. A managed-output
48
+ /// instruction merges into an existing system message, and when it has to
49
+ /// prepend one it renumbers every segment itself (`shim.rs`,
50
+ /// `prepend_instruction`).
51
+ ///
52
+ /// Honoured on the Anthropic Messages wire only, where it becomes a
53
+ /// `cache_control` breakpoint on the last block of that message (or the last
54
+ /// tool call, or the tool result itself) — unless that last block is a
55
+ /// thinking block, in which case the segment is skipped without a marker.
56
+ /// The bounds check is Anthropic-only too: on every other wire the policy is
57
+ /// still parsed (malformed `x-cache` fails everywhere), but a segment's index
58
+ /// is neither checked nor placed — an out-of-range index there is silently
59
+ /// ignored, not rejected. `label` is the caller's own tag; llmshim never
60
+ /// reads it.
20
61
  #[derive(Debug, Clone, Serialize, Deserialize)]
21
62
  pub struct CacheSegment {
63
+ /// Index into the request's `messages` as sent — see the type-level note.
22
64
  pub upto_message: usize,
23
65
  #[serde(default)]
24
66
  pub label: String,
25
67
  pub stability: Stability,
26
68
  }
69
+
70
+ /// The caller's `x-cache` annotation: stability boundaries plus an optional
71
+ /// cache identity. Read once per request and never forwarded to a provider.
72
+ ///
73
+ /// The two halves land on different wires. `segments` are Anthropic-only —
74
+ /// see [`CacheSegment`] for the index convention and the no-op rule elsewhere.
75
+ /// `key` is not: on the OpenAI Responses wire it becomes `prompt_cache_key`,
76
+ /// and is ignored on the others. A request may carry both; each wire takes the
77
+ /// half it can use.
78
+ ///
79
+ /// Explicit segments supersede any request-level `cache_control` the caller
80
+ /// placed, and Anthropic's four-breakpoint budget is spent from the end of the
81
+ /// request backwards, so the boundaries nearest the tail are the ones kept.
27
82
  #[derive(Debug, Clone, Default, Serialize, Deserialize)]
28
83
  pub struct CachePolicy {
29
84
  #[serde(default)]
@@ -1,3 +1,4 @@
1
+ use crate::breaker::ProviderBreaker;
1
2
  use crate::error::{Result, ShimError};
2
3
  use crate::provider::{Provider, ProviderRequest};
3
4
  use bytes::Bytes;
@@ -7,6 +8,7 @@ use futures::{Stream, StreamExt};
7
8
  use reqwest::header::HeaderMap;
8
9
  use reqwest::Client;
9
10
  use std::pin::Pin;
11
+ use std::sync::Arc;
10
12
  use std::time::Duration;
11
13
 
12
14
  /// Retry bounds, resolved once from the environment (with defaults) at
@@ -55,6 +57,9 @@ fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
55
57
  pub struct ShimClient {
56
58
  http: Client,
57
59
  retry: RetryConfig,
60
+ /// Provider health, fed by every dispatch this client makes. `None` means
61
+ /// this client reports to nobody — see [`ShimClient::with_breaker`].
62
+ breaker: Option<Arc<ProviderBreaker>>,
58
63
  }
59
64
 
60
65
  impl Default for ShimClient {
@@ -77,6 +82,36 @@ impl ShimClient {
77
82
  .build()
78
83
  .expect("failed to build HTTP client"),
79
84
  retry: RetryConfig::from_env(),
85
+ breaker: None,
86
+ }
87
+ }
88
+
89
+ /// Report every dispatch's outcome to `breaker`.
90
+ ///
91
+ /// The breaker lives on the [`Router`](crate::router::Router), so a caller
92
+ /// that resolves a provider itself and comes straight here has to hand it
93
+ /// over: `ShimClient::new().with_breaker(router.breaker().clone())`. The
94
+ /// crate's own entry points (`llmshim::completion`, `stream`,
95
+ /// `completion_with_fallback`) bind the router's breaker this way, so this
96
+ /// is the one place a dispatch is counted — whichever door it came in by.
97
+ ///
98
+ /// Cheap: the HTTP connection pool is shared by clone, so binding a breaker
99
+ /// per call costs an `Arc` clone, not a new pool.
100
+ pub fn with_breaker(mut self, breaker: Arc<ProviderBreaker>) -> Self {
101
+ self.breaker = Some(breaker);
102
+ self
103
+ }
104
+
105
+ /// Record one dispatch against the attached breaker, if any. Called once
106
+ /// per public entry point on its *final* result — after transport retries
107
+ /// and any output-contract repair — so one caller-visible call is one
108
+ /// observation, never one per attempt.
109
+ ///
110
+ /// Takes the projected outcome rather than the result itself so a stream's
111
+ /// non-`Sync` body is never borrowed across the await.
112
+ async fn observe(&self, provider: &dyn Provider, outcome: std::result::Result<(), &ShimError>) {
113
+ if let Some(breaker) = &self.breaker {
114
+ breaker.observe(provider.name(), outcome).await;
80
115
  }
81
116
  }
82
117
 
@@ -167,6 +202,17 @@ impl ShimClient {
167
202
  provider: &dyn Provider,
168
203
  model: &str,
169
204
  request: &serde_json::Value,
205
+ ) -> Result<serde_json::Value> {
206
+ let result = self.completion_unobserved(provider, model, request).await;
207
+ self.observe(provider, result.as_ref().map(|_| ())).await;
208
+ result
209
+ }
210
+
211
+ async fn completion_unobserved(
212
+ &self,
213
+ provider: &dyn Provider,
214
+ model: &str,
215
+ request: &serde_json::Value,
170
216
  ) -> Result<serde_json::Value> {
171
217
  let plan = crate::shim::Plan::new(
172
218
  provider.name(),
@@ -232,6 +278,19 @@ impl ShimClient {
232
278
  provider: &dyn Provider,
233
279
  model: &str,
234
280
  request: &serde_json::Value,
281
+ ) -> Result<Pin<Box<dyn Stream<Item = Result<String>> + Send>>> {
282
+ // A stream's health verdict is whether it opened; per-chunk failures
283
+ // are the transport's business, not the breaker's.
284
+ let opened = self.stream_unobserved(provider, model, request).await;
285
+ self.observe(provider, opened.as_ref().map(|_| ())).await;
286
+ opened
287
+ }
288
+
289
+ async fn stream_unobserved(
290
+ &self,
291
+ provider: &dyn Provider,
292
+ model: &str,
293
+ request: &serde_json::Value,
235
294
  ) -> Result<Pin<Box<dyn Stream<Item = Result<String>> + Send>>> {
236
295
  let plan = crate::shim::Plan::new(
237
296
  provider.name(),
@@ -278,7 +337,23 @@ impl ShimClient {
278
337
  /// and keepalives while validation and a possible repair are in progress.
279
338
  pub async fn stream_owned(
280
339
  &self,
281
- provider: std::sync::Arc<dyn Provider>,
340
+ provider: Arc<dyn Provider>,
341
+ model: &str,
342
+ request: &serde_json::Value,
343
+ ) -> Result<Pin<Box<dyn Stream<Item = Result<String>> + Send>>> {
344
+ // Observed on the open only. A buffered plan's repair re-opens inside
345
+ // the returned stream; that second dial is not a separate verdict.
346
+ let opened = self
347
+ .stream_owned_unobserved(provider.clone(), model, request)
348
+ .await;
349
+ self.observe(provider.as_ref(), opened.as_ref().map(|_| ()))
350
+ .await;
351
+ opened
352
+ }
353
+
354
+ async fn stream_owned_unobserved(
355
+ &self,
356
+ provider: Arc<dyn Provider>,
282
357
  model: &str,
283
358
  request: &serde_json::Value,
284
359
  ) -> Result<Pin<Box<dyn Stream<Item = Result<String>> + Send>>> {
@@ -1,7 +1,6 @@
1
1
  use crate::error::{Result, ShimError};
2
2
  use crate::log::{LogEntry, Logger, RequestTimer};
3
3
  use crate::router::Router;
4
- use crate::SHARED_CLIENT;
5
4
  use serde_json::Value;
6
5
  use std::time::Duration;
7
6
 
@@ -75,7 +74,9 @@ pub async fn completion_with_fallback(
75
74
  };
76
75
 
77
76
  let mut errors: Vec<String> = Vec::new();
78
- let client = &*SHARED_CLIENT;
77
+ // Every attempt below is counted by the client against this router's
78
+ // breaker; the loop only asks `admit` before dialling.
79
+ let client = crate::bound_client(router);
79
80
 
80
81
  for model_str in &models {
81
82
  // Build request with this model. A named route expands to its model and
@@ -118,10 +119,6 @@ pub async fn completion_with_fallback(
118
119
  // Keep OAuth preparation, SSE-only providers, reasoning provenance,
119
120
  // and tool normalization identical to an ordinary completion.
120
121
  let outcome = client.completion(provider, &model, &req).await;
121
- router
122
- .breaker()
123
- .observe(provider.name(), outcome.as_ref().map(|_| ()))
124
- .await;
125
122
  match outcome {
126
123
  Ok(result) => {
127
124
  if let Some(logger) = logger {
@@ -79,16 +79,13 @@ pub async fn completion_with_logger(
79
79
  .ok_or(error::ShimError::MissingModel)?;
80
80
 
81
81
  let (provider, model) = router.resolve(model_str)?;
82
- let client = &*SHARED_CLIENT;
82
+ let client = bound_client(router);
83
83
  let timer = RequestTimer::start();
84
84
 
85
85
  // Ordinary traffic feeds provider health too, so a chain's first fallback
86
86
  // decision is not the first thing that ever noticed a provider is down.
87
+ // The client does the counting; see `ShimClient::with_breaker`.
87
88
  let result = client.completion(provider, &model, request).await;
88
- router
89
- .breaker()
90
- .observe(provider.name(), result.as_ref().map(|_| ()))
91
- .await;
92
89
 
93
90
  match result {
94
91
  Ok(resp) => {
@@ -132,13 +129,13 @@ pub async fn stream(
132
129
  // Observed but not gated: a single-target call has no alternative, so
133
130
  // refusing here would only convert an upstream failure into a local one.
134
131
  // The breaker refuses where there is somewhere else to go — `fallback.rs`.
135
- let client = &*SHARED_CLIENT;
136
- let opened = client.stream_owned(provider.clone(), &model, request).await;
137
- // A stream's health verdict is whether it opened; per-chunk failures are
138
- // the transport's business, not the breaker's.
139
- router
140
- .breaker()
141
- .observe(provider.name(), opened.as_ref().map(|_| ()))
142
- .await;
143
- opened
132
+ bound_client(router)
133
+ .stream_owned(provider, &model, request)
134
+ .await
135
+ }
136
+
137
+ /// The shared HTTP client, reporting to this router's breaker. The pool is
138
+ /// shared by clone; only the breaker handle is per call.
139
+ pub(crate) fn bound_client(router: &Router) -> ShimClient {
140
+ SHARED_CLIENT.clone().with_breaker(router.breaker().clone())
144
141
  }
@@ -116,6 +116,17 @@ fn extract_system_message(
116
116
  (system, rest)
117
117
  }
118
118
 
119
+ /// One Chat Completions message in, one Anthropic message out — a `role:
120
+ /// "tool"` result included, which becomes its own `user` message even when it
121
+ /// sits beside another.
122
+ ///
123
+ /// Same-role adjacency is deliberately left alone. The Messages API accepts
124
+ /// it: its reference states that consecutive `user` or `assistant` turns in a
125
+ /// request are combined into a single turn server-side (platform.claude.com,
126
+ /// Messages API, `messages` parameter), and parallel tool results already
127
+ /// reach it here as back-to-back `user` messages. Merging locally would only
128
+ /// destroy message boundaries a caller may key on. Gemini is the one wire in
129
+ /// this crate that rejects adjacency, and it merges in its own adapter.
119
130
  fn transform_messages(messages: &[Value]) -> Vec<Value> {
120
131
  messages
121
132
  .iter()
@@ -114,6 +114,12 @@ fn transform_messages(messages: &[Value]) -> (Option<Value>, Vec<Value>) {
114
114
  (system_instruction, contents)
115
115
  }
116
116
 
117
+ /// Gemini's own repair, not a shared one. This is the only wire in the crate
118
+ /// known to reject adjacent same-role turns, so it is the only adapter that
119
+ /// folds them together. Every other adapter passes adjacency through — each
120
+ /// says why on its own message pass — because a merge destroys message
121
+ /// boundaries a caller may depend on, and only a wire that would otherwise
122
+ /// fail the request earns that.
117
123
  fn merge_same_role(turns: Vec<Value>) -> Vec<Value> {
118
124
  let mut merged: Vec<Value> = Vec::new();
119
125
  for turn in turns {
@@ -46,6 +46,13 @@ fn strip_cache_control(value: &mut Value) {
46
46
  /// - Assistant messages with tool_calls → split into the assistant message +
47
47
  /// separate `function_call` items
48
48
  /// - `role: "tool"` messages → `function_call_output` items
49
+ ///
50
+ /// Same-role adjacency passes through. `input` is a flat item list, and the
51
+ /// Responses reference documents roles and their precedence but no ordering or
52
+ /// alternation rule (developers.openai.com, Responses API, `input`); this
53
+ /// adapter already emits several `function_call_output` items in a row for
54
+ /// parallel tool calls. Two adjacent assistant messages stay two items. The
55
+ /// ChatGPT adapter goes through this same translator and inherits the stance.
49
56
  fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
50
57
  let mut result = Vec::new();
51
58
  for msg in messages {
@@ -40,6 +40,16 @@ impl OpenAiCompatible {
40
40
  /// Strip llmshim-normalized / foreign-provider fields and normalize content
41
41
  /// blocks to Chat Completions form. Messages, `tool_calls`, and `role: "tool"`
42
42
  /// stay in Chat Completions shape (the target format).
43
+ ///
44
+ /// Same-role adjacency passes through unchanged, and here the answer is
45
+ /// genuinely the served model's. vLLM and SGLang render `messages` through the
46
+ /// tokenizer's Jinja chat template (or `--chat-template`), so acceptance is a
47
+ /// property of that template: most current ones accept adjacent turns, some
48
+ /// older ones raise — Mistral-7B-Instruct-v0.1's template errors with
49
+ /// "conversation roles must alternate user/assistant/user/assistant/...".
50
+ /// llmshim cannot see the template, so it does not merge; a strict template's
51
+ /// rejection surfaces as the server's own 400. No `x-vllm` / `x-sglang`
52
+ /// parameter changes this.
43
53
  fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
44
54
  messages
45
55
  .iter()
@@ -61,6 +61,14 @@ fn normalize_openrouter_effort(effort: &str, pro: bool) -> &'static str {
61
61
  /// llmshim-normalized / foreign-provider fields so multi-model conversations
62
62
  /// don't leak them, and normalize vision blocks to OpenAI form. Messages,
63
63
  /// `tool_calls`, and `role: "tool"` all stay in Chat Completions shape.
64
+ ///
65
+ /// Same-role adjacency passes through unchanged. OpenRouter is an aggregator:
66
+ /// its own API is Chat Completions and documents no alternation rule
67
+ /// (openrouter.ai/docs, API reference and parameters). Whether the vendor
68
+ /// behind a given slug rejects adjacent turns is unknown from here and is
69
+ /// OpenRouter's to reconcile; a faithful passthrough does not pre-empt it by
70
+ /// merging, which would destroy message boundaries for the vendors that
71
+ /// accept them.
64
72
  fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
65
73
  messages
66
74
  .iter()
@@ -29,6 +29,13 @@ impl Xai {
29
29
  /// - Assistant messages with tool_calls → split into the assistant message +
30
30
  /// separate `function_call` items
31
31
  /// - `role: "tool"` messages → `function_call_output` items
32
+ ///
33
+ /// Same-role adjacency passes through unchanged. xAI's API is OpenAI
34
+ /// Responses-shaped and its documentation (docs.x.ai, chat guide and API
35
+ /// reference) states no ordering or alternation rule — but silence is not a
36
+ /// verified acceptance, and this has not been checked live. Nothing is merged
37
+ /// here because merging would destroy message boundaries a caller may depend
38
+ /// on; if xAI ever rejects adjacency the rejection arrives as its own 400.
32
39
  fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
33
40
  let mut result = Vec::new();
34
41
  for msg in messages {
@@ -176,15 +176,35 @@ impl Router {
176
176
  .ok_or_else(|| ShimError::UnknownProvider(key.to_string()))
177
177
  }
178
178
 
179
- /// Build a router from provider env vars and a saved ChatGPT login.
179
+ /// Build a router from provider env vars and a saved ChatGPT login, and
180
+ /// schedule one background refresh of the model catalog.
181
+ ///
182
+ /// This is the daemon's constructor. The proxy starts once and serves for
183
+ /// days, so a single fetch at startup is what keeps its prices and
184
+ /// capabilities current for the rest of its life. A process that starts
185
+ /// many times a day, or must run air-gapped, wants
186
+ /// [`Router::from_env_without_catalog_refresh`] instead and decides for
187
+ /// itself when — or whether — to call
188
+ /// [`Router::refresh_catalog_in_background`].
180
189
  pub fn from_env() -> Self {
181
- let mut router = Router::new();
190
+ let router = Self::from_env_without_catalog_refresh();
191
+ router.refresh_catalog_in_background();
192
+ router
193
+ }
182
194
 
183
- // Only startup's local snapshot is synchronous. No catalog HTTP fetch
184
- // is ever awaited by a model request.
185
- if let Ok(catalog) = crate::catalog::global() {
186
- catalog.refresh_in_background();
187
- }
195
+ /// [`Router::from_env`] minus the catalog refresh: building a router makes
196
+ /// no network call.
197
+ ///
198
+ /// The catalog is still there — the vendored snapshot, whatever an earlier
199
+ /// refresh cached on disk, and the local override files — so `resolve` and
200
+ /// pricing work exactly as they do after `from_env`; the data is simply
201
+ /// never newer than the disk. For a short-lived embedder that is the right
202
+ /// default: a CLI that phones home for a price list before the user has
203
+ /// typed anything is doing something nobody asked for, and a machine with
204
+ /// no route out should not have to discover `LLMSHIM_CATALOG_OFFLINE` to
205
+ /// stop it. The fetch becomes something the caller asks for.
206
+ pub fn from_env_without_catalog_refresh() -> Self {
207
+ let mut router = Router::new();
188
208
 
189
209
  // Named routes are configuration, not discovery: they come from
190
210
  // ~/.llmshim/config.toml and nothing synthesizes a default set.
@@ -230,6 +250,22 @@ impl Router {
230
250
  router
231
251
  }
232
252
 
253
+ /// Refresh the shared model catalog from the network, detached from every
254
+ /// request: only startup's local snapshot is synchronous, and no catalog
255
+ /// HTTP fetch is ever awaited by a model request.
256
+ ///
257
+ /// The refresh rides the caller's Tokio runtime and never creates one, so
258
+ /// `None` means nothing was scheduled: there is no runtime on this thread,
259
+ /// `LLMSHIM_CATALOG_OFFLINE=1` is set, or the local catalog configuration
260
+ /// is invalid. The catalog is process-wide — it is what every router in
261
+ /// the process resolves against — so refreshing through one router
262
+ /// refreshes it for all of them.
263
+ pub fn refresh_catalog_in_background(
264
+ &self,
265
+ ) -> Option<tokio::task::JoinHandle<crate::catalog::RefreshOutcome>> {
266
+ crate::catalog::global().ok()?.refresh_in_background()
267
+ }
268
+
233
269
  /// Resolve model string to (provider, model_name).
234
270
  pub fn resolve(&self, model: &str) -> Result<(&dyn Provider, String)> {
235
271
  let (key, model) = self.resolve_key(model)?;