From f9be3c3d68f2775ff6f6ec1a3fae4a96120ffaa5 Mon Sep 17 00:00:00 2001 From: Reed Yalouh Date: Thu, 15 Oct 2026 17:22:10 -0700 Subject: [PATCH 1/2] fix(llm): preserve explicit Anthropic thinking disablement Signed-off-by: Reed Yalouh --- lib/llm/src/protocols/anthropic/types.rs | 124 +++++++++++++++++++++-- lib/llm/tests/anthropic_http_replay.rs | 52 ++++++++++ 2 files changed, 167 insertions(+), 9 deletions(-) diff --git a/lib/llm/src/protocols/anthropic/types.rs b/lib/llm/src/protocols/anthropic/types.rs index 5ac3d75b66a3..0522850121f4 100644 --- a/lib/llm/src/protocols/anthropic/types.rs +++ b/lib/llm/src/protocols/anthropic/types.rs @@ -201,17 +201,19 @@ impl TryFrom for NvCreateChatCompletionRequest { // when a reasoning parser is configured. The conversion layer only // forwards the client's explicit thinking preference here; the handler // has access to parsing_options and makes the final decision. - chat_template_args: if req - .thinking - .as_ref() - .is_some_and(|t| t.thinking_type == "enabled") - { + chat_template_args: req.thinking.as_ref().and_then(|thinking| { + let enabled = match thinking.thinking_type.as_str() { + "enabled" => true, + "disabled" => false, + _ => return None, + }; let mut args = std::collections::HashMap::new(); - args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true)); + args.insert( + "enable_thinking".to_string(), + serde_json::Value::Bool(enabled), + ); Some(args) - } else { - None - }, + }), thinking: None, thinking_token_budget: None, media_io_kwargs: None, @@ -3039,3 +3041,107 @@ mod anthropic_types_tests { assert_eq!(recovered.len(), 1); } } + +#[cfg(test)] +mod thinking_control_tests { + use super::*; + + fn request_with_thinking(thinking: Option) -> AnthropicCreateMessageRequest { + let mut request = serde_json::json!({ + "model": "test-model", + "max_tokens": 2048, + "messages": [{"role": "user", "content": "Hello"}] + }); + if let Some(thinking) = thinking { + request["thinking"] = thinking; + } + serde_json::from_value(request).unwrap() + } + + #[test] + fn enabled_thinking_survives_conversion() { + let converted = NvCreateChatCompletionRequest::try_from(request_with_thinking(Some( + serde_json::json!({"type": "enabled", "budget_tokens": 1024}), + ))) + .unwrap(); + assert_eq!( + converted.chat_template_args.unwrap()["enable_thinking"], + true + ); + } + + #[test] + fn disabled_thinking_survives_conversion() { + let converted = NvCreateChatCompletionRequest::try_from(request_with_thinking(Some( + serde_json::json!({"type": "disabled"}), + ))) + .unwrap(); + assert_eq!( + converted.chat_template_args.unwrap()["enable_thinking"], + false + ); + } + + #[test] + fn omitted_thinking_keeps_template_default() { + let converted = + NvCreateChatCompletionRequest::try_from(request_with_thinking(None)).unwrap(); + assert!(converted.chat_template_args.is_none()); + } + + #[test] + fn null_thinking_keeps_template_default() { + let converted = NvCreateChatCompletionRequest::try_from(request_with_thinking(Some( + serde_json::Value::Null, + ))) + .unwrap(); + assert!(converted.chat_template_args.is_none()); + } + + #[test] + fn other_thinking_modes_keep_existing_behavior() { + for mode in ["adaptive", "unknown"] { + let converted = NvCreateChatCompletionRequest::try_from(request_with_thinking(Some( + serde_json::json!({"type": mode}), + ))) + .unwrap(); + assert!(converted.chat_template_args.is_none(), "{mode}"); + } + } + + #[test] + fn disabling_thinking_preserves_history_and_tools() { + let mut value = serde_json::json!({ + "model": "test-model", "max_tokens": 2048, + "messages": [ + {"role": "user", "content": "Read x"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "Inspect the file", "signature": "sig"}, + {"type": "tool_use", "id": "call_1", "name": "read", "input": {"path": "x"}} + ]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "hello"}]} + ], + "tools": [{"name": "read", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}], + "tool_choice": {"type": "tool", "name": "read"}, + "nvext": {"cache_salt": "test-salt"} + }); + let unspecified = NvCreateChatCompletionRequest::try_from( + serde_json::from_value::(value.clone()).unwrap(), + ) + .unwrap(); + value["thinking"] = serde_json::json!({"type": "disabled"}); + let mut disabled = NvCreateChatCompletionRequest::try_from( + serde_json::from_value::(value).unwrap(), + ) + .unwrap(); + assert_eq!( + disabled.chat_template_args.as_ref().unwrap()["enable_thinking"], + false + ); + disabled.chat_template_args = None; + assert_eq!( + serde_json::to_value(unspecified).unwrap(), + serde_json::to_value(disabled).unwrap() + ); + } +} diff --git a/lib/llm/tests/anthropic_http_replay.rs b/lib/llm/tests/anthropic_http_replay.rs index ae212690170e..20070b4dcb05 100644 --- a/lib/llm/tests/anthropic_http_replay.rs +++ b/lib/llm/tests/anthropic_http_replay.rs @@ -881,3 +881,55 @@ async fn count_tokens_returns_exact_estimate_without_calling_engine() { }) .await; } + +#[tokio::test] +#[serial] +async fn explicit_thinking_controls_reach_the_chat_engine() { + temp_env::async_with_vars(ENV, async { + let mut scripts = Vec::new(); + for _ in 0..6 { + scripts.push(load_agent_fixture("text.sse").await.unwrap()); + } + let svc = HarnessService::start(scripts).await; + let mut expected = Vec::new(); + for streaming in [false, true] { + for (thinking, enabled) in [ + (None, None), + ( + Some(json!({"type": "enabled", "budget_tokens": 1024})), + Some(true), + ), + (Some(json!({"type": "disabled"})), Some(false)), + ] { + let mut body = json!({ + "model": MODEL, + "max_tokens": 2048, + "stream": streaming, + "messages": [{"role": "user", "content": "ping"}] + }); + if let Some(thinking) = thinking { + body["thinking"] = thinking; + } + let response = post_messages(&svc, &body).await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + let _ = response.text().await.unwrap(); + expected.push(enabled.map(Value::Bool)); + } + } + let requests = svc.engine.take_requests().await; + let actual: Vec<_> = requests + .iter() + .map(|request| { + request + .chat_template_args + .as_ref() + .and_then(|args| args.get("enable_thinking")) + .cloned() + }) + .collect(); + assert_eq!(actual, expected); + assert_eq!(svc.engine.remaining_scripts().await, 0); + svc.shutdown().await; + }) + .await; +} From 768d4eb3ac8003a79825fa83a2f944d18f7b5e37 Mon Sep 17 00:00:00 2001 From: Reed Yalouh Date: Thu, 15 Oct 2026 18:22:43 -0700 Subject: [PATCH 2/2] test(llm): trim duplicate thinking-control replay cases Signed-off-by: Reed Yalouh --- lib/llm/tests/anthropic_http_replay.rs | 44 ++++++++++++-------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/lib/llm/tests/anthropic_http_replay.rs b/lib/llm/tests/anthropic_http_replay.rs index 20070b4dcb05..3aaaf0e3e10e 100644 --- a/lib/llm/tests/anthropic_http_replay.rs +++ b/lib/llm/tests/anthropic_http_replay.rs @@ -887,34 +887,32 @@ async fn count_tokens_returns_exact_estimate_without_calling_engine() { async fn explicit_thinking_controls_reach_the_chat_engine() { temp_env::async_with_vars(ENV, async { let mut scripts = Vec::new(); - for _ in 0..6 { + for _ in 0..3 { scripts.push(load_agent_fixture("text.sse").await.unwrap()); } let svc = HarnessService::start(scripts).await; let mut expected = Vec::new(); - for streaming in [false, true] { - for (thinking, enabled) in [ - (None, None), - ( - Some(json!({"type": "enabled", "budget_tokens": 1024})), - Some(true), - ), - (Some(json!({"type": "disabled"})), Some(false)), - ] { - let mut body = json!({ - "model": MODEL, - "max_tokens": 2048, - "stream": streaming, - "messages": [{"role": "user", "content": "ping"}] - }); - if let Some(thinking) = thinking { - body["thinking"] = thinking; - } - let response = post_messages(&svc, &body).await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - let _ = response.text().await.unwrap(); - expected.push(enabled.map(Value::Bool)); + for (thinking, enabled) in [ + (None, None), + ( + Some(json!({"type": "enabled", "budget_tokens": 1024})), + Some(true), + ), + (Some(json!({"type": "disabled"})), Some(false)), + ] { + let mut body = json!({ + "model": MODEL, + "max_tokens": 2048, + "stream": false, + "messages": [{"role": "user", "content": "ping"}] + }); + if let Some(thinking) = thinking { + body["thinking"] = thinking; } + let response = post_messages(&svc, &body).await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + let _ = response.text().await.unwrap(); + expected.push(enabled.map(Value::Bool)); } let requests = svc.engine.take_requests().await; let actual: Vec<_> = requests