llmshim 0.3.5__tar.gz → 0.3.6__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.3.5 → llmshim-0.3.6}/CLAUDE.md +11 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/Cargo.lock +1 -1
- {llmshim-0.3.5 → llmshim-0.3.6}/Cargo.toml +1 -1
- {llmshim-0.3.5 → llmshim-0.3.6}/PKG-INFO +1 -1
- {llmshim-0.3.5 → llmshim-0.3.6}/README.md +15 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/errors.md +12 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/client.rs +46 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/anthropic.rs +15 -14
- {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/gemini.rs +11 -10
- {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/openai.rs +9 -8
- {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/xai.rs +9 -8
- llmshim-0.3.6/tests/support/completion_status.rs +124 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_openai.rs +3 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/.github/workflows/pages.yml +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/.github/workflows/release.yml +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/.gitignore +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/CODE_OF_CONDUCT.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/CONTRIBUTING.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/LICENSE-APACHE +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/LICENSE-MIT +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/NOTICE +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/SECURITY.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/benchmarks/bench.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/benchmarks/bench_python.py +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/benchmarks/gateway_loadtest.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/benchmarks/loadtest.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/.gitignore +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/book.toml +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/mermaid-init.js +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/mermaid.min.js +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/SUMMARY.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/contracts.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/conversations.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/portability.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/routing.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/translation-flow.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/fallbacks.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/images.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/native-controls.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/reasoning.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/streaming.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/tools.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/introduction.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/proxy/deployment.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/proxy/http-api.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/proxy/scaling.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/api.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/cli.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/configuration.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/models.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/providers.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/request-fields.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/surfaces.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/choose.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/cli.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/clients.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/configure.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/proxy.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/rust.md +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/examples/chat.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/examples/stream.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/llmshim/__init__.py +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/llmshim/_client.py +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/llmshim/_server.py +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/llmshim/types.py +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/pyproject.toml +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/config.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/env.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/error.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/fallback.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/auth.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/distributed.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/http.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/idempotency.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/metrics.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/mod.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/quota.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/lib.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/log.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/main.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/models.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/provider.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/mod.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/openai_compat.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/openrouter.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/convert.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/error.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/handlers.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/mod.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/ratelimit.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/types.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/router.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/src/vision.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_fallback.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_gemini.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_gemini_tools.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_long_context.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_multimodel.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_openrouter.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_proxy.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_sglang.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_thinking.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_tool_roundtrip.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_vision.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_xai.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_anthropic.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_fallback.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_fast_mode.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_gemini.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_log.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_models.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_multimodel.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_openai_compat.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_openrouter.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_proxy.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_proxy_convert.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_router.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_sse.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_tools.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_vision.rs +0 -0
- {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_xai.rs +0 -0
|
@@ -58,12 +58,23 @@ llmshim::completion(router, request)
|
|
|
58
58
|
|
|
59
59
|
Every provider implements: `transform_request`, `transform_response`, `transform_stream_chunk`.
|
|
60
60
|
|
|
61
|
+
Non-streaming OpenAI/xAI Responses, Gemini and Anthropic transformations require
|
|
62
|
+
supported terminal metadata. Do not turn absent or unrecognized status into
|
|
63
|
+
`finish_reason: "stop"`. Rejected metadata returns a fixed 502 provider error
|
|
64
|
+
without copying provider-supplied status/content into the diagnostic. Tests
|
|
65
|
+
live in `tests/support/completion_status.rs`, included by `unit_openai`.
|
|
66
|
+
Streaming status handling is separate and is not changed by this policy.
|
|
67
|
+
|
|
61
68
|
### Router (`src/router.rs`)
|
|
62
69
|
|
|
63
70
|
Parses `"provider/model"` strings by splitting on the **first** `/` only, so an OpenRouter slug's internal slash survives (`openrouter/anthropic/claude-sonnet-4.5` → provider `openrouter`, model `anthropic/claude-sonnet-4.5`). Auto-infers provider from prefix (`gpt*`/`o*` → openai, `claude*` → anthropic, `gemini*` → gemini, `grok*` → xai); **OpenRouter, vLLM, and SGLang have no prefix inference** — their slugs collide with everyone's, so address them explicitly (`openrouter/…`, `vllm/…`, `sglang/…`); the first-slash split also preserves HF-style served-model slugs (`vllm/meta-llama/Llama-3.1-8B-Instruct`). Supports aliases. `Router::from_env()` reads API-key env vars, plus `VLLM_BASE_URL` / `SGLANG_BASE_URL` (+ optional `*_API_KEY`) for the self-hosted providers.
|
|
64
71
|
|
|
65
72
|
### HTTP Client (`src/client.rs`)
|
|
66
73
|
|
|
74
|
+
Automatic redirects are disabled on the shared client. Keep prompts and
|
|
75
|
+
provider-specific credential headers at the configured endpoint; 3xx responses
|
|
76
|
+
remain provider errors. Callers must configure the final URL directly.
|
|
77
|
+
|
|
67
78
|
`ShimClient` with shared connection pool (`LazyLock`), HTTP/2, gzip/brotli/zstd compression, TCP keepalive + nodelay. Automatic retry (3 attempts by default) on transport errors and 429/500/502/503/504/529 status codes. This is the **reactive** layer: on a retryable *response* it honors the server's `Retry-After` header (integer seconds or HTTP-date) and provider reset hints (OpenAI `x-ratelimit-reset-*`, Anthropic `anthropic-ratelimit-*-reset`), clamped to a cap and nudged with a little jitter; when there's no server hint (or a transport error) it falls back to full-jitter exponential backoff (uniform in `[0, min(cap, base·2^attempt)]`) to avoid a thundering herd. Tunable via `LLMSHIM_MAX_RETRIES` and `LLMSHIM_MAX_BACKOFF_SECS`. `warmup()` pre-establishes TCP+TLS connections. `SseStream` buffers bytes, extracts `data:` lines, routes through provider's `transform_stream_chunk`.
|
|
68
79
|
|
|
69
80
|
### Fallback chains (`src/fallback.rs`)
|
|
@@ -76,8 +76,23 @@ llmshim configure # interactive prompt
|
|
|
76
76
|
|
|
77
77
|
---
|
|
78
78
|
|
|
79
|
+
## Endpoint redirects
|
|
80
|
+
|
|
81
|
+
The shared HTTP client does not follow redirects. Configure the final API URL
|
|
82
|
+
directly: a 3xx response is returned as a provider error instead of forwarding
|
|
83
|
+
the prompt and provider-specific credential headers to another endpoint.
|
|
84
|
+
This applies to both streaming and non-streaming requests.
|
|
85
|
+
|
|
79
86
|
## Use it from Rust
|
|
80
87
|
|
|
88
|
+
Non-streaming OpenAI Responses, xAI Responses, Gemini, and Anthropic results
|
|
89
|
+
require supported terminal metadata. Missing, malformed, or unsupported
|
|
90
|
+
statuses return a `ProviderError` (502) with a fixed diagnostic instead of
|
|
91
|
+
being interpreted as successful completion. Known completion, output-limit,
|
|
92
|
+
filtering and Anthropic tool-call mappings remain available. Additional native
|
|
93
|
+
reasons require an explicit mapping; streaming transforms are unchanged.
|
|
94
|
+
Provider mocks should include the native terminal status/stop reason.
|
|
95
|
+
|
|
81
96
|
```bash
|
|
82
97
|
cargo add llmshim tokio serde_json
|
|
83
98
|
```
|
|
@@ -23,6 +23,18 @@ failure can occur after streaming begins.
|
|
|
23
23
|
|
|
24
24
|
## What the shared client retries
|
|
25
25
|
|
|
26
|
+
Provider requests do not follow HTTP redirects. A `3xx` response returns a
|
|
27
|
+
`ProviderError` with that status; configure the final endpoint URL directly.
|
|
28
|
+
This keeps prompts and provider authentication headers at the configured
|
|
29
|
+
destination for both completion and streaming requests.
|
|
30
|
+
|
|
31
|
+
Non-streaming native normalizers require supported terminal metadata. Missing,
|
|
32
|
+
malformed, nonterminal or unrecognized status/finish-reason values produce a
|
|
33
|
+
`ProviderError` with status `502` and a fixed diagnostic, rather than labeling
|
|
34
|
+
partial text as a successful `stop`. The diagnostic excludes response text
|
|
35
|
+
and the provider-supplied reason. Known completion, length, filtering and tool
|
|
36
|
+
mappings remain supported. Streaming transformations are unchanged.
|
|
37
|
+
|
|
26
38
|
The shared provider client automatically retries:
|
|
27
39
|
|
|
28
40
|
- transport connect, timeout, request, and body failures;
|
|
@@ -65,6 +65,9 @@ impl ShimClient {
|
|
|
65
65
|
pub fn new() -> Self {
|
|
66
66
|
Self {
|
|
67
67
|
http: Client::builder()
|
|
68
|
+
// Prompts and custom provider credentials belong only at the
|
|
69
|
+
// configured endpoint, never an HTTP Location target.
|
|
70
|
+
.redirect(reqwest::redirect::Policy::none())
|
|
68
71
|
.pool_idle_timeout(Duration::from_secs(90))
|
|
69
72
|
.pool_max_idle_per_host(4)
|
|
70
73
|
.tcp_keepalive(Duration::from_secs(30))
|
|
@@ -670,6 +673,49 @@ mod tests {
|
|
|
670
673
|
|
|
671
674
|
// --- Integration (mockito, local only — no provider API calls) ----------
|
|
672
675
|
|
|
676
|
+
#[tokio::test]
|
|
677
|
+
async fn redirects_do_not_forward_prompts_or_credentials() {
|
|
678
|
+
for status in [301, 302, 303, 307, 308] {
|
|
679
|
+
for same_origin in [false, true] {
|
|
680
|
+
let mut origin = mockito::Server::new_async().await;
|
|
681
|
+
let mut other = mockito::Server::new_async().await;
|
|
682
|
+
let destination = if same_origin { &mut origin } else { &mut other };
|
|
683
|
+
let location = format!("{}/moved", destination.url());
|
|
684
|
+
let forwarded = destination
|
|
685
|
+
.mock(if status <= 303 { "GET" } else { "POST" }, "/moved")
|
|
686
|
+
.with_status(200)
|
|
687
|
+
.with_body("unexpected forwarding")
|
|
688
|
+
.expect(0)
|
|
689
|
+
.create_async()
|
|
690
|
+
.await;
|
|
691
|
+
let redirect = origin
|
|
692
|
+
.mock("POST", "/v1/chat/completions")
|
|
693
|
+
.match_header("x-api-key", "test-secret")
|
|
694
|
+
.match_body(mockito::Matcher::Json(serde_json::json!({
|
|
695
|
+
"messages": [{"role": "user", "content": "private transcript"}]
|
|
696
|
+
})))
|
|
697
|
+
.with_status(status)
|
|
698
|
+
.with_header("location", &location)
|
|
699
|
+
.expect(1)
|
|
700
|
+
.create_async()
|
|
701
|
+
.await;
|
|
702
|
+
let request = ProviderRequest {
|
|
703
|
+
url: format!("{}/v1/chat/completions", origin.url()),
|
|
704
|
+
headers: vec![("x-api-key".into(), "test-secret".into())],
|
|
705
|
+
body: serde_json::json!({
|
|
706
|
+
"messages": [{"role": "user", "content": "private transcript"}]
|
|
707
|
+
}),
|
|
708
|
+
};
|
|
709
|
+
let result = ShimClient::new().send(&request).await;
|
|
710
|
+
redirect.assert_async().await;
|
|
711
|
+
forwarded.assert_async().await;
|
|
712
|
+
assert!(
|
|
713
|
+
matches!(result, Err(ShimError::ProviderError { status: actual, .. }) if actual == status as u16)
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
673
719
|
#[tokio::test]
|
|
674
720
|
async fn honors_retry_after_then_succeeds() {
|
|
675
721
|
let mut server = mockito::Server::new_async().await;
|
|
@@ -381,7 +381,7 @@ fn translate_tool_choice(tc: &Value) -> Option<Value> {
|
|
|
381
381
|
|
|
382
382
|
// -- Response transformation helpers --
|
|
383
383
|
|
|
384
|
-
fn transform_response_to_openai(model: &str, resp: &Value) -> Value {
|
|
384
|
+
fn transform_response_to_openai(model: &str, resp: &Value) -> Result<Value> {
|
|
385
385
|
let content_blocks = resp
|
|
386
386
|
.get("content")
|
|
387
387
|
.and_then(|c| c.as_array())
|
|
@@ -438,16 +438,17 @@ fn transform_response_to_openai(model: &str, resp: &Value) -> Value {
|
|
|
438
438
|
json!(text_parts.join(""))
|
|
439
439
|
};
|
|
440
440
|
|
|
441
|
-
let stop_reason = resp
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
441
|
+
let stop_reason = match resp.get("stop_reason").and_then(Value::as_str) {
|
|
442
|
+
Some("end_turn" | "stop_sequence") => "stop",
|
|
443
|
+
Some("max_tokens") => "length",
|
|
444
|
+
Some("tool_use") => "tool_calls",
|
|
445
|
+
_ => {
|
|
446
|
+
return Err(ShimError::ProviderError {
|
|
447
|
+
status: 502,
|
|
448
|
+
body: "Anthropic response has no supported terminal stop reason".into(),
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
};
|
|
451
452
|
|
|
452
453
|
let usage = resp.get("usage").cloned().unwrap_or(json!({}));
|
|
453
454
|
let normalized_usage = normalized_anthropic_usage(&usage);
|
|
@@ -472,7 +473,7 @@ fn transform_response_to_openai(model: &str, resp: &Value) -> Value {
|
|
|
472
473
|
message["redacted_reasoning_content"] = json!(data);
|
|
473
474
|
}
|
|
474
475
|
|
|
475
|
-
json!({
|
|
476
|
+
Ok(json!({
|
|
476
477
|
"id": resp.get("id").cloned().unwrap_or(json!("")),
|
|
477
478
|
"object": "chat.completion",
|
|
478
479
|
"model": model,
|
|
@@ -482,7 +483,7 @@ fn transform_response_to_openai(model: &str, resp: &Value) -> Value {
|
|
|
482
483
|
"finish_reason": stop_reason,
|
|
483
484
|
}],
|
|
484
485
|
"usage": normalized_usage
|
|
485
|
-
})
|
|
486
|
+
}))
|
|
486
487
|
}
|
|
487
488
|
|
|
488
489
|
/// Normalize a unified reasoning effort (`none|low|medium|high|xhigh|max`,
|
|
@@ -770,7 +771,7 @@ impl Provider for Anthropic {
|
|
|
770
771
|
});
|
|
771
772
|
}
|
|
772
773
|
|
|
773
|
-
|
|
774
|
+
transform_response_to_openai(model, &response)
|
|
774
775
|
}
|
|
775
776
|
|
|
776
777
|
fn transform_stream_chunk(&self, model: &str, chunk: &str) -> Result<Option<String>> {
|
|
@@ -540,16 +540,17 @@ fn transform_response_to_openai(model: &str, resp: &Value) -> Result<Value> {
|
|
|
540
540
|
json!(text_parts.join(""))
|
|
541
541
|
};
|
|
542
542
|
|
|
543
|
-
let finish_reason = candidate
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
543
|
+
let finish_reason = match candidate.get("finishReason").and_then(Value::as_str) {
|
|
544
|
+
Some("STOP") => "stop",
|
|
545
|
+
Some("MAX_TOKENS") => "length",
|
|
546
|
+
Some("SAFETY") => "content_filter",
|
|
547
|
+
_ => {
|
|
548
|
+
return Err(ShimError::ProviderError {
|
|
549
|
+
status: 502,
|
|
550
|
+
body: "Gemini response has no supported terminal finish reason".into(),
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
};
|
|
553
554
|
|
|
554
555
|
let usage = resp.get("usageMetadata").cloned().unwrap_or(json!({}));
|
|
555
556
|
|
|
@@ -539,14 +539,15 @@ impl Provider for OpenAi {
|
|
|
539
539
|
message["reasoning_content"] = json!(reasoning);
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
-
let
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
542
|
+
let finish_reason = match response.get("status").and_then(Value::as_str) {
|
|
543
|
+
Some("completed") => "stop",
|
|
544
|
+
Some("incomplete") => "length",
|
|
545
|
+
_ => {
|
|
546
|
+
return Err(ShimError::ProviderError {
|
|
547
|
+
status: 502,
|
|
548
|
+
body: "OpenAI response has no supported terminal status".into(),
|
|
549
|
+
})
|
|
550
|
+
}
|
|
550
551
|
};
|
|
551
552
|
|
|
552
553
|
let usage = response.get("usage").cloned().unwrap_or(json!({}));
|
|
@@ -379,14 +379,15 @@ impl Provider for Xai {
|
|
|
379
379
|
message["reasoning_content"] = json!(reasoning);
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
-
let
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
382
|
+
let finish_reason = match response.get("status").and_then(Value::as_str) {
|
|
383
|
+
Some("completed") => "stop",
|
|
384
|
+
Some("incomplete") => "length",
|
|
385
|
+
_ => {
|
|
386
|
+
return Err(ShimError::ProviderError {
|
|
387
|
+
status: 502,
|
|
388
|
+
body: "xAI response has no supported terminal status".into(),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
390
391
|
};
|
|
391
392
|
|
|
392
393
|
let usage = response.get("usage").cloned().unwrap_or(json!({}));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
use llmshim::provider::Provider;
|
|
2
|
+
use llmshim::providers::{anthropic::Anthropic, gemini::Gemini, openai::OpenAi, xai::Xai};
|
|
3
|
+
use serde_json::{json, Value};
|
|
4
|
+
|
|
5
|
+
fn check(provider: &dyn Provider, template: Value, pointer: &str, bad: &[Value]) {
|
|
6
|
+
for value in bad {
|
|
7
|
+
let mut response = template.clone();
|
|
8
|
+
*response.pointer_mut(pointer).unwrap() = value.clone();
|
|
9
|
+
let error = provider
|
|
10
|
+
.transform_response("test-model", response)
|
|
11
|
+
.unwrap_err();
|
|
12
|
+
assert!(!error.to_string().contains("private"));
|
|
13
|
+
}
|
|
14
|
+
let mut response = template;
|
|
15
|
+
let (parent, key) = pointer.rsplit_once('/').unwrap();
|
|
16
|
+
response
|
|
17
|
+
.pointer_mut(parent)
|
|
18
|
+
.unwrap()
|
|
19
|
+
.as_object_mut()
|
|
20
|
+
.unwrap()
|
|
21
|
+
.remove(key);
|
|
22
|
+
assert!(provider.transform_response("test-model", response).is_err());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[test]
|
|
26
|
+
fn openai_nonterminal_or_missing_status_cannot_become_stop() {
|
|
27
|
+
check_responses_api(&OpenAi::new("unused".into()));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[test]
|
|
31
|
+
fn xai_nonterminal_or_missing_status_cannot_become_stop() {
|
|
32
|
+
check_responses_api(&Xai::new("unused".into()));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn check_responses_api(provider: &dyn Provider) {
|
|
36
|
+
let response = json!({"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"private partial text"}]}]});
|
|
37
|
+
check(
|
|
38
|
+
provider,
|
|
39
|
+
response.clone(),
|
|
40
|
+
"/status",
|
|
41
|
+
&[
|
|
42
|
+
json!("failed"),
|
|
43
|
+
json!("cancelled"),
|
|
44
|
+
json!("queued"),
|
|
45
|
+
json!("in_progress"),
|
|
46
|
+
json!("private-unknown"),
|
|
47
|
+
Value::Null,
|
|
48
|
+
json!(42),
|
|
49
|
+
],
|
|
50
|
+
);
|
|
51
|
+
for (status, expected) in [("completed", "stop"), ("incomplete", "length")] {
|
|
52
|
+
let mut value = response.clone();
|
|
53
|
+
value["status"] = json!(status);
|
|
54
|
+
assert_eq!(
|
|
55
|
+
provider.transform_response("test-model", value).unwrap()["choices"][0]
|
|
56
|
+
["finish_reason"],
|
|
57
|
+
expected
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[test]
|
|
63
|
+
fn gemini_unknown_or_missing_reason_cannot_become_stop() {
|
|
64
|
+
let provider = Gemini::new("unused".into());
|
|
65
|
+
let response = json!({"candidates":[{"finishReason":"STOP","content":{"parts":[{"text":"private partial text"}]}}]});
|
|
66
|
+
check(
|
|
67
|
+
&provider,
|
|
68
|
+
response.clone(),
|
|
69
|
+
"/candidates/0/finishReason",
|
|
70
|
+
&[
|
|
71
|
+
json!("RECITATION"),
|
|
72
|
+
json!("MALFORMED_FUNCTION_CALL"),
|
|
73
|
+
json!("OTHER"),
|
|
74
|
+
json!("private-unknown"),
|
|
75
|
+
Value::Null,
|
|
76
|
+
json!(42),
|
|
77
|
+
],
|
|
78
|
+
);
|
|
79
|
+
for (reason, expected) in [
|
|
80
|
+
("STOP", "stop"),
|
|
81
|
+
("MAX_TOKENS", "length"),
|
|
82
|
+
("SAFETY", "content_filter"),
|
|
83
|
+
] {
|
|
84
|
+
let mut value = response.clone();
|
|
85
|
+
value["candidates"][0]["finishReason"] = json!(reason);
|
|
86
|
+
assert_eq!(
|
|
87
|
+
provider.transform_response("test-model", value).unwrap()["choices"][0]
|
|
88
|
+
["finish_reason"],
|
|
89
|
+
expected
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[test]
|
|
95
|
+
fn anthropic_missing_or_unknown_reason_cannot_become_stop() {
|
|
96
|
+
let provider = Anthropic::new("unused".into());
|
|
97
|
+
let response =
|
|
98
|
+
json!({"stop_reason":"end_turn","content":[{"type":"text","text":"private partial text"}]});
|
|
99
|
+
check(
|
|
100
|
+
&provider,
|
|
101
|
+
response.clone(),
|
|
102
|
+
"/stop_reason",
|
|
103
|
+
&[
|
|
104
|
+
json!("pause_turn"),
|
|
105
|
+
json!("private-unknown"),
|
|
106
|
+
Value::Null,
|
|
107
|
+
json!(42),
|
|
108
|
+
],
|
|
109
|
+
);
|
|
110
|
+
for (reason, expected) in [
|
|
111
|
+
("end_turn", "stop"),
|
|
112
|
+
("stop_sequence", "stop"),
|
|
113
|
+
("max_tokens", "length"),
|
|
114
|
+
("tool_use", "tool_calls"),
|
|
115
|
+
] {
|
|
116
|
+
let mut value = response.clone();
|
|
117
|
+
value["stop_reason"] = json!(reason);
|
|
118
|
+
assert_eq!(
|
|
119
|
+
provider.transform_response("test-model", value).unwrap()["choices"][0]
|
|
120
|
+
["finish_reason"],
|
|
121
|
+
expected
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|