llmshim 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {llmshim-0.2.0 → llmshim-0.2.2}/CLAUDE.md +2 -2
  2. {llmshim-0.2.0 → llmshim-0.2.2}/Cargo.lock +1 -1
  3. {llmshim-0.2.0 → llmshim-0.2.2}/Cargo.toml +1 -1
  4. {llmshim-0.2.0 → llmshim-0.2.2}/PKG-INFO +1 -1
  5. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/reference/request-fields.md +16 -0
  6. {llmshim-0.2.0 → llmshim-0.2.2}/src/providers/anthropic.rs +123 -4
  7. {llmshim-0.2.0 → llmshim-0.2.2}/src/providers/openai.rs +4 -0
  8. {llmshim-0.2.0 → llmshim-0.2.2}/src/providers/xai.rs +4 -0
  9. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_thinking.rs +104 -0
  10. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_anthropic.rs +270 -2
  11. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_gemini.rs +22 -0
  12. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_openai.rs +22 -0
  13. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_xai.rs +22 -0
  14. {llmshim-0.2.0 → llmshim-0.2.2}/.github/workflows/pages.yml +0 -0
  15. {llmshim-0.2.0 → llmshim-0.2.2}/.github/workflows/release.yml +0 -0
  16. {llmshim-0.2.0 → llmshim-0.2.2}/.gitignore +0 -0
  17. {llmshim-0.2.0 → llmshim-0.2.2}/LICENSE +0 -0
  18. {llmshim-0.2.0 → llmshim-0.2.2}/README.md +0 -0
  19. {llmshim-0.2.0 → llmshim-0.2.2}/benchmarks/bench.rs +0 -0
  20. {llmshim-0.2.0 → llmshim-0.2.2}/benchmarks/bench_python.py +0 -0
  21. {llmshim-0.2.0 → llmshim-0.2.2}/benchmarks/loadtest.rs +0 -0
  22. {llmshim-0.2.0 → llmshim-0.2.2}/docs/.gitignore +0 -0
  23. {llmshim-0.2.0 → llmshim-0.2.2}/docs/book.toml +0 -0
  24. {llmshim-0.2.0 → llmshim-0.2.2}/docs/mermaid-init.js +0 -0
  25. {llmshim-0.2.0 → llmshim-0.2.2}/docs/mermaid.min.js +0 -0
  26. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/SUMMARY.md +0 -0
  27. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/concepts/contracts.md +0 -0
  28. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/concepts/conversations.md +0 -0
  29. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/concepts/portability.md +0 -0
  30. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/concepts/routing.md +0 -0
  31. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/concepts/translation-flow.md +0 -0
  32. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/guides/fallbacks.md +0 -0
  33. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/guides/images.md +0 -0
  34. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/guides/native-controls.md +0 -0
  35. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/guides/reasoning.md +0 -0
  36. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/guides/streaming.md +0 -0
  37. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/guides/tools.md +0 -0
  38. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/introduction.md +0 -0
  39. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/proxy/deployment.md +0 -0
  40. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/proxy/http-api.md +0 -0
  41. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/proxy/scaling.md +0 -0
  42. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/reference/api.md +0 -0
  43. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/reference/cli.md +0 -0
  44. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/reference/configuration.md +0 -0
  45. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/reference/errors.md +0 -0
  46. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/reference/models.md +0 -0
  47. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/reference/providers.md +0 -0
  48. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/reference/surfaces.md +0 -0
  49. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/start/choose.md +0 -0
  50. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/start/cli.md +0 -0
  51. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/start/clients.md +0 -0
  52. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/start/configure.md +0 -0
  53. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/start/proxy.md +0 -0
  54. {llmshim-0.2.0 → llmshim-0.2.2}/docs/src/start/rust.md +0 -0
  55. {llmshim-0.2.0 → llmshim-0.2.2}/examples/chat.rs +0 -0
  56. {llmshim-0.2.0 → llmshim-0.2.2}/examples/stream.rs +0 -0
  57. {llmshim-0.2.0 → llmshim-0.2.2}/llmshim/__init__.py +0 -0
  58. {llmshim-0.2.0 → llmshim-0.2.2}/llmshim/_client.py +0 -0
  59. {llmshim-0.2.0 → llmshim-0.2.2}/llmshim/_server.py +0 -0
  60. {llmshim-0.2.0 → llmshim-0.2.2}/llmshim/types.py +0 -0
  61. {llmshim-0.2.0 → llmshim-0.2.2}/pyproject.toml +0 -0
  62. {llmshim-0.2.0 → llmshim-0.2.2}/src/client.rs +0 -0
  63. {llmshim-0.2.0 → llmshim-0.2.2}/src/config.rs +0 -0
  64. {llmshim-0.2.0 → llmshim-0.2.2}/src/env.rs +0 -0
  65. {llmshim-0.2.0 → llmshim-0.2.2}/src/error.rs +0 -0
  66. {llmshim-0.2.0 → llmshim-0.2.2}/src/fallback.rs +0 -0
  67. {llmshim-0.2.0 → llmshim-0.2.2}/src/lib.rs +0 -0
  68. {llmshim-0.2.0 → llmshim-0.2.2}/src/log.rs +0 -0
  69. {llmshim-0.2.0 → llmshim-0.2.2}/src/main.rs +0 -0
  70. {llmshim-0.2.0 → llmshim-0.2.2}/src/models.rs +0 -0
  71. {llmshim-0.2.0 → llmshim-0.2.2}/src/provider.rs +0 -0
  72. {llmshim-0.2.0 → llmshim-0.2.2}/src/providers/gemini.rs +0 -0
  73. {llmshim-0.2.0 → llmshim-0.2.2}/src/providers/mod.rs +0 -0
  74. {llmshim-0.2.0 → llmshim-0.2.2}/src/proxy/convert.rs +0 -0
  75. {llmshim-0.2.0 → llmshim-0.2.2}/src/proxy/error.rs +0 -0
  76. {llmshim-0.2.0 → llmshim-0.2.2}/src/proxy/handlers.rs +0 -0
  77. {llmshim-0.2.0 → llmshim-0.2.2}/src/proxy/mod.rs +0 -0
  78. {llmshim-0.2.0 → llmshim-0.2.2}/src/proxy/ratelimit.rs +0 -0
  79. {llmshim-0.2.0 → llmshim-0.2.2}/src/proxy/types.rs +0 -0
  80. {llmshim-0.2.0 → llmshim-0.2.2}/src/router.rs +0 -0
  81. {llmshim-0.2.0 → llmshim-0.2.2}/src/vision.rs +0 -0
  82. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration.rs +0 -0
  83. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_fallback.rs +0 -0
  84. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_gemini.rs +0 -0
  85. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_gemini_tools.rs +0 -0
  86. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_long_context.rs +0 -0
  87. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_multimodel.rs +0 -0
  88. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_proxy.rs +0 -0
  89. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_tool_roundtrip.rs +0 -0
  90. {llmshim-0.2.0 → llmshim-0.2.2}/tests/integration_vision.rs +0 -0
  91. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_fallback.rs +0 -0
  92. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_fast_mode.rs +0 -0
  93. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_log.rs +0 -0
  94. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_models.rs +0 -0
  95. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_multimodel.rs +0 -0
  96. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_proxy.rs +0 -0
  97. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_proxy_convert.rs +0 -0
  98. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_router.rs +0 -0
  99. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_sse.rs +0 -0
  100. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_tools.rs +0 -0
  101. {llmshim-0.2.0 → llmshim-0.2.2}/tests/unit_vision.rs +0 -0
