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