Skip to content

Commit 93ca225

Browse files
committed
feat(api): add reasoning-effort registry with fail-fast validation and settings/env/CLI precedence
- Introduce ReasoningEffort enum and per-provider level/wire registry - Fail-fast validation in preflight rejects unsupported/unknown levels - Wire translation: Anthropic thinking budgets, OpenAI reasoning_effort (off omits field) - Propagate settings.json plugins.reasoningEffort with env/CLI precedence - Document CLAW_REASONING_EFFORT in .env.example
1 parent 00c2552 commit 93ca225

14 files changed

Lines changed: 678 additions & 35 deletions

File tree

rust/clawcode/claw/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@
2121
# Sampling temperature (0.0–2.0). Overridden by --temperature flag and /temperature.
2222
# CLAW_TEMPERATURE=0.7
2323

24+
# Reasoning effort level: off | low | medium | high | max.
25+
# Low=4096,medium=8192,max=32000
26+
# Default when unset: Anthropic=high (thinking budget 16384), OpenAI-compat=off
27+
# (field omitted, server default applies). Overridden by --reasoning-effort and
28+
# agent frontmatter `reasoning_effort:`. Unsupported levels fail fast before
29+
# the request is sent (e.g. `max` on native OpenAI, any level but `off` on a
30+
# non-reasoning model).
31+
# CLAW_REASONING_EFFORT=high
32+
2433
# --- Paths -------------------------------------------------------------------
2534
# Custom config directory (default: ~/.claw or ~/.config/claw)
2635
# CLAW_CONFIG_HOME=/path/to/.claw

rust/clawcode/rust/crates/agents/src/types.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,11 @@ pub struct AgentInput {
183183
/// reporting parity with the definition.
184184
#[serde(default)]
185185
pub mode: Option<String>,
186-
/// Optional reasoning-effort level (e.g. `low`/`medium`/`high`) forwarded
187-
/// to the provider's `MessageRequest`. When present, the spawned sub-agent
188-
/// runs with the agent definition's configured effort instead of the
189-
/// provider default.
186+
/// Optional reasoning-effort level (`off`/`low`/`medium`/`high`/`max`)
187+
/// forwarded to the provider's `MessageRequest`. When present, the spawned
188+
/// sub-agent runs with the agent definition's configured effort instead of
189+
/// the provider default. `off` disables reasoning; the rest map to a
190+
/// provider-specific wire spelling via the reasoning registry.
190191
#[serde(default)]
191192
pub reasoning_effort: Option<String>,
192193
/// Optional `permission:` directives from the agent file frontmatter

rust/clawcode/rust/crates/api/src/error.rs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ pub enum ApiError {
7676
max_bytes: usize,
7777
provider: &'static str,
7878
},
79+
/// The requested reasoning-effort level is not supported by the resolved
80+
/// model, or the level string is not a recognised level. Produced before
81+
/// any network I/O so a stale, mistyped, or model-unsupported level fails
82+
/// fast instead of being silently ignored by the backend.
83+
UnsupportedReasoningEffort {
84+
model: String,
85+
level: String,
86+
supported: Vec<String>,
87+
},
7988
}
8089

8190
impl ApiError {
@@ -157,7 +166,8 @@ impl ApiError {
157166
| Self::Json { .. }
158167
| Self::InvalidSseFrame(_)
159168
| Self::BackoffOverflow { .. }
160-
| Self::RequestBodySizeExceeded { .. } => false,
169+
| Self::RequestBodySizeExceeded { .. }
170+
| Self::UnsupportedReasoningEffort { .. } => false,
161171
}
162172
}
163173

@@ -177,7 +187,8 @@ impl ApiError {
177187
| Self::Json { .. }
178188
| Self::InvalidSseFrame(_)
179189
| Self::BackoffOverflow { .. }
180-
| Self::RequestBodySizeExceeded { .. } => None,
190+
| Self::RequestBodySizeExceeded { .. }
191+
| Self::UnsupportedReasoningEffort { .. } => None,
181192
}
182193
}
183194