@@ -70,7 +70,7 @@ Image content blocks are translated between providers automatically. Users can s
70
70
 
71
71
  ### Multi-model conversations
72
72
 
73
- Each provider sanitizes messages from other providers in `transform_request`. OpenAI's `annotations`/`refusal` stripped for Anthropic/Gemini. `reasoning_content` stripped for all. Tool calls normalized to OpenAI format in responses, translated back per-provider on input.
73
+ Each provider sanitizes messages from other providers in `transform_request`. OpenAI's `annotations`/`refusal` stripped for Anthropic/Gemini. `reasoning_content` is stripped by other providers, but **Anthropic reconstructs a native `thinking` block** from `reasoning_content` + `reasoning_signature` (and `redacted_thinking` from `redacted_reasoning_content`) as the first block of the assistant turn, so extended-thinking + tool-use round-trips losslessly (surfaced on responses incl. streaming; opaque signatures are stripped by other providers so they never leak cross-provider; no signature → still stripped). Symmetric to the tool-call `thought_signature` round-trip. Tool calls normalized to OpenAI format in responses, translated back per-provider on input.
74
74
 
75
75
  ### Provider extension namespaces (`x-anthropic`, `x-gemini`)
76
76
 
@@ -81,7 +81,7 @@ Callers pass provider-specific controls under these keys. Each provider copies w
81
81
 
82
82
  ### Unified reasoning controls
83
83
 
