diff --git a/crates/aion-agent/src/engine_test.rs b/crates/aion-agent/src/engine_test.rs index 60c72856..22215842 100644 --- a/crates/aion-agent/src/engine_test.rs +++ b/crates/aion-agent/src/engine_test.rs @@ -210,6 +210,7 @@ mod tests_set_config { let compat = ProviderCompat { reasoning: ReasoningCompat { supports_thinking: Some(true), + emit_disabled_thinking: None, supports_effort: Some(true), effort_levels: Some(vec!["low".into()]), }, diff --git a/crates/aion-config/src/compat.rs b/crates/aion-config/src/compat.rs index 061024bb..9a1b1291 100644 --- a/crates/aion-config/src/compat.rs +++ b/crates/aion-config/src/compat.rs @@ -130,6 +130,10 @@ pub struct ReasoningCompat { /// Default: true for anthropic/bedrock/vertex, false for openai. pub supports_thinking: Option, + /// Whether a disabled thinking request must be emitted explicitly. + /// Default: false, preserving providers where omission means disabled. + pub emit_disabled_thinking: Option, + /// Whether this provider supports reasoning_effort. /// Default: false for anthropic/bedrock/vertex, true for openai. pub supports_effort: Option, @@ -192,6 +196,7 @@ impl ReasoningCompat { fn merge(defaults: Self, user: Self) -> Self { Self { supports_thinking: user.supports_thinking.or(defaults.supports_thinking), + emit_disabled_thinking: user.emit_disabled_thinking.or(defaults.emit_disabled_thinking), supports_effort: user.supports_effort.or(defaults.supports_effort), effort_levels: user.effort_levels.or(defaults.effort_levels), } @@ -281,6 +286,7 @@ impl ProviderCompat { }, reasoning: ReasoningCompat { supports_thinking: Some(false), + emit_disabled_thinking: None, supports_effort: Some(true), effort_levels: Some(vec!["low".into(), "medium".into(), "high".into()]), }, @@ -383,6 +389,10 @@ impl ProviderCompat { self.reasoning.supports_thinking.unwrap_or(false) } + pub fn emit_disabled_thinking(&self) -> bool { + self.reasoning.emit_disabled_thinking.unwrap_or(false) + } + pub fn supports_effort(&self) -> bool { self.reasoning.supports_effort.unwrap_or(false) } diff --git a/crates/aion-config/src/compat_test.rs b/crates/aion-config/src/compat_test.rs index 87d43c6f..0bf5c0f1 100644 --- a/crates/aion-config/src/compat_test.rs +++ b/crates/aion-config/src/compat_test.rs @@ -27,6 +27,7 @@ sanitize_schema = true strip_patterns = ["__REASONING__"] auto_tool_id = true supports_thinking = true +emit_disabled_thinking = true supports_effort = false effort_levels = ["low", "medium"] @@ -68,6 +69,8 @@ max_tokens = 64000 assert_eq!(compat.tool_wire_shape(), ToolWireShape::OpenAiFunction); assert_eq!(compat.schema.sanitize_schema, Some(true)); assert_eq!(compat.reasoning.supports_thinking, Some(true)); + assert_eq!(compat.reasoning.emit_disabled_thinking, Some(true)); + assert!(compat.emit_disabled_thinking()); assert_eq!(compat.reasoning.supports_effort, Some(false)); assert_eq!( compat.reasoning.effort_levels, @@ -110,6 +113,7 @@ max_tokens = 64000 }, reasoning: ReasoningCompat { supports_thinking: Some(true), + emit_disabled_thinking: Some(true), supports_effort: Some(false), effort_levels: Some(vec!["low".to_string(), "medium".to_string()]), }, @@ -139,6 +143,7 @@ max_tokens = 64000 assert!(toml.contains("strip_patterns = [\"__REASONING__\"]")); assert!(toml.contains("auto_tool_id = true")); assert!(toml.contains("supports_thinking = true")); + assert!(toml.contains("emit_disabled_thinking = true")); assert!(toml.contains("supports_effort = false")); assert!(toml.contains("effort_levels = [\"low\", \"medium\"]")); assert!(!toml.contains("[transport]")); @@ -180,6 +185,7 @@ max_tokens = 64000 }, reasoning: ReasoningCompat { supports_thinking: Some(true), + emit_disabled_thinking: Some(true), supports_effort: None, effort_levels: Some(vec!["custom".to_string()]), }, @@ -206,10 +212,12 @@ max_tokens = 64000 assert!(merged.sanitize_schema()); assert!(!merged.auto_tool_id()); assert!(merged.supports_thinking()); + assert!(merged.emit_disabled_thinking()); assert!(merged.supports_effort()); assert_eq!(merged.effort_levels(), &["custom"]); assert_eq!(merged.messages.strip_patterns, Some(vec!["strip-me".to_string()])); assert_eq!(merged.reasoning.supports_thinking, Some(true)); + assert_eq!(merged.reasoning.emit_disabled_thinking, Some(true)); assert_eq!(merged.reasoning.supports_effort, Some(true)); assert_eq!(merged.reasoning.effort_levels, Some(vec!["custom".to_string()])); } @@ -473,6 +481,7 @@ tool_wire_shape = "provider_guess" fn test_anthropic_defaults_capability_fields() { let compat = ProviderCompat::anthropic_defaults(); assert_eq!(compat.reasoning.supports_thinking, Some(true)); + assert!(!compat.emit_disabled_thinking()); assert_eq!(compat.reasoning.supports_effort, Some(false)); assert!(compat.reasoning.effort_levels.is_none()); } @@ -481,6 +490,7 @@ tool_wire_shape = "provider_guess" fn test_openai_defaults_capability_fields() { let compat = ProviderCompat::openai_defaults(); assert_eq!(compat.reasoning.supports_thinking, Some(false)); + assert!(!compat.emit_disabled_thinking()); assert_eq!(compat.reasoning.supports_effort, Some(true)); assert_eq!( compat.reasoning.effort_levels, @@ -492,6 +502,7 @@ tool_wire_shape = "provider_guess" fn test_bedrock_defaults_capability_fields() { let compat = ProviderCompat::bedrock_defaults(); assert_eq!(compat.reasoning.supports_thinking, Some(true)); + assert!(!compat.emit_disabled_thinking()); assert_eq!(compat.reasoning.supports_effort, Some(false)); } @@ -501,12 +512,15 @@ tool_wire_shape = "provider_guess" let user = ProviderCompat { reasoning: ReasoningCompat { supports_thinking: Some(true), + emit_disabled_thinking: Some(true), ..Default::default() }, ..Default::default() }; let merged = ProviderCompat::merge(defaults, user); assert_eq!(merged.reasoning.supports_thinking, Some(true)); + assert_eq!(merged.reasoning.emit_disabled_thinking, Some(true)); + assert!(merged.emit_disabled_thinking()); assert_eq!(merged.reasoning.supports_effort, Some(true)); } @@ -514,6 +528,7 @@ tool_wire_shape = "provider_guess" fn test_capability_accessors() { let compat = ProviderCompat::anthropic_defaults(); assert!(compat.supports_thinking()); + assert!(!compat.emit_disabled_thinking()); assert!(!compat.supports_effort()); assert!(compat.effort_levels().is_empty()); diff --git a/crates/aion-providers/src/projector.rs b/crates/aion-providers/src/projector.rs index 12d59f50..1dddf0e7 100644 --- a/crates/aion-providers/src/projector.rs +++ b/crates/aion-providers/src/projector.rs @@ -136,11 +136,19 @@ impl AnthropicWireProjector { body["tools"] = json!(tools); } - if let Some(ThinkingConfig::Enabled { budget_tokens }) = &request.thinking { - body["thinking"] = json!({ - "type": "enabled", - "budget_tokens": budget_tokens - }); + if let Some(thinking) = &request.thinking { + match thinking { + ThinkingConfig::Enabled { budget_tokens } => { + body["thinking"] = json!({ + "type": "enabled", + "budget_tokens": budget_tokens + }); + } + ThinkingConfig::Disabled if compat.emit_disabled_thinking() => { + body["thinking"] = json!({ "type": "disabled" }); + } + ThinkingConfig::Disabled => {} + } } preflight_projected_body(params.provider, &body, tool_count, compat)?; diff --git a/crates/aion-providers/src/projector_test.rs b/crates/aion-providers/src/projector_test.rs index ae0e7f10..3f8cb7f0 100644 --- a/crates/aion-providers/src/projector_test.rs +++ b/crates/aion-providers/src/projector_test.rs @@ -185,6 +185,50 @@ mod tests { ); } + #[test] + fn test_anthropic_projector_omits_disabled_thinking_by_default() { + let request = test_request(vec![], Some(ThinkingConfig::Disabled)); + + let body = AnthropicWireProjector::project( + &request, + &ProviderCompat::anthropic_defaults(), + WireParams { + provider: WireProvider::Anthropic, + anthropic_version: None, + include_model_in_body: true, + include_stream: true, + cache_enabled: false, + sanitize_schema: false, + }, + ) + .expect("request body projection should succeed"); + + assert!(body.get("thinking").is_none()); + } + + #[test] + fn test_anthropic_projector_emits_disabled_thinking_when_configured() { + let request = test_request(vec![], Some(ThinkingConfig::Disabled)); + let mut compat = ProviderCompat::anthropic_defaults(); + compat.reasoning.emit_disabled_thinking = Some(true); + + let body = AnthropicWireProjector::project( + &request, + &compat, + WireParams { + provider: WireProvider::Anthropic, + anthropic_version: None, + include_model_in_body: true, + include_stream: true, + cache_enabled: false, + sanitize_schema: false, + }, + ) + .expect("request body projection should succeed"); + + assert_eq!(body["thinking"], json!({ "type": "disabled" })); + } + #[test] fn test_anthropic_projector_uses_model_default_max_tokens_when_unset() { let mut request = test_request(vec![], None); diff --git a/docs/providers.md b/docs/providers.md index a608d7d2..d3c52be6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -126,6 +126,15 @@ aionrs --json-stream \ --thinking enabled ``` +Anthropic-compatible gateways that enable thinking when the field is omitted +can require an explicit disabled request. Enable that behavior per profile: + +```toml +[profiles.glm-anthropic.compat] +supports_thinking = true +emit_disabled_thinking = true +``` + `--thinking-budget` only has effect together with `--thinking enabled`, and is only sent on the Anthropic wire path. OpenAI-compatible requests currently send only `thinking.type`, so any configured budget is ignored by that provider path.