llmshim 0.2.0__tar.gz → 0.2.1__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.1}/CLAUDE.md +1 -1
  2. {llmshim-0.2.0 → llmshim-0.2.1}/Cargo.lock +1 -1
  3. {llmshim-0.2.0 → llmshim-0.2.1}/Cargo.toml +1 -1
  4. {llmshim-0.2.0 → llmshim-0.2.1}/PKG-INFO +1 -1
  5. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/request-fields.md +15 -0
  6. {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/anthropic.rs +106 -2
  7. {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/openai.rs +4 -0
  8. {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/xai.rs +4 -0
  9. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_thinking.rs +73 -0
  10. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_anthropic.rs +179 -2
  11. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_gemini.rs +22 -0
  12. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_openai.rs +22 -0
  13. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_xai.rs +22 -0
  14. {llmshim-0.2.0 → llmshim-0.2.1}/.github/workflows/pages.yml +0 -0
  15. {llmshim-0.2.0 → llmshim-0.2.1}/.github/workflows/release.yml +0 -0
  16. {llmshim-0.2.0 → llmshim-0.2.1}/.gitignore +0 -0
  17. {llmshim-0.2.0 → llmshim-0.2.1}/LICENSE +0 -0
  18. {llmshim-0.2.0 → llmshim-0.2.1}/README.md +0 -0
  19. {llmshim-0.2.0 → llmshim-0.2.1}/benchmarks/bench.rs +0 -0
  20. {llmshim-0.2.0 → llmshim-0.2.1}/benchmarks/bench_python.py +0 -0
  21. {llmshim-0.2.0 → llmshim-0.2.1}/benchmarks/loadtest.rs +0 -0
  22. {llmshim-0.2.0 → llmshim-0.2.1}/docs/.gitignore +0 -0
  23. {llmshim-0.2.0 → llmshim-0.2.1}/docs/book.toml +0 -0
  24. {llmshim-0.2.0 → llmshim-0.2.1}/docs/mermaid-init.js +0 -0
  25. {llmshim-0.2.0 → llmshim-0.2.1}/docs/mermaid.min.js +0 -0
  26. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/SUMMARY.md +0 -0
  27. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/contracts.md +0 -0
  28. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/conversations.md +0 -0
  29. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/portability.md +0 -0
  30. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/routing.md +0 -0
  31. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/translation-flow.md +0 -0
  32. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/fallbacks.md +0 -0
  33. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/images.md +0 -0
  34. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/native-controls.md +0 -0
  35. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/reasoning.md +0 -0
  36. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/streaming.md +0 -0
  37. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/tools.md +0 -0
  38. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/introduction.md +0 -0
  39. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/proxy/deployment.md +0 -0
  40. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/proxy/http-api.md +0 -0
  41. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/proxy/scaling.md +0 -0
  42. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/api.md +0 -0
  43. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/cli.md +0 -0
  44. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/configuration.md +0 -0
  45. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/errors.md +0 -0
  46. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/models.md +0 -0
  47. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/providers.md +0 -0
  48. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/surfaces.md +0 -0
  49. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/choose.md +0 -0
  50. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/cli.md +0 -0
  51. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/clients.md +0 -0
  52. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/configure.md +0 -0
  53. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/proxy.md +0 -0
  54. {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/rust.md +0 -0
  55. {llmshim-0.2.0 → llmshim-0.2.1}/examples/chat.rs +0 -0
  56. {llmshim-0.2.0 → llmshim-0.2.1}/examples/stream.rs +0 -0
  57. {llmshim-0.2.0 → llmshim-0.2.1}/llmshim/__init__.py +0 -0
  58. {llmshim-0.2.0 → llmshim-0.2.1}/llmshim/_client.py +0 -0
  59. {llmshim-0.2.0 → llmshim-0.2.1}/llmshim/_server.py +0 -0
  60. {llmshim-0.2.0 → llmshim-0.2.1}/llmshim/types.py +0 -0
  61. {llmshim-0.2.0 → llmshim-0.2.1}/pyproject.toml +0 -0
  62. {llmshim-0.2.0 → llmshim-0.2.1}/src/client.rs +0 -0
  63. {llmshim-0.2.0 → llmshim-0.2.1}/src/config.rs +0 -0
  64. {llmshim-0.2.0 → llmshim-0.2.1}/src/env.rs +0 -0
  65. {llmshim-0.2.0 → llmshim-0.2.1}/src/error.rs +0 -0
  66. {llmshim-0.2.0 → llmshim-0.2.1}/src/fallback.rs +0 -0
  67. {llmshim-0.2.0 → llmshim-0.2.1}/src/lib.rs +0 -0
  68. {llmshim-0.2.0 → llmshim-0.2.1}/src/log.rs +0 -0
  69. {llmshim-0.2.0 → llmshim-0.2.1}/src/main.rs +0 -0
  70. {llmshim-0.2.0 → llmshim-0.2.1}/src/models.rs +0 -0
  71. {llmshim-0.2.0 → llmshim-0.2.1}/src/provider.rs +0 -0
  72. {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/gemini.rs +0 -0
  73. {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/mod.rs +0 -0
  74. {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/convert.rs +0 -0
  75. {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/error.rs +0 -0
  76. {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/handlers.rs +0 -0
  77. {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/mod.rs +0 -0
  78. {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/ratelimit.rs +0 -0
  79. {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/types.rs +0 -0
  80. {llmshim-0.2.0 → llmshim-0.2.1}/src/router.rs +0 -0
  81. {llmshim-0.2.0 → llmshim-0.2.1}/src/vision.rs +0 -0
  82. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration.rs +0 -0
  83. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_fallback.rs +0 -0
  84. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_gemini.rs +0 -0
  85. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_gemini_tools.rs +0 -0
  86. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_long_context.rs +0 -0
  87. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_multimodel.rs +0 -0
  88. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_proxy.rs +0 -0
  89. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_tool_roundtrip.rs +0 -0
  90. {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_vision.rs +0 -0
  91. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_fallback.rs +0 -0
  92. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_fast_mode.rs +0 -0
  93. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_log.rs +0 -0
  94. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_models.rs +0 -0
  95. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_multimodel.rs +0 -0
  96. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_proxy.rs +0 -0
  97. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_proxy_convert.rs +0 -0
  98. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_router.rs +0 -0
  99. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_sse.rs +0 -0
  100. {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_tools.rs +0 -0
  101. {llmshim-0.2.0 → llmshim-0.2.1}/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
 
@@ -958,7 +958,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
958
958
 
959
959
  [[package]]
960
960
  name = "llmshim"
961
- version = "0.2.0"
961
+ version = "0.2.1"
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.1"
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.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -52,10 +52,25 @@ are covered in [Native provider controls](../guides/native-controls.md).
52
52
  | `tool_calls` | array | Assistant tool requests returned on a previous turn |
53
53
  | `tool_call_id` | string | Connects a `role: "tool"` result to its request |
54
54
  | `reasoning_content` | string | Provider-returned reasoning carried into a later turn |
55
+ | `reasoning_signature` | string | Opaque provider signature for the reasoning block; echo it back with `reasoning_content` for lossless round-trip |
56
+ | `redacted_reasoning_content` | string | Opaque data for a redacted reasoning block; echo it back to reconstruct it |
55
57
 
56
58
  Content blocks may use OpenAI `image_url`, Anthropic `image`, or Gemini
57
59
  `inline_data` input forms. See [Images and vision](../guides/images.md).
58
60
 
61
+ ### Lossless reasoning round-trip
62
+
63
+ A thinking-capable Anthropic model returns `reasoning_content` **and**
64
+ `reasoning_signature` (streaming emits both incrementally; the signature arrives
65
+ as a `reasoning_signature` delta). Echo the assistant message back verbatim on a
66
+ follow-up request and llmshim reconstructs the provider-native `thinking` block
67
+ (as the first block of the turn) — required for extended-thinking + tool-use
68
+ continuations and for keeping the prompt cache warm. The signature is opaque and
69
+ provider-specific: other providers strip it, so it never leaks in a multi-model
70
+ conversation. Absent a signature, `reasoning_content` is stripped (a thinking
71
+ block without its signature is rejected). Symmetric to the tool-call
72
+ `thought_signature` round-trip.
73
+
59
74
  ## Proxy request
60
75
 
61
76
  | 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!("")),
@@ -766,8 +837,25 @@ impl Provider for Anthropic {
766
837
  });
767
838
  Ok(Some(serde_json::to_string(&chunk)?))
768
839
  }
769
- // signature_delta: skip (opaque verification, not useful to consumers)
770
- Some("signature_delta") => Ok(None),
840
+ // signature_delta: emit the opaque signature so a streaming
841
+ // consumer can reassemble a complete, round-trippable thinking
842
+ // block (fed back via reasoning_signature on the next request).
843
+ Some("signature_delta") => {
844
+ let signature = delta
845
+ .get("signature")
846
+ .and_then(|s| s.as_str())
847
+ .unwrap_or("");
848
+ let chunk = json!({
849
+ "object": "chat.completion.chunk",
850
+ "model": model,
851
+ "choices": [{
852
+ "index": 0,
853
+ "delta": { "reasoning_signature": signature },
854
+ "finish_reason": null,
855
+ }]
856
+ });
857
+ Ok(Some(serde_json::to_string(&chunk)?))
858
+ }
771
859
  _ => Ok(None),
772
860
  }
773
861
  }
@@ -797,6 +885,22 @@ impl Provider for Anthropic {
797
885
  });
798
886
  return Ok(Some(serde_json::to_string(&chunk)?));
799
887
  }
888
+ // redacted_thinking arrives whole (no deltas); surface its
889
+ // opaque data so it can be echoed back on a later request.
890
+ if cb.get("type").and_then(|t| t.as_str()) == Some("redacted_thinking") {
891
+ if let Some(data) = cb.get("data").and_then(|d| d.as_str()) {
892
+ let chunk = json!({
893
+ "object": "chat.completion.chunk",
894
+ "model": model,
895
+ "choices": [{
896
+ "index": 0,
897
+ "delta": { "redacted_reasoning_content": data },
898
+ "finish_reason": null,
899
+ }]
900
+ });
901
+ return Ok(Some(serde_json::to_string(&chunk)?));
902
+ }
903
+ }
800
904
  }
801
905
  Ok(None)
802
906
  }
@@ -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,76 @@ 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
+ }
@@ -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,173 @@ 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
+ }
@@ -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