84
- Two knobs work across every provider: `reasoning_effort` (`none|low|medium|high|xhigh|max`) and `reasoning_mode` (`standard|pro`). Each provider transform maps them to its native dialect, **clamping to the nearest tier the target model accepts** (all boundaries verified live — e.g. `max` is native only on OpenAI gpt-5.6; Anthropic 4.6 rejects `xhigh` but has `max`; Gemini's enum tops out at `high`; xAI grok-4.20 models reject any reasoning param). `mode: "pro"` is native on OpenAI gpt-5.6/-pro models (`reasoning.mode`), emulated as a one-tier effort bump elsewhere; explicit `none` always wins. Native passthrough (`x-openai.reasoning`, `x-anthropic.thinking`, `x-gemini.thinkingConfig`) bypasses the mapping entirely and always takes precedence. **Full per-provider mapping tables: `docs/src/guides/reasoning.md`** — update it and the pinning tests in `tests/unit_*.rs` together whenever a mapping changes.
84
+ Two knobs work across every provider: `reasoning_effort` (`none|low|medium|high|xhigh|max`) and `reasoning_mode` (`standard|pro`). A third, `reasoning_summary` (`auto|none`), controls reasoning-text visibility → Anthropic `thinking.display` (`auto`→`summarized`, the default when `reasoning_effort` is present so newer models like Sonnet 5 / Opus 4.7-4.8 return reasoning text instead of the API-default `omitted`; `none`→`omitted` for lower latency). Applies to both the adaptive and pre-4.6 enabled thinking builders; a caller-supplied `thinking` block bypasses it. Each provider transform maps them to its native dialect, **clamping to the nearest tier the target model accepts** (all boundaries verified live — e.g. `max` is native only on OpenAI gpt-5.6; Anthropic 4.6 rejects `xhigh` but has `max`; Gemini's enum tops out at `high`; xAI grok-4.20 models reject any reasoning param). `mode: "pro"` is native on OpenAI gpt-5.6/-pro models (`reasoning.mode`), emulated as a one-tier effort bump elsewhere; explicit `none` always wins. Native passthrough (`x-openai.reasoning`, `x-anthropic.thinking`, `x-gemini.thinkingConfig`) bypasses the mapping entirely and always takes precedence. **Full per-provider mapping tables: `docs/src/guides/reasoning.md`** — update it and the pinning tests in `tests/unit_*.rs` together whenever a mapping changes.
85
85
 
86
86
  ### Tool format translation
87
87
 
@@ -958,7 +958,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
958
958
 
959
959
  [[package]]
960
960
  name = "llmshim"
961
- version = "0.2.0"
961
+ version = "0.2.2"
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.2.0"
3
+ version = "0.2.2"
4
4
  edition = "2021"
5
5
  description = "Blazing fast LLM API translation layer in pure Rust"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llmshim
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -23,6 +23,7 @@ renamed or reshaped for the selected provider; unsupported controls are omitted.
23
23
  | `stop` | array of strings | Conditional, mapped | Passed as `stop` to Anthropic or mapped to Gemini `stopSequences`; omitted by OpenAI/xAI |
24
24
  | `reasoning_effort` | string | Portable, mapped/clamped | Unified effort from `none` through `max` |
25
25
  | `reasoning_mode` | string | Portable, mapped/clamped | `standard` or `pro` |
26
+ | `reasoning_summary` | string | Anthropic (`auto`\|`none`) | Reasoning visibility → Anthropic `thinking.display`; `auto`→`summarized` (default with `reasoning_effort`), `none`→`omitted` for lower latency |
26
27
  | `tools` | array | Portable, mapped | OpenAI Chat Completions function schema translated to native tools |
27
28
  | `tool_choice` | string or object | Portable, mapped | Translated where the provider supports tool choice |
28
29
  | `x-openai` | object | Native passthrough | Fields copied to an OpenAI Responses request |
@@ -52,10 +53,25 @@ are covered in [Native provider controls](../guides/native-controls.md).
52
53
  | `tool_calls` | array | Assistant tool requests returned on a previous turn |
53
54
  | `tool_call_id` | string | Connects a `role: "tool"` result to its request |
54
55
  | `reasoning_content` | string | Provider-returned reasoning carried into a later turn |
56
+ | `reasoning_signature` | string | Opaque provider signature for the reasoning block; echo it back with `reasoning_content` for lossless round-trip |
57
+ | `redacted_reasoning_content` | string | Opaque data for a redacted reasoning block; echo it back to reconstruct it |
55
58
 
56
59
  Content blocks may use OpenAI `image_url`, Anthropic `image`, or Gemini
57
60
  `inline_data` input forms. See [Images and vision](../guides/images.md).
58
61
 
62
+ ### Lossless reasoning round-trip
63
+
64
+ A thinking-capable Anthropic model returns `reasoning_content` **and**
65
+ `reasoning_signature` (streaming emits both incrementally; the signature arrives
66
+ as a `reasoning_signature` delta). Echo the assistant message back verbatim on a
67
+ follow-up request and llmshim reconstructs the provider-native `thinking` block
68
+ (as the first block of the turn) — required for extended-thinking + tool-use
69
+ continuations and for keeping the prompt cache warm. The signature is opaque and
70
+ provider-specific: other providers strip it, so it never leaks in a multi-model
71
+ conversation. Absent a signature, `reasoning_content` is stripped (a thinking
72
+ block without its signature is rejected). Symmetric to the tool-call
73
+ `thought_signature` round-trip.
74
+
59
75
  ## Proxy request
60
76
 
61
77
  | Field | Type | Class | Engine destination |
@@ -161,10 +161,29 @@ fn transform_messages(messages: &[Value]) -> Vec<Value> {
161
161
  .map(|msg| {
162
162
  let mut out = msg.clone();
163
163
 
164
+ // Capture normalized reasoning fields before sanitizing, so an
165
+ // assistant turn's thinking block can be reconstructed losslessly
166
+ // below (symmetric to the tool-call thought_signature round-trip).
167
+ let role = out.get("role").and_then(|r| r.as_str()).map(str::to_string);
168
+ let reasoning_content = out
169
+ .get("reasoning_content")
170
+ .and_then(|v| v.as_str())
171
+ .map(str::to_string);
172
+ let reasoning_signature = out
173
+ .get("reasoning_signature")
174
+ .and_then(|v| v.as_str())
175
+ .map(str::to_string);
176
+ let redacted_reasoning = out
177
+ .get("redacted_reasoning_content")
178
+ .and_then(|v| v.as_str())
179
+ .map(str::to_string);
180
+
164
181
  // Sanitize cross-provider fields that Anthropic's API rejects.
165
182
  // This enables multi-model conversations (e.g., Cursor-style provider switching).
166
183
  if let Some(obj) = out.as_object_mut() {
167
184
  obj.remove("reasoning_content"); // our normalized thinking field
185
+ obj.remove("reasoning_signature"); // reconstructed into a thinking block below
186
+ obj.remove("redacted_reasoning_content"); // reconstructed into redacted_thinking below
168
187
  obj.remove("annotations"); // OpenAI returns this on every message
169
188
  obj.remove("refusal"); // OpenAI safety refusal field
170
189
  obj.remove("audio"); // OpenAI audio response field
@@ -236,6 +255,38 @@ fn transform_messages(messages: &[Value]) -> Vec<Value> {
236
255
  });
237
256
  }
238
257
 
258
+ // Reconstruct thinking block(s) as the FIRST content block(s) of an
259
+ // assistant turn so extended-thinking + tool-use continuations are
260
+ // accepted (the API requires thinking before text/tool_use). Only when
261
+ // we hold the opaque token — a thinking block without its signature is
262
+ // rejected, so absent a signature we leave it stripped (no regression).
263
+ if role.as_deref() == Some("assistant") {
264
+ let mut thinking_blocks: Vec<Value> = Vec::new();
265
+ if let (Some(text), Some(sig)) = (&reasoning_content, &reasoning_signature) {
266
+ thinking_blocks.push(json!({
267
+ "type": "thinking",
268
+ "thinking": text,
269
+ "signature": sig,
270
+ }));
271
+ }
272
+ if let Some(data) = &redacted_reasoning {
273
+ thinking_blocks.push(json!({
274
+ "type": "redacted_thinking",
275
+ "data": data,
276
+ }));
277
+ }
278
+ if !thinking_blocks.is_empty() {
279
+ match out.get("content").cloned() {
280
+ Some(Value::Array(arr)) => thinking_blocks.extend(arr),
281
+ Some(Value::String(s)) if !s.is_empty() => {
282
+ thinking_blocks.push(json!({"type": "text", "text": s}))
283
+ }
284
+ _ => {}
285
+ }
286
+ out["content"] = json!(thinking_blocks);
287
+ }
288
+ }
289
+
239
290
  out
240
291
  })
241
292
  .collect()
@@ -339,6 +390,10 @@ fn transform_response_to_openai(model: &str, resp: &Value) -> Value {
339
390
  let mut text_parts: Vec<String> = Vec::new();
340
391
  let mut tool_calls: Vec<Value> = Vec::new();
341
392
  let mut thinking_content: Option<String> = None;
393
+ // Opaque signature + redacted data so reasoning can round-trip losslessly
394
+ // (see transform_messages reconstruction). Symmetric to tool thought_signature.
395
+ let mut thinking_signature: Option<String> = None;
396
+ let mut redacted_thinking: Option<String> = None;
342
397
 
343
398
  for block in &content_blocks {
344
399
  match block.get("type").and_then(|t| t.as_str()) {
@@ -351,6 +406,14 @@ fn transform_response_to_openai(model: &str, resp: &Value) -> Value {
351
406
  if let Some(t) = block.get("thinking").and_then(|t| t.as_str()) {
352
407
  thinking_content = Some(t.to_string());
353
408
  }
409
+ if let Some(s) = block.get("signature").and_then(|s| s.as_str()) {
410
+ thinking_signature = Some(s.to_string());
411
+ }
412
+ }
413
+ Some("redacted_thinking") => {
414
+ if let Some(d) = block.get("data").and_then(|d| d.as_str()) {
415
+ redacted_thinking = Some(d.to_string());
416
+ }
354
417
  }
355
418
  Some("tool_use") => {
356
419
  tool_calls.push(json!({
@@ -399,6 +462,14 @@ fn transform_response_to_openai(model: &str, resp: &Value) -> Value {
399
462
  if let Some(thinking) = thinking_content {
400
463
  message["reasoning_content"] = json!(thinking);
401
464
  }
465
+ // Surface the opaque signature + redacted data so the reasoning block can be
466
+ // echoed back losslessly on a follow-up request (see transform_messages).
467
+ if let Some(sig) = thinking_signature {
468
+ message["reasoning_signature"] = json!(sig);
469
+ }
470
+ if let Some(data) = redacted_thinking {
471
+ message["redacted_reasoning_content"] = json!(data);
472
+ }
402
473
 
403
474
  json!({
404
475
  "id": resp.get("id").cloned().unwrap_or(json!("")),
@@ -532,6 +603,17 @@ impl Provider for Anthropic {
532
603
  .unwrap_or(false);
533
604
  let effort = normalize_unified_effort(effort, pro);
534
605
 
606
+ // Reasoning-summary visibility. Newer models (Sonnet 5, Opus
607
+ // 4.7/4.8, ...) default `display` to "omitted" — a signed but
608
+ // empty thinking block with no thinking_delta text. Default to
609
+ // "summarized" so reasoning text is returned consistently across
610
+ // model generations; a latency-sensitive caller opts back into
611
+ // "omitted" via reasoning_summary. Verified live 2026-07-17.
612
+ let display = match obj.get("reasoning_summary").and_then(|v| v.as_str()) {
613
+ Some("none") | Some("omitted") => "omitted",
614
+ _ => "summarized",
615
+ };
616
+
535
617
  if effort == "none" {
536
618
  if Self::uses_adaptive_thinking(model) {
537
619
  // Adaptive models think by default even with no config;
@@ -541,7 +623,10 @@ impl Provider for Anthropic {
541
623
  }
542
624
  // Pre-4.6/Haiku: thinking is opt-in; omitting the key IS "none".
543
625
  } else if Self::uses_adaptive_thinking(model) {
544
- body_obj.insert("thinking".to_string(), json!({"type": "adaptive"}));
626
+ body_obj.insert(
627
+ "thinking".to_string(),
628
+ json!({"type": "adaptive", "display": display}),
629
+ );
545
630
  // Opus/Sonnet 4.6 reject "xhigh" (their tiers: low/medium/high/max);
546
631
  // Opus 4.7/4.8 + Sonnet 5 accept the full low..max range (verified).
547
632
  let anthropic_effort =
@@ -573,7 +658,8 @@ impl Provider for Anthropic {
573
658
  "thinking".to_string(),
574
659
  json!({
575
660
  "type": "enabled",
576
- "budget_tokens": budget
661
+ "budget_tokens": budget,
662
+ "display": display
577
663
  }),
578
664
  );
579
665
  }
@@ -766,8 +852,25 @@ impl Provider for Anthropic {
766
852
  });
767
853
  Ok(Some(serde_json::to_string(&chunk)?))
768
854
  }
769
- // signature_delta: skip (opaque verification, not useful to consumers)
770
- Some("signature_delta") => Ok(None),
855
+ // signature_delta: emit the opaque signature so a streaming
856
+ // consumer can reassemble a complete, round-trippable thinking
857
+ // block (fed back via reasoning_signature on the next request).
858
+ Some("signature_delta") => {
859
+ let signature = delta
860
+ .get("signature")
861
+ .and_then(|s| s.as_str())
862
+ .unwrap_or("");
863
+ let chunk = json!({
864
+ "object": "chat.completion.chunk",
865
+ "model": model,
866
+ "choices": [{
867
+ "index": 0,
868
+ "delta": { "reasoning_signature": signature },
869
+ "finish_reason": null,
870
+ }]
871
+ });
872
+ Ok(Some(serde_json::to_string(&chunk)?))
873
+ }
771
874
  _ => Ok(None),
772
875
  }
773
876
  }
@@ -797,6 +900,22 @@ impl Provider for Anthropic {
797
900
  });
798
901
  return Ok(Some(serde_json::to_string(&chunk)?));
799
902
  }
903
+ // redacted_thinking arrives whole (no deltas); surface its
904
+ // opaque data so it can be echoed back on a later request.
905
+ if cb.get("type").and_then(|t| t.as_str()) == Some("redacted_thinking") {
906
+ if let Some(data) = cb.get("data").and_then(|d| d.as_str()) {
907
+ let chunk = json!({
908
+ "object": "chat.completion.chunk",
909
+ "model": model,
910
+ "choices": [{
911
+ "index": 0,
912
+ "delta": { "redacted_reasoning_content": data },
913
+ "finish_reason": null,
914
+ }]
915
+ });
916
+ return Ok(Some(serde_json::to_string(&chunk)?));
917
+ }
918
+ }
800
919
  }
801
920
  Ok(None)
802
921
  }
@@ -57,6 +57,8 @@ fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
57
57
  let mut out = msg.clone();
58
58
  if let Some(obj) = out.as_object_mut() {
59
59
  obj.remove("reasoning_content");
60
+ obj.remove("reasoning_signature"); // opaque Anthropic token — never forward
61
+ obj.remove("redacted_reasoning_content"); // opaque Anthropic token — never forward
60
62
  obj.remove("annotations");
61
63
  obj.remove("refusal");
62
64
  obj.remove("tool_calls"); // Handled separately below.
@@ -130,6 +132,8 @@ fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
130
132
  let mut out = msg.clone();
131
133
  if let Some(obj) = out.as_object_mut() {
132
134
  obj.remove("reasoning_content");
135
+ obj.remove("reasoning_signature"); // opaque Anthropic token — never forward
136
+ obj.remove("redacted_reasoning_content"); // opaque Anthropic token — never forward
133
137
  obj.remove("annotations");
134
138
  obj.remove("refusal");
135
139
  }
@@ -39,6 +39,8 @@ fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
39
39
  let mut out = msg.clone();
40
40
  if let Some(obj) = out.as_object_mut() {
41
41
  obj.remove("reasoning_content");
42
+ obj.remove("reasoning_signature"); // opaque Anthropic token — never forward
43
+ obj.remove("redacted_reasoning_content"); // opaque Anthropic token — never forward
42
44
  obj.remove("annotations");
43
45
  obj.remove("refusal");
44
46
  obj.remove("tool_calls");
@@ -108,6 +110,8 @@ fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
108
110
  let mut out = msg.clone();
109
111
  if let Some(obj) = out.as_object_mut() {
110
112
  obj.remove("reasoning_content");
113
+ obj.remove("reasoning_signature"); // opaque Anthropic token — never forward
114
+ obj.remove("redacted_reasoning_content"); // opaque Anthropic token — never forward
111
115
  obj.remove("annotations");
112
116
  obj.remove("refusal");
113
117
  }
@@ -330,3 +330,107 @@ async fn three_hop_with_thinking_in_middle() {
330
330
  content3
331
331
  );
332
332
  }
333
+
334
+ // ============================================================
335
+ // Reasoning round-trip (issue #34) — lossless thinking + tool-use continuation
336
+ // ============================================================
337
+
338
+ #[tokio::test]
339
+ #[ignore]
340
+ async fn anthropic_reasoning_roundtrip_accepted_live() {
341
+ if std::env::var("ANTHROPIC_API_KEY").is_err() {
342
+ return;
343
+ }
344
+ let router = router();
345
+ let model = "anthropic/claude-sonnet-4-6";
346
+ let tools = json!([{
347
+ "type": "function",
348
+ "function": {
349
+ "name": "get_weather",
350
+ "description": "Get the current weather for a city",
351
+ "parameters": {
352
+ "type": "object",
353
+ "properties": {"city": {"type": "string"}},
354
+ "required": ["city"]
355
+ }
356
+ }
357
+ }]);
358
+ let user_turn = json!({"role": "user", "content": "What's the weather in Tokyo? Use the get_weather tool."});
359
+
360
+ // Turn 1: thinking on + a tool available → expect thinking + a tool call.
361
+ let req1 = json!({
362
+ "model": model,
363
+ "messages": [user_turn],
364
+ "max_tokens": 4000,
365
+ "reasoning_effort": "high",
366
+ "tools": tools,
367
+ });
368
+ let resp1 = llmshim::completion(&router, &req1).await.unwrap();
369
+ let msg1 = &resp1["choices"][0]["message"];
370
+
371
+ let tool_calls = msg1
372
+ .get("tool_calls")
373
+ .and_then(|t| t.as_array())
374
+ .filter(|a| !a.is_empty());
375
+ assert!(tool_calls.is_some(), "expected a tool call, got: {resp1}");
376
+ assert!(
377
+ msg1.get("reasoning_signature").is_some(),
378
+ "expected reasoning_signature to be surfaced, got: {msg1}"
379
+ );
380
+ let call_id = tool_calls.unwrap()[0]["id"].as_str().unwrap().to_string();
381
+
382
+ // Turn 2: echo the assistant message VERBATIM (reasoning_content +
383
+ // reasoning_signature + tool_calls) and add the tool result. The shim must
384
+ // reconstruct a valid thinking block first — the API must accept it (200).
385
+ let req2 = json!({
386
+ "model": model,
387
+ "messages": [
388
+ user_turn,
389
+ msg1.clone(),
390
+ {"role": "tool", "tool_call_id": call_id, "content": "72F and sunny"}
391
+ ],
392
+ "max_tokens": 4000,
393
+ "reasoning_effort": "high",
394
+ "tools": tools,
395
+ });
396
+ let resp2 = llmshim::completion(&router, &req2)
397
+ .await
398
+ .expect("reconstructed thinking block must be accepted by the API");
399
+ assert_eq!(resp2["object"], "chat.completion");
400
+ let content = resp2["choices"][0]["message"]["content"]
401
+ .as_str()
402
+ .unwrap_or("");
403
+ assert!(!content.is_empty(), "expected a final answer, got: {resp2}");
404
+ println!("Round-trip accepted. Final answer: {content}");
405
+ }
406
+
407
+ #[tokio::test]
408
+ #[ignore]
409
+ async fn anthropic_sonnet5_reasoning_summary_default_returns_text_live() {
410
+ // Issue #37: sonnet-5 defaults display to "omitted" (empty thinking text).
411
+ // With the fix, reasoning_effort defaults display to "summarized" so real
412
+ // reasoning text comes back.
413
+ if std::env::var("ANTHROPIC_API_KEY").is_err() {
414
+ return;
415
+ }
416
+ let router = router();
417
+ // A prompt that reliably engages adaptive thinking (trivial prompts may be
418
+ // answered directly even at high effort — "adaptive" means the model decides).
419
+ let prompt = "Prove or disprove: for every positive integer n, the value \
420
+ n^2 + n + 41 is prime. Give rigorous reasoning.";
421
+ let req = json!({
422
+ "model": "anthropic/claude-sonnet-5",
423
+ "messages": [{"role": "user", "content": prompt}],
424
+ "max_tokens": 4000,
425
+ "reasoning_effort": "high",
426
+ });
427
+ let resp = llmshim::completion(&router, &req).await.unwrap();
428
+ let reasoning = resp["choices"][0]["message"]["reasoning_content"]
429
+ .as_str()
430
+ .unwrap_or("");
431
+ assert!(
432
+ !reasoning.is_empty(),
433
+ "expected reasoning text by default (display=summarized), got empty. resp: {resp}"
434
+ );
435
+ println!("sonnet-5 reasoning text chars: {}", reasoning.len());
436
+ }
@@ -1131,7 +1131,9 @@ fn stream_thinking_delta() {
1131
1131
  }
1132
1132
 
1133
1133
  #[test]
1134
- fn stream_signature_delta_skipped() {
1134
+ fn stream_signature_delta_emits_signature() {
1135
+ // Issue #34: the signature is now surfaced (was previously dropped) so a
1136
+ // streaming consumer can reassemble a round-trippable thinking block.
1135
1137
  let p = provider();
1136
1138
  let chunk = json!({
1137
1139
  "type": "content_block_delta",
@@ -1139,8 +1141,13 @@ fn stream_signature_delta_skipped() {
1139
1141
  });
1140
1142
  let result = p
1141
1143
  .transform_stream_chunk("x", &serde_json::to_string(&chunk).unwrap())
1144
+ .unwrap()
1142
1145
  .unwrap();
1143
- assert!(result.is_none(), "signature_delta should be skipped");
1146
+ let parsed: Value = serde_json::from_str(&result).unwrap();
1147
+ assert_eq!(
1148
+ parsed["choices"][0]["delta"]["reasoning_signature"],
1149
+ "EqoBCkgIAxgC..."
1150
+ );
1144
1151
  }
1145
1152
 
1146
1153
  // ============================================================
@@ -1576,3 +1583,264 @@ fn top_level_thinking_passthrough_strips_temperature() {
1576
1583
  assert_eq!(result.body["thinking"]["type"], "adaptive");
1577
1584
  assert!(result.body.get("temperature").is_none());
1578
1585
  }
1586
+
1587
+ // ============================================================
1588
+ // Reasoning round-trip (issue #34): signature + redacted passthrough
1589
+ // ============================================================
1590
+
1591
+ #[test]
1592
+ fn response_surfaces_reasoning_signature() {
1593
+ let p = provider();
1594
+ let resp = json!({
1595
+ "id": "msg_r",
1596
+ "content": [
1597
+ {"type": "thinking", "thinking": "Let me think...", "signature": "sig-abc123"},
1598
+ {"type": "text", "text": "The answer is 42."}
1599
+ ],
1600
+ "stop_reason": "end_turn",
1601
+ "usage": {"input_tokens": 5, "output_tokens": 3}
1602
+ });
1603
+ let result = p.transform_response("claude-opus-4-8", resp).unwrap();
1604
+ let msg = &result["choices"][0]["message"];
1605
+ assert_eq!(msg["reasoning_content"], "Let me think...");
1606
+ assert_eq!(msg["reasoning_signature"], "sig-abc123");
1607
+ assert_eq!(msg["content"], "The answer is 42.");
1608
+ }
1609
+
1610
+ #[test]
1611
+ fn response_surfaces_redacted_reasoning() {
1612
+ let p = provider();
1613
+ let resp = json!({
1614
+ "id": "msg_r",
1615
+ "content": [
1616
+ {"type": "redacted_thinking", "data": "encrypted-blob-xyz"},
1617
+ {"type": "text", "text": "Done."}
1618
+ ],
1619
+ "stop_reason": "end_turn",
1620
+ "usage": {"input_tokens": 5, "output_tokens": 3}
1621
+ });
1622
+ let result = p.transform_response("claude-opus-4-8", resp).unwrap();
1623
+ assert_eq!(
1624
+ result["choices"][0]["message"]["redacted_reasoning_content"],
1625
+ "encrypted-blob-xyz"
1626
+ );
1627
+ }
1628
+
1629
+ #[test]
1630
+ fn request_reconstructs_thinking_block_first_with_tool_calls() {
1631
+ let p = provider();
1632
+ let req = json!({
1633
+ "model": "claude-opus-4-8",
1634
+ "messages": [
1635
+ {"role": "user", "content": "weather?"},
1636
+ {
1637
+ "role": "assistant",
1638
+ "content": "",
1639
+ "reasoning_content": "I should call the tool.",
1640
+ "reasoning_signature": "sig-xyz",
1641
+ "tool_calls": [{
1642
+ "id": "call_1",
1643
+ "type": "function",
1644
+ "function": {"name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}"}
1645
+ }]
1646
+ }
1647
+ ]
1648
+ });
1649
+ let result = p.transform_request("claude-opus-4-8", &req).unwrap();
1650
+ let content = &result.body["messages"][1]["content"];
1651
+ // Thinking block must be FIRST (with signature), before the tool_use block.
1652
+ assert_eq!(content[0]["type"], "thinking");
1653
+ assert_eq!(content[0]["thinking"], "I should call the tool.");
1654
+ assert_eq!(content[0]["signature"], "sig-xyz");
1655
+ assert_eq!(content[1]["type"], "tool_use");
1656
+ // Normalized fields must not leak raw on the message.
1657
+ assert!(result.body["messages"][1]
1658
+ .get("reasoning_content")
1659
+ .is_none());
1660
+ assert!(result.body["messages"][1]
1661
+ .get("reasoning_signature")
1662
+ .is_none());
1663
+ }
1664
+
1665
+ #[test]
1666
+ fn request_reconstructs_thinking_block_plain_text() {
1667
+ let p = provider();
1668
+ let req = json!({
1669
+ "model": "claude-opus-4-8",
1670
+ "messages": [{
1671
+ "role": "assistant",
1672
+ "content": "Final answer.",
1673
+ "reasoning_content": "thinking...",
1674
+ "reasoning_signature": "sig-1"
1675
+ }]
1676
+ });
1677
+ let result = p.transform_request("claude-opus-4-8", &req).unwrap();
1678
+ let content = &result.body["messages"][0]["content"];
1679
+ assert_eq!(content[0]["type"], "thinking");
1680
+ assert_eq!(content[0]["signature"], "sig-1");
1681
+ assert_eq!(content[1]["type"], "text");
1682
+ assert_eq!(content[1]["text"], "Final answer.");
1683
+ }
1684
+
1685
+ #[test]
1686
+ fn request_reconstructs_redacted_thinking() {
1687
+ let p = provider();
1688
+ let req = json!({
1689
+ "model": "claude-opus-4-8",
1690
+ "messages": [{
1691
+ "role": "assistant",
1692
+ "content": "ok",
1693
+ "redacted_reasoning_content": "blob-1"
1694
+ }]
1695
+ });
1696
+ let result = p.transform_request("claude-opus-4-8", &req).unwrap();
1697
+ let content = &result.body["messages"][0]["content"];
1698
+ assert_eq!(content[0]["type"], "redacted_thinking");
1699
+ assert_eq!(content[0]["data"], "blob-1");
1700
+ }
1701
+
1702
+ #[test]
1703
+ fn request_no_signature_strips_reasoning() {
1704
+ // Without a signature a thinking block would 400, so we strip (no regression).
1705
+ let p = provider();
1706
+ let req = json!({
1707
+ "model": "claude-opus-4-8",
1708
+ "messages": [{
1709
+ "role": "assistant",
1710
+ "content": "hi",
1711
+ "reasoning_content": "orphan thinking, no signature"
1712
+ }]
1713
+ });
1714
+ let result = p.transform_request("claude-opus-4-8", &req).unwrap();
1715
+ let msg = &result.body["messages"][0];
1716
+ assert!(msg.get("reasoning_content").is_none());
1717
+ assert_eq!(msg["content"], "hi"); // stays a plain string, no thinking block
1718
+ }
1719
+
1720
+ #[test]
1721
+ fn stream_signature_delta_emits_reasoning_signature() {
1722
+ let p = provider();
1723
+ let chunk = json!({
1724
+ "type": "content_block_delta",
1725
+ "delta": {"type": "signature_delta", "signature": "sig-stream-1"}
1726
+ });
1727
+ let result = p
1728
+ .transform_stream_chunk("x", &serde_json::to_string(&chunk).unwrap())
1729
+ .unwrap()
1730
+ .unwrap();
1731
+ let parsed: Value = serde_json::from_str(&result).unwrap();
1732
+ assert_eq!(
1733
+ parsed["choices"][0]["delta"]["reasoning_signature"],
1734
+ "sig-stream-1"
1735
+ );
1736
+ }
1737
+
1738
+ #[test]
1739
+ fn stream_redacted_thinking_block_start_emits() {
1740
+ let p = provider();
1741
+ let chunk = json!({
1742
+ "type": "content_block_start",
1743
+ "index": 0,
1744
+ "content_block": {"type": "redacted_thinking", "data": "blob-stream"}
1745
+ });
1746
+ let result = p
1747
+ .transform_stream_chunk("x", &serde_json::to_string(&chunk).unwrap())
1748
+ .unwrap()
1749
+ .unwrap();
1750
+ let parsed: Value = serde_json::from_str(&result).unwrap();
1751
+ assert_eq!(
1752
+ parsed["choices"][0]["delta"]["redacted_reasoning_content"],
1753
+ "blob-stream"
1754
+ );
1755
+ }
1756
+
1757
+ // ============================================================
1758
+ // Reasoning summary visibility (issue #37): thinking.display
1759
+ // ============================================================
1760
+
1761
+ #[test]
1762
+ fn adaptive_thinking_defaults_display_summarized() {
1763
+ let p = provider();
1764
+ let req = json!({
1765
+ "model": "claude-sonnet-5",
1766
+ "messages": [{"role": "user", "content": "hi"}],
1767
+ "reasoning_effort": "high",
1768
+ });
1769
+ let result = p.transform_request("claude-sonnet-5", &req).unwrap();
1770
+ assert_eq!(result.body["thinking"]["type"], "adaptive");
1771
+ assert_eq!(result.body["thinking"]["display"], "summarized");
1772
+ assert_eq!(result.body["output_config"]["effort"], "high");
1773
+ }
1774
+
1775
+ #[test]
1776
+ fn adaptive_thinking_reasoning_summary_none_omits() {
1777
+ let p = provider();
1778
+ let req = json!({
1779
+ "model": "claude-sonnet-5",
1780
+ "messages": [{"role": "user", "content": "hi"}],
1781
+ "reasoning_effort": "high",
1782
+ "reasoning_summary": "none",
1783
+ });
1784
+ let result = p.transform_request("claude-sonnet-5", &req).unwrap();
1785
+ assert_eq!(result.body["thinking"]["display"], "omitted");
1786
+ // The unified control must not leak into the API body.
1787
+ assert!(result.body.get("reasoning_summary").is_none());
1788
+ }
1789
+
1790
+ #[test]
1791
+ fn adaptive_thinking_reasoning_summary_auto_summarizes() {
1792
+ let p = provider();
1793
+ let req = json!({
1794
+ "model": "claude-opus-4-8",
1795
+ "messages": [{"role": "user", "content": "hi"}],
1796
+ "reasoning_effort": "medium",
1797
+ "reasoning_summary": "auto",
1798
+ });
1799
+ let result = p.transform_request("claude-opus-4-8", &req).unwrap();
1800
+ assert_eq!(result.body["thinking"]["display"], "summarized");
1801
+ }
1802
+
1803
+ #[test]
1804
+ fn enabled_thinking_forwards_display() {
1805
+ // Pre-4.6 / Haiku use the enabled-budget path; display is valid there too.
1806
+ let p = provider();
1807
+ let req = json!({
1808
+ "model": "claude-haiku-4-5-20251001",
1809
+ "messages": [{"role": "user", "content": "hi"}],
1810
+ "reasoning_effort": "high",
1811
+ });
1812
+ let result = p
1813
+ .transform_request("claude-haiku-4-5-20251001", &req)
1814
+ .unwrap();
1815
+ assert_eq!(result.body["thinking"]["type"], "enabled");
1816
+ assert_eq!(result.body["thinking"]["display"], "summarized");
1817
+ assert!(result.body["thinking"]["budget_tokens"].as_u64().unwrap() >= 1024);
1818
+ }
1819
+
1820
+ #[test]
1821
+ fn reasoning_effort_none_stays_disabled_without_display() {
1822
+ let p = provider();
1823
+ let req = json!({
1824
+ "model": "claude-sonnet-5",
1825
+ "messages": [{"role": "user", "content": "hi"}],
1826
+ "reasoning_effort": "none",
1827
+ });
1828
+ let result = p.transform_request("claude-sonnet-5", &req).unwrap();
1829
+ assert_eq!(result.body["thinking"]["type"], "disabled");
1830
+ assert!(result.body["thinking"].get("display").is_none());
1831
+ }
1832
+
1833
+ #[test]
1834
+ fn passthrough_thinking_display_untouched() {
1835
+ // A caller-supplied thinking block wins; the effort translation (and its
1836
+ // default display) is bypassed by the has_thinking gate.
1837
+ let p = provider();
1838
+ let req = json!({
1839
+ "model": "claude-sonnet-5",
1840
+ "messages": [{"role": "user", "content": "hi"}],
1841
+ "reasoning_effort": "high",
1842
+ "thinking": {"type": "adaptive", "display": "omitted"},
1843
+ });
1844
+ let result = p.transform_request("claude-sonnet-5", &req).unwrap();
1845
+ assert_eq!(result.body["thinking"]["display"], "omitted");
1846
+ }
@@ -1507,3 +1507,25 @@ fn mode_pro_bumps_thinking_level_and_none_wins() {
1507
1507
  "minimal"
1508
1508
  );
1509
1509
  }
1510
+
1511
+ #[test]
1512
+ fn request_strips_foreign_reasoning_signature() {
1513
+ // Issue #34: Gemini rebuilds messages into contents/parts, so an opaque
1514
+ // Anthropic reasoning signature is naturally dropped — verify it never leaks.
1515
+ let p = provider();
1516
+ let req = json!({
1517
+ "model": "gemini-3.5-flash",
1518
+ "messages": [{
1519
+ "role": "assistant",
1520
+ "content": "hi",
1521
+ "reasoning_content": "x",
1522
+ "reasoning_signature": "anthropic-sig-should-not-leak",
1523
+ "redacted_reasoning_content": "redacted-should-not-leak"
1524
+ }]
1525
+ });
1526
+ let result = p.transform_request("gemini-3.5-flash", &req).unwrap();
1527
+ let body = serde_json::to_string(&result.body).unwrap();
1528
+ assert!(!body.contains("anthropic-sig-should-not-leak"));
1529
+ assert!(!body.contains("redacted-should-not-leak"));
1530
+ assert!(!body.contains("reasoning_signature"));
1531
+ }
@@ -891,3 +891,25 @@ fn mode_pro_without_effort() {
891
891
  assert_eq!(r.body["reasoning"]["effort"], "high");
892
892
  assert!(r.body["reasoning"].get("mode").is_none());
893
893
  }
894
+
895
+ #[test]
896
+ fn request_strips_foreign_reasoning_signature() {
897
+ // Issue #34: an opaque Anthropic reasoning signature must never leak into an
898
+ // OpenAI request in a multi-model conversation.
899
+ let p = provider();
900
+ let req = json!({
901
+ "model": "gpt-5.4",
902
+ "messages": [{
903
+ "role": "assistant",
904
+ "content": "hi",
905
+ "reasoning_content": "x",
906
+ "reasoning_signature": "anthropic-sig-should-not-leak",
907
+ "redacted_reasoning_content": "redacted-should-not-leak"
908
+ }]
909
+ });
910
+ let result = p.transform_request("gpt-5.4", &req).unwrap();
911
+ let body = serde_json::to_string(&result.body).unwrap();
912
+ assert!(!body.contains("anthropic-sig-should-not-leak"));
913
+ assert!(!body.contains("redacted-should-not-leak"));
914
+ assert!(!body.contains("reasoning_signature"));
915
+ }
@@ -736,3 +736,25 @@ fn grok_4_5_effort_range() {
736
736
  assert_eq!(r.body["reasoning"]["effort"], expected, "effort {effort}");
737
737
  }
738
738
  }
739
+
740
+ #[test]
741
+ fn request_strips_foreign_reasoning_signature() {
742
+ // Issue #34: an opaque Anthropic reasoning signature must never leak into an
743
+ // xAI request in a multi-model conversation.
744
+ let p = provider();
745
+ let req = json!({
746
+ "model": "grok-4.3",
747
+ "messages": [{
748
+ "role": "assistant",
749
+ "content": "hi",
750
+ "reasoning_content": "x",
751
+ "reasoning_signature": "anthropic-sig-should-not-leak",
752
+ "redacted_reasoning_content": "redacted-should-not-leak"
753
+ }]
754
+ });
755
+ let result = p.transform_request("grok-4.3", &req).unwrap();
756
+ let body = serde_json::to_string(&result.body).unwrap();
757
+ assert!(!body.contains("anthropic-sig-should-not-leak"));
758
+ assert!(!body.contains("redacted-should-not-leak"));
759
+ assert!(!body.contains("reasoning_signature"));
760
+ }
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