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.
- llmshim-0.7.2/.github/scripts/npm-verify-served.sh +37 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/.github/workflows/release.yml +18 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/CLAUDE.md +10 -2
- {llmshim-0.7.0 → llmshim-0.7.2}/Cargo.lock +1 -1
- {llmshim-0.7.0 → llmshim-0.7.2}/Cargo.toml +1 -1
- {llmshim-0.7.0 → llmshim-0.7.2}/PKG-INFO +1 -1
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/routing.md +22 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/caching.md +8 -1
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/models.md +4 -1
- {llmshim-0.7.0 → llmshim-0.7.2}/src/cache.rs +55 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/client.rs +76 -1
- {llmshim-0.7.0 → llmshim-0.7.2}/src/fallback.rs +3 -6
- {llmshim-0.7.0 → llmshim-0.7.2}/src/lib.rs +11 -14
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/anthropic.rs +11 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/gemini.rs +6 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/openai.rs +7 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/openai_compat.rs +10 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/openrouter.rs +8 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/xai.rs +7 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/router.rs +43 -7
- llmshim-0.7.2/tests/unit_client_breaker.rs +118 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_provider_contracts.rs +53 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_router.rs +60 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/.github/workflows/catalog-refresh.yml +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/.github/workflows/pages.yml +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/.gitignore +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/CODE_OF_CONDUCT.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/CONTRIBUTING.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/LICENSE-APACHE +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/LICENSE-MIT +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/NOTICE +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/README.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/SECURITY.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/benchmarks/bench.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/benchmarks/bench_python.py +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/benchmarks/gateway_loadtest.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/benchmarks/loadtest.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/Cargo.toml +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/LICENSE-APACHE +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/LICENSE-MIT +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/README.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/data/LICENSE.models.dev +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/data/README.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/data/models.dev.json +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/aliases.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/builtin.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/capabilities.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/lib.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/merge.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/parse.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/refresh.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/src/types.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/tests/catalog.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/crates/llmshim-catalog/tests/refresh.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/.gitignore +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/book.toml +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/mermaid-init.js +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/mermaid.min.js +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/SUMMARY.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/contracts.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/conversations.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/portability.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/concepts/translation-flow.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/capabilities.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/fallbacks.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/images.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/native-controls.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/reasoning.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/schemas.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/streaming.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/guides/tools.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/introduction.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/proxy/deployment.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/proxy/http-api.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/proxy/native-apis.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/proxy/scaling.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/api.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/cli.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/configuration.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/errors.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/providers.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/request-fields.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/reference/surfaces.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/choose.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/cli.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/clients.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/configure.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/proxy.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/docs/src/start/rust.md +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/examples/chat.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/examples/stream.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/llmshim/__init__.py +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/llmshim/_client.py +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/llmshim/_server.py +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/llmshim/types.py +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/pyproject.toml +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/breaker.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/cli.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/config.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/cost.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/env.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/error/normalize.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/error.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/auth.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/distributed.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/http.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/idempotency.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/metrics.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/mod.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/gateway/quota.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/log.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/main.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/models.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/provider.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/anthropic_reasoning.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/anthropic_signature.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/chatgpt/auth.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/chatgpt/mod.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/chatgpt/streaming.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/providers/mod.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/convert.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/error.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/handlers.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/health.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/mod.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/ratelimit.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/types.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/wire/mod.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/proxy/wire/receipts.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/reasoning/normalize.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/reasoning.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/schema/memo.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/schema/mod.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/schema/validate.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/schema/walk.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/shim.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/streaming.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/toolcall/streaming.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/toolcall.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/usage.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/src/vision.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/fixtures/chatgpt-red.png +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_chatgpt.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_chatgpt_proxy.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_current_models.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_fallback.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_gemini.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_gemini_tools.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_long_context.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_multimodel.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_openrouter.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_proxy.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_sglang.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_thinking.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_tool_roundtrip.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_vision.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/integration_xai.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/support/completion_status.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_advertised_models.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_anthropic.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_cache.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_chatgpt.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_cli.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_fable.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_fallback.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_fast_mode.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_gemini.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_log.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_models.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_multimodel.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_openai.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_openai_compat.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_openrouter.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_proxy.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_proxy_convert.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_reasoning.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_reasoning_profile.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_schema.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_shim.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_signature.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_sse.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_toolcall.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_tools.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_usage.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_vision.rs +0 -0
- {llmshim-0.7.0 → llmshim-0.7.2}/tests/unit_wire.rs +0 -0
- {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`)
|
|
232
|
-
|
|
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:
|
|
@@ -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
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
190
|
+
let router = Self::from_env_without_catalog_refresh();
|
|
191
|
+
router.refresh_catalog_in_background();
|
|
192
|
+
router
|
|
193
|
+
}
|
|
182
194
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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)?;
|