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.
- {llmshim-0.2.0 → llmshim-0.2.1}/CLAUDE.md +1 -1
- {llmshim-0.2.0 → llmshim-0.2.1}/Cargo.lock +1 -1
- {llmshim-0.2.0 → llmshim-0.2.1}/Cargo.toml +1 -1
- {llmshim-0.2.0 → llmshim-0.2.1}/PKG-INFO +1 -1
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/request-fields.md +15 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/anthropic.rs +106 -2
- {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/openai.rs +4 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/xai.rs +4 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_thinking.rs +73 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_anthropic.rs +179 -2
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_gemini.rs +22 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_openai.rs +22 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_xai.rs +22 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/.github/workflows/pages.yml +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/.github/workflows/release.yml +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/.gitignore +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/LICENSE +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/README.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/benchmarks/bench.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/benchmarks/bench_python.py +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/benchmarks/loadtest.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/.gitignore +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/book.toml +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/mermaid-init.js +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/mermaid.min.js +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/SUMMARY.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/contracts.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/conversations.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/portability.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/routing.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/concepts/translation-flow.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/fallbacks.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/images.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/native-controls.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/reasoning.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/streaming.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/guides/tools.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/introduction.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/proxy/deployment.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/proxy/http-api.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/proxy/scaling.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/api.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/cli.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/configuration.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/errors.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/models.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/providers.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/reference/surfaces.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/choose.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/cli.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/clients.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/configure.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/proxy.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/docs/src/start/rust.md +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/examples/chat.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/examples/stream.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/llmshim/__init__.py +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/llmshim/_client.py +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/llmshim/_server.py +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/llmshim/types.py +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/pyproject.toml +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/client.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/config.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/env.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/error.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/fallback.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/lib.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/log.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/main.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/models.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/provider.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/gemini.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/providers/mod.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/convert.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/error.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/handlers.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/mod.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/ratelimit.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/proxy/types.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/router.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/src/vision.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_fallback.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_gemini.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_gemini_tools.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_long_context.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_multimodel.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_proxy.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_tool_roundtrip.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/integration_vision.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_fallback.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_fast_mode.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_log.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_models.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_multimodel.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_proxy.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_proxy_convert.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_router.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_sse.rs +0 -0
- {llmshim-0.2.0 → llmshim-0.2.1}/tests/unit_tools.rs +0 -0
- {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
|
|
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
|
|
|
@@ -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:
|
|
770
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|