Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/aion-agent/src/engine_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()]),
},
Expand Down
10 changes: 10 additions & 0 deletions crates/aion-config/src/compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ pub struct ReasoningCompat {
/// Default: true for anthropic/bedrock/vertex, false for openai.
pub supports_thinking: Option<bool>,

/// Whether a disabled thinking request must be emitted explicitly.
/// Default: false, preserving providers where omission means disabled.
pub emit_disabled_thinking: Option<bool>,

/// Whether this provider supports reasoning_effort.
/// Default: false for anthropic/bedrock/vertex, true for openai.
pub supports_effort: Option<bool>,
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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()]),
},
Expand Down Expand Up @@ -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)
}
Expand Down
15 changes: 15 additions & 0 deletions crates/aion-config/src/compat_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()]),
},
Expand Down Expand Up @@ -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]"));
Expand Down Expand Up @@ -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()]),
},
Expand All @@ -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()]));
}
Expand Down Expand Up @@ -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());
}
Expand All @@ -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,
Expand All @@ -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));
}

Expand All @@ -501,19 +512,23 @@ 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));
}

#[test]
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());

Expand Down
18 changes: 13 additions & 5 deletions crates/aion-providers/src/projector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
44 changes: 44 additions & 0 deletions crates/aion-providers/src/projector_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down