@@ -203,6 +214,7 @@ impl ApiError {
203214
}
204215
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json { .. } => "runtime_io",
205216
Self::RequestBodySizeExceeded { .. } => "request_size",
217+
Self::UnsupportedReasoningEffort { .. } => "invalid_request",
206218
}
207219
}
208220

@@ -227,7 +239,8 @@ impl ApiError {
227239
| Self::Json { .. }
228240
| Self::InvalidSseFrame(_)
229241
| Self::BackoffOverflow { .. }
230-
| Self::RequestBodySizeExceeded { .. } => false,
242+
| Self::RequestBodySizeExceeded { .. }
243+
| Self::UnsupportedReasoningEffort { .. } => false,
231244
}
232245
}
233246

@@ -258,7 +271,8 @@ impl ApiError {
258271
| Self::Json { .. }
259272
| Self::InvalidSseFrame(_)
260273
| Self::BackoffOverflow { .. }
261-
| Self::RequestBodySizeExceeded { .. } => false,
274+
| Self::RequestBodySizeExceeded { .. }
275+
| Self::UnsupportedReasoningEffort { .. } => false,
262276
}
263277
}
264278
}
@@ -374,6 +388,15 @@ impl Display for ApiError {
374388
f,
375389
"request body size ({estimated_bytes} bytes) exceeds {provider} limit ({max_bytes} bytes); reduce prompt length or context before retrying"
376390
),
391+
Self::UnsupportedReasoningEffort {
392+
model,
393+
level,
394+
supported,
395+
} => write!(
396+
f,
397+
"model \"{model}\" does not support reasoning effort \"{level}\"; supported: {}",
398+
supported.join(", ")
399+
),
377400
}
378401
}
379402
}

rust/clawcode/rust/crates/api/src/incremental_body.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,10 @@ fn serialise_base(request: &MessageRequest) -> Map<String, Value> {
181181
map.insert("stop_sequences".into(), serde_json::to_value(v).unwrap_or_default());
182182
}
183183
}
184-
if let Some(ref v) = request.reasoning_effort {
185-
map.insert("reasoning_effort".into(), Value::String(v.clone()));
186-
}
184+
// `reasoning_effort` is intentionally absent from the Anthropic body: the
185+
// level is translated to a `thinking` budget (see `render_anthropic_body`
186+
// and `effective_thinking_config`), never carried through as the raw
187+
// OpenAI-style field.
187188
if let Some(ref v) = request.thinking {
188189
map.insert("thinking".into(), serde_json::to_value(v).unwrap_or_default());
189190
}

rust/clawcode/rust/crates/api/src/lib.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,15 @@ pub use sse::{parse_frame, SseParser};
3535
pub use types::{
3636
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
3737
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
38-
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
39-
ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
38+
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, ReasoningEffort,
39+
StreamEvent, ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
4040
};
4141
pub use types::render_tools_block;
42+
pub use providers::reasoning::{
43+
anthropic_thinking_budget, default_reasoning_effort, effective_thinking_config,
44+
openai_wire_effort, reasoning_levels, supports_level, validate_reasoning_effort,
45+
UnsupportedReasoningEffort,
46+
};
4247

4348
pub use telemetry::{
4449
AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, JsonlTelemetrySink,

rust/clawcode/rust/crates/api/src/providers/mod.rs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ use std::pin::Pin;
55
use serde::Serialize;
66

77
use crate::error::ApiError;
8-
use crate::types::{MessageRequest, MessageResponse};
8+
use crate::types::{MessageRequest, MessageResponse, ReasoningEffort};
9+
use crate::providers::reasoning::reasoning_levels;
910

1011
pub mod anthropic;
1112
pub mod openai_compat;
13+
pub mod reasoning;
1214

1315
#[allow(dead_code)]
1416
pub type ProviderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiError>> + Send + 'a>>;
@@ -242,7 +244,45 @@ pub fn is_local_inference() -> bool {
242244
false
243245
}
244246

247+
/// Fail-fast reasoning-effort validation: reject a level the resolved model
248+
/// does not support, or a level string that is not a recognised level, before
249+
/// the request leaves the process. Mirrors the dsh `resolveReasoningLevel`
250+
/// posture — a stale or mistyped level fails here instead of being silently
251+
/// ignored by the backend.
252+
///
253+
/// `None` (no level requested) always passes: the provider's own server
254+
/// default applies (the `reasoning_effort` field is omitted from the wire).
255+
fn validate_reasoning_effort_for_request(request: &MessageRequest) -> Result<(), ApiError> {
256+
let Some(level_str) = request.reasoning_effort.as_deref() else {
257+
return Ok(());
258+
};
259+
let canonical = resolve_model_alias(&request.model);
260+
let provider = detect_provider_kind(&canonical);
261+
let supported = reasoning_levels(provider, &canonical);
262+
let supported_names: Vec<String> = supported
263+
.iter()
264+
.map(|level| level.as_str().to_string())
265+
.collect();
266+
let level = ReasoningEffort::from_name(level_str).ok_or_else(|| {
267+
ApiError::UnsupportedReasoningEffort {
268+
model: canonical.clone(),
269+
level: level_str.to_string(),
270+
supported: supported_names.clone(),
271+
}
272+
})?;
273+
if supported.contains(&level) {
274+
Ok(())
275+
} else {
276+
Err(ApiError::UnsupportedReasoningEffort {
277+
model: canonical,
278+
level: level_str.to_string(),
279+
supported: supported_names,
280+
})
281+
}
282+
}
283+
245284
pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiError> {
285+
validate_reasoning_effort_for_request(request)?;
246286
let Some(limit) = model_token_limit(&request.model) else {
247287
return Ok(());
248288
};
@@ -648,6 +688,72 @@ mod tests {
648688
.expect("models without context metadata should skip the guarded preflight");
649689
}
650690

691+
#[test]
692+
fn preflight_rejects_unsupported_reasoning_effort() {
693+
// `max` is not a level native OpenAI exposes (`off/low/medium/high`
694+
// only), so it must fail before any network I/O. The `openai/` prefix
695+
// makes provider detection environment-independent.
696+
let request = MessageRequest {
697+
model: "openai/o4-mini".to_string(),
698+
max_tokens: 1024,
699+
messages: Arc::new(vec![InputMessage::user_text("think")]),
700+
reasoning_effort: Some("max".to_string()),
701+
..Default::default()
702+
};
703+
let err = preflight_message_request(&request)
704+
.expect_err("max must be rejected for native OpenAI reasoning models");
705+
assert!(err.to_string().contains("o4-mini"));
706+
assert!(err.to_string().contains("max"));
707+
assert!(err.to_string().contains("off, low, medium, high"));
708+
}
709+
710+
#[test]
711+
fn preflight_rejects_high_against_non_reasoning_model() {
712+
// A non-reasoning model exposes only `off`; `high` must fail fast.
713+
let request = MessageRequest {
714+
model: "gpt-4o".to_string(),
715+
max_tokens: 1024,
716+
messages: Arc::new(vec![InputMessage::user_text("hi")]),
717+
reasoning_effort: Some("high".to_string()),
718+
..Default::default()
719+
};
720+
let err = preflight_message_request(&request)
721+
.expect_err("high must be rejected for non-reasoning models");
722+
assert!(err.to_string().contains("gpt-4o"));
723+
assert!(err.to_string().contains("high"));
724+
}
725+
726+
#[test]
727+
fn preflight_rejects_unrecognised_level_string() {
728+
let request = MessageRequest {
729+
model: "o4-mini".to_string(),
730+
max_tokens: 1024,
731+
messages: Arc::new(vec![InputMessage::user_text("hi")]),
732+
reasoning_effort: Some("turbo".to_string()),
733+
..Default::default()
734+
};
735+
let err = preflight_message_request(&request)
736+
.expect_err("an unrecognised level string must fail fast");
737+
assert!(err.to_string().contains("turbo"));
738+
}
739+
740+
#[test]
741+
fn preflight_accepts_off_for_every_model() {
742+
let reasoning = |model: &str| MessageRequest {
743+
model: model.to_string(),
744+
max_tokens: 1024,
745+
messages: Arc::new(vec![InputMessage::user_text("hi")]),
746+
reasoning_effort: Some("off".to_string()),
747+
..Default::default()
748+
};
749+
preflight_message_request(&reasoning("gpt-4o"))
750+
.expect("off is always supported");
751+
preflight_message_request(&reasoning("o4-mini"))
752+
.expect("off is always supported");
753+
preflight_message_request(&reasoning("claude-sonnet-4-6"))
754+
.expect("off is always supported");
755+
}
756+
651757
#[test]
652758
fn parse_dotenv_extracts_keys_handles_comments_quotes_and_export_prefix() {
653759
// given

rust/clawcode/rust/crates/api/src/providers/openai_compat.rs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ use crate::http_client::build_http_client_or_default;
1010
use crate::types::{
1111
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
1212
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
13-
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
14-
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
13+
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, ReasoningEffort,
14+
StreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
1515
};
1616

17+
use super::reasoning::openai_wire_effort;
18+
1719
use super::{preflight_message_request, Provider, ProviderFuture};
1820

1921
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
@@ -951,9 +953,17 @@ pub fn build_chat_completion_request_with_options(
951953
payload["stop"] = json!(stop);
952954
}
953955
}
954-
// reasoning_effort for OpenAI-compatible reasoning models (o4-mini, o3, etc.)
955-
if let Some(effort) = &request.reasoning_effort {
956-
payload["reasoning_effort"] = json!(effort);
956+
// reasoning_effort for OpenAI-compatible reasoning models (o4-mini, o3, etc.).
957+
// Translate the level string via the registry: `off` omits the field (OpenAI
958+
// has no `off` spelling) and other levels emit their wire spelling. An
959+
// unrecognised string is omitted here as a defensive fallback — the
960+
// preflight validator rejects it before the request reaches this point.
961+
if let Some(level_str) = &request.reasoning_effort {
962+
if let Some(level) = ReasoningEffort::from_name(level_str) {
963+
if let Some(wire) = openai_wire_effort(level) {
964+
payload["reasoning_effort"] = json!(wire);
965+
}
966+
}
957967
}
958968

959969
payload
@@ -1657,6 +1667,46 @@ mod tests {
16571667
assert!(payload.get("reasoning_effort").is_none());
16581668
}
16591669

1670+
#[test]
1671+
fn reasoning_effort_off_omits_the_field() {
1672+
// `off` is the "disable reasoning" level: OpenAI has no `off` wire
1673+
// spelling, so the registry translates it to `None` and the field is
1674+
// omitted — the provider's own server default (no reasoning) applies.
1675+
let payload = build_chat_completion_request(
1676+
&MessageRequest {
1677+
model: "o4-mini".to_string(),
1678+
max_tokens: 1024,
1679+
messages: Arc::new(vec![InputMessage::user_text("skip thinking")]),
1680+
reasoning_effort: Some("off".to_string()),
1681+
..Default::default()
1682+
},
1683+
OpenAiCompatConfig::openai(),
1684+
);
1685+
assert!(
1686+
payload.get("reasoning_effort").is_none(),
1687+
"off must omit reasoning_effort, got: {payload}"
1688+
);
1689+
}
1690+
1691+
#[test]
1692+
fn reasoning_effort_unrecognised_string_is_omitted() {
1693+
// An unrecognised level string is omitted at the emit layer as a
1694+
// defensive fallback; the preflight validator rejects it before this
1695+
// point, so a `None` here only signals the request never carried a
1696+
// valid wire spelling.
1697+
let payload = build_chat_completion_request(
1698+
&MessageRequest {
1699+
model: "o4-mini".to_string(),
1700+
max_tokens: 1024,
1701+
messages: Arc::new(vec![InputMessage::user_text("oops")]),
1702+
reasoning_effort: Some("turbo".to_string()),
1703+
..Default::default()
1704+
},
1705+
OpenAiCompatConfig::openai(),
1706+
);
1707+
assert!(payload.get("reasoning_effort").is_none());
1708+
}
1709+
16601710
#[test]
16611711
fn openai_streaming_requests_include_usage_opt_in() {
16621712
let payload = build_chat_completion_request(

0 commit comments

Comments
 (0)