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.
Files changed (125) hide show
  1. {llmshim-0.3.5 → llmshim-0.3.6}/CLAUDE.md +11 -0
  2. {llmshim-0.3.5 → llmshim-0.3.6}/Cargo.lock +1 -1
  3. {llmshim-0.3.5 → llmshim-0.3.6}/Cargo.toml +1 -1
  4. {llmshim-0.3.5 → llmshim-0.3.6}/PKG-INFO +1 -1
  5. {llmshim-0.3.5 → llmshim-0.3.6}/README.md +15 -0
  6. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/errors.md +12 -0
  7. {llmshim-0.3.5 → llmshim-0.3.6}/src/client.rs +46 -0
  8. {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/anthropic.rs +15 -14
  9. {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/gemini.rs +11 -10
  10. {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/openai.rs +9 -8
  11. {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/xai.rs +9 -8
  12. llmshim-0.3.6/tests/support/completion_status.rs +124 -0
  13. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_openai.rs +3 -0
  14. {llmshim-0.3.5 → llmshim-0.3.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {llmshim-0.3.5 → llmshim-0.3.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  16. {llmshim-0.3.5 → llmshim-0.3.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {llmshim-0.3.5 → llmshim-0.3.6}/.github/workflows/pages.yml +0 -0
  18. {llmshim-0.3.5 → llmshim-0.3.6}/.github/workflows/release.yml +0 -0
  19. {llmshim-0.3.5 → llmshim-0.3.6}/.gitignore +0 -0
  20. {llmshim-0.3.5 → llmshim-0.3.6}/CODE_OF_CONDUCT.md +0 -0
  21. {llmshim-0.3.5 → llmshim-0.3.6}/CONTRIBUTING.md +0 -0
  22. {llmshim-0.3.5 → llmshim-0.3.6}/LICENSE-APACHE +0 -0
  23. {llmshim-0.3.5 → llmshim-0.3.6}/LICENSE-MIT +0 -0
  24. {llmshim-0.3.5 → llmshim-0.3.6}/NOTICE +0 -0
  25. {llmshim-0.3.5 → llmshim-0.3.6}/SECURITY.md +0 -0
  26. {llmshim-0.3.5 → llmshim-0.3.6}/benchmarks/bench.rs +0 -0
  27. {llmshim-0.3.5 → llmshim-0.3.6}/benchmarks/bench_python.py +0 -0
  28. {llmshim-0.3.5 → llmshim-0.3.6}/benchmarks/gateway_loadtest.rs +0 -0
  29. {llmshim-0.3.5 → llmshim-0.3.6}/benchmarks/loadtest.rs +0 -0
  30. {llmshim-0.3.5 → llmshim-0.3.6}/docs/.gitignore +0 -0
  31. {llmshim-0.3.5 → llmshim-0.3.6}/docs/book.toml +0 -0
  32. {llmshim-0.3.5 → llmshim-0.3.6}/docs/mermaid-init.js +0 -0
  33. {llmshim-0.3.5 → llmshim-0.3.6}/docs/mermaid.min.js +0 -0
  34. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/SUMMARY.md +0 -0
  35. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/contracts.md +0 -0
  36. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/conversations.md +0 -0
  37. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/portability.md +0 -0
  38. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/routing.md +0 -0
  39. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/concepts/translation-flow.md +0 -0
  40. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/fallbacks.md +0 -0
  41. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/images.md +0 -0
  42. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/native-controls.md +0 -0
  43. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/reasoning.md +0 -0
  44. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/streaming.md +0 -0
  45. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/guides/tools.md +0 -0
  46. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/introduction.md +0 -0
  47. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/proxy/deployment.md +0 -0
  48. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/proxy/http-api.md +0 -0
  49. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/proxy/scaling.md +0 -0
  50. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/api.md +0 -0
  51. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/cli.md +0 -0
  52. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/configuration.md +0 -0
  53. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/models.md +0 -0
  54. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/providers.md +0 -0
  55. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/request-fields.md +0 -0
  56. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/reference/surfaces.md +0 -0
  57. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/choose.md +0 -0
  58. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/cli.md +0 -0
  59. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/clients.md +0 -0
  60. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/configure.md +0 -0
  61. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/proxy.md +0 -0
  62. {llmshim-0.3.5 → llmshim-0.3.6}/docs/src/start/rust.md +0 -0
  63. {llmshim-0.3.5 → llmshim-0.3.6}/examples/chat.rs +0 -0
  64. {llmshim-0.3.5 → llmshim-0.3.6}/examples/stream.rs +0 -0
  65. {llmshim-0.3.5 → llmshim-0.3.6}/llmshim/__init__.py +0 -0
  66. {llmshim-0.3.5 → llmshim-0.3.6}/llmshim/_client.py +0 -0
  67. {llmshim-0.3.5 → llmshim-0.3.6}/llmshim/_server.py +0 -0
  68. {llmshim-0.3.5 → llmshim-0.3.6}/llmshim/types.py +0 -0
  69. {llmshim-0.3.5 → llmshim-0.3.6}/pyproject.toml +0 -0
  70. {llmshim-0.3.5 → llmshim-0.3.6}/src/config.rs +0 -0
  71. {llmshim-0.3.5 → llmshim-0.3.6}/src/env.rs +0 -0
  72. {llmshim-0.3.5 → llmshim-0.3.6}/src/error.rs +0 -0
  73. {llmshim-0.3.5 → llmshim-0.3.6}/src/fallback.rs +0 -0
  74. {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/auth.rs +0 -0
  75. {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/distributed.rs +0 -0
  76. {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/http.rs +0 -0
  77. {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/idempotency.rs +0 -0
  78. {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/metrics.rs +0 -0
  79. {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/mod.rs +0 -0
  80. {llmshim-0.3.5 → llmshim-0.3.6}/src/gateway/quota.rs +0 -0
  81. {llmshim-0.3.5 → llmshim-0.3.6}/src/lib.rs +0 -0
  82. {llmshim-0.3.5 → llmshim-0.3.6}/src/log.rs +0 -0
  83. {llmshim-0.3.5 → llmshim-0.3.6}/src/main.rs +0 -0
  84. {llmshim-0.3.5 → llmshim-0.3.6}/src/models.rs +0 -0
  85. {llmshim-0.3.5 → llmshim-0.3.6}/src/provider.rs +0 -0
  86. {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/mod.rs +0 -0
  87. {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/openai_compat.rs +0 -0
  88. {llmshim-0.3.5 → llmshim-0.3.6}/src/providers/openrouter.rs +0 -0
  89. {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/convert.rs +0 -0
  90. {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/error.rs +0 -0
  91. {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/handlers.rs +0 -0
  92. {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/mod.rs +0 -0
  93. {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/ratelimit.rs +0 -0
  94. {llmshim-0.3.5 → llmshim-0.3.6}/src/proxy/types.rs +0 -0
  95. {llmshim-0.3.5 → llmshim-0.3.6}/src/router.rs +0 -0
  96. {llmshim-0.3.5 → llmshim-0.3.6}/src/vision.rs +0 -0
  97. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration.rs +0 -0
  98. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_fallback.rs +0 -0
  99. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_gemini.rs +0 -0
  100. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_gemini_tools.rs +0 -0
  101. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_long_context.rs +0 -0
  102. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_multimodel.rs +0 -0
  103. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_openrouter.rs +0 -0
  104. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_proxy.rs +0 -0
  105. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_sglang.rs +0 -0
  106. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_thinking.rs +0 -0
  107. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_tool_roundtrip.rs +0 -0
  108. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_vision.rs +0 -0
  109. {llmshim-0.3.5 → llmshim-0.3.6}/tests/integration_xai.rs +0 -0
  110. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_anthropic.rs +0 -0
  111. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_fallback.rs +0 -0
  112. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_fast_mode.rs +0 -0
  113. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_gemini.rs +0 -0
  114. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_log.rs +0 -0
  115. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_models.rs +0 -0
  116. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_multimodel.rs +0 -0
  117. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_openai_compat.rs +0 -0
  118. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_openrouter.rs +0 -0
  119. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_proxy.rs +0 -0
  120. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_proxy_convert.rs +0 -0
  121. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_router.rs +0 -0
  122. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_sse.rs +0 -0
  123. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_tools.rs +0 -0
  124. {llmshim-0.3.5 → llmshim-0.3.6}/tests/unit_vision.rs +0 -0
  125. {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`)
@@ -958,7 +958,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
958
958
 
959
959
  [[package]]
960
960
  name = "llmshim"
961
- version = "0.3.5"
961
+ version = "0.3.6"
962
962
  dependencies = [
963
963
  "async-stream",
964
964
  "async-trait",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "llmshim"
3
- version = "0.3.5"
3
+ version = "0.3.6"
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.3.5
3
+ Version: 0.3.6
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -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
- .get("stop_reason")
443
- .and_then(|r| r.as_str())
444
- .map(|r| match r {
445
- "end_turn" => "stop",
446
- "max_tokens" => "length",
447
- "tool_use" => "tool_calls",
448
- other => other,
449
- })
450
- .unwrap_or("stop");
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
- Ok(transform_response_to_openai(model, &response))
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
- .get("finishReason")
545
- .and_then(|f| f.as_str())
546
- .map(|f| match f {
547
- "STOP" => "stop",
548
- "MAX_TOKENS" => "length",
549
- "SAFETY" => "content_filter",
550
- _ => "stop",
551
- })
552
- .unwrap_or("stop");
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 status = response
543
- .get("status")
544
- .and_then(|s| s.as_str())
545
- .unwrap_or("completed");
546
- let finish_reason = match status {
547
- "completed" => "stop",
548
- "incomplete" => "length",
549
- _ => "stop",
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 status = response
383
- .get("status")
384
- .and_then(|s| s.as_str())
385
- .unwrap_or("completed");
386
- let finish_reason = match status {
387
- "completed" => "stop",
388
- "incomplete" => "length",
389
- _ => "stop",
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
+ }
@@ -2,6 +2,9 @@ use llmshim::provider::Provider;
2
2
  use llmshim::providers::openai::OpenAi;
3
3
  use serde_json::{json, Value};
4
4
 
5
+ #[path = "support/completion_status.rs"]
6
+ mod completion_status;
7
+
5
8
  fn provider() -> OpenAi {
6
9
  OpenAi::new("test-key-123".into())
7
10
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes