diff --git a/.forge/skills/test-reasoning/SKILL.md b/.forge/skills/test-reasoning/SKILL.md index 248f35ea1e..e5a91147f6 100644 --- a/.forge/skills/test-reasoning/SKILL.md +++ b/.forge/skills/test-reasoning/SKILL.md @@ -36,6 +36,7 @@ Then inspect `.forge/forge.request.json` for the expected fields. | Provider | Model | Config fields | Expected JSON field | | ---------------- | ---------------------------- | ------------------------------------------------- | --------------------------------- | | `open_router` | `openai/o4-mini` | `effort: none\|minimal\|low\|medium\|high\|xhigh` | `reasoning.effort` | +| `open_router` | `openai/o4-mini` | `effort: max` (normalised → `"xhigh"`) | `reasoning.effort = "xhigh"` | | `open_router` | `openai/o4-mini` | `max_tokens: 4000` | `reasoning.max_tokens` | | `open_router` | `openai/o4-mini` | `effort: high` + `exclude: true` | `reasoning.effort` + `.exclude` | | `open_router` | `openai/o4-mini` | `enabled: true` | `reasoning.enabled` | diff --git a/.forge/skills/test-reasoning/scripts/test-reasoning.sh b/.forge/skills/test-reasoning/scripts/test-reasoning.sh index 2cad7ee712..728d346d04 100755 --- a/.forge/skills/test-reasoning/scripts/test-reasoning.sh +++ b/.forge/skills/test-reasoning/scripts/test-reasoning.sh @@ -161,6 +161,22 @@ for effort in none minimal low medium high xhigh; do ) > "$CURRENT_RF" & done +# ─── OpenRouter · openai/o4-mini — effort: max → normalised to xhigh ───────── +# OpenRouter does not support the "max" effort string; it only supports up to +# "xhigh". The NormalizeOpenRouterReasoning transformer must convert "max" to +# "xhigh" before the request is serialised. This test verifies that conversion. + +next_result_file +( + log_header "OpenRouter · openai/o4-mini · effort: max (normalised → xhigh)" + OUT="$WORK_DIR/openrouter-openai-effort-max-normalised.json" + if run_test "$OUT" open_router "openai/o4-mini" "FORGE_REASONING__EFFORT=max"; then + assert_field "$OUT" "reasoning.effort" '"xhigh"' "openrouter/openai (max→xhigh)" + else + log_skip "open_router not configured — skipping" + fi +) > "$CURRENT_RF" & + # ─── OpenRouter · openai/o4-mini — max_tokens ──────────────────────────────── # When max_tokens is set, reasoning.max_tokens should appear. # Note: the default forge config also injects effort="medium" and enabled=true; diff --git a/crates/forge_app/src/dto/openai/request.rs b/crates/forge_app/src/dto/openai/request.rs index 309aeaca09..544d98f88d 100644 --- a/crates/forge_app/src/dto/openai/request.rs +++ b/crates/forge_app/src/dto/openai/request.rs @@ -272,12 +272,34 @@ pub struct Request { pub initiator: Option, #[serde(skip_serializing_if = "Option::is_none")] pub stream_options: Option, - #[serde(skip_serializing_if = "Option::is_none")] + /// Internal staging field holding the raw domain reasoning config. + /// + /// Not serialized to the wire. Consumed by provider-specific transformers + /// (e.g. [`SetZaiThinking`], [`SetReasoningEffort`], [`MakeOpenAiCompat`]). + /// OpenRouter-compatible providers use the pre-converted [`Self::reasoning_or`] + /// field instead. + #[serde(skip)] + // FIXME: Drop references to `domain::ReasoningConfig` and use `openai::request::ReasoningConfig` + // Keep the field and update docs pub reasoning: Option, + /// OpenRouter wire representation of the reasoning config. + /// + /// Serialized as `"reasoning"` in the JSON body. Populated in + /// [`From`] by converting the domain [`ReasoningConfig`] to + /// [`OpenRouterReasoningConfig`], which normalises `effort: max` → `"xhigh"`. + /// No transformer step is required. + + // FIXME: Drop this field + #[serde(rename = "reasoning", skip_serializing_if = "Option::is_none")] + pub reasoning_or: Option, + + // FIXME: This is unused - drop it #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_completion_tokens: Option, + + // FIXME: This is unused - drop it #[serde(skip_serializing_if = "Option::is_none")] pub thinking: Option, } @@ -405,6 +427,10 @@ impl From for Request { stream_options: Some(StreamOptions { include_usage: Some(true) }), session_id: context.conversation_id.map(|id| id.to_string()), initiator: context.initiator, + reasoning_or: context + .reasoning + .as_ref() + .map(|r| crate::dto::openai::transformers::open_router_reasoning::OpenRouterReasoningConfig::from(r.clone())), reasoning: context.reasoning, reasoning_effort: Default::default(), max_completion_tokens: Default::default(), diff --git a/crates/forge_app/src/dto/openai/transformers/make_openai_compat.rs b/crates/forge_app/src/dto/openai/transformers/make_openai_compat.rs index d1ccd5fa77..3b7257e98f 100644 --- a/crates/forge_app/src/dto/openai/transformers/make_openai_compat.rs +++ b/crates/forge_app/src/dto/openai/transformers/make_openai_compat.rs @@ -22,6 +22,7 @@ impl Transformer for MakeOpenAiCompat { request.top_a = None; request.session_id = None; request.reasoning = None; + request.reasoning_or = None; let tools_present = request .tools diff --git a/crates/forge_app/src/dto/openai/transformers/mod.rs b/crates/forge_app/src/dto/openai/transformers/mod.rs index 88dcb30b82..009418bc4e 100644 --- a/crates/forge_app/src/dto/openai/transformers/mod.rs +++ b/crates/forge_app/src/dto/openai/transformers/mod.rs @@ -5,6 +5,7 @@ mod make_cerebras_compat; mod make_openai_compat; mod minimax; mod normalize_tool_schema; +pub mod open_router_reasoning; mod pipeline; mod set_cache; mod set_reasoning_effort; diff --git a/crates/forge_app/src/dto/openai/transformers/open_router_reasoning.rs b/crates/forge_app/src/dto/openai/transformers/open_router_reasoning.rs new file mode 100644 index 0000000000..2c53dc8de1 --- /dev/null +++ b/crates/forge_app/src/dto/openai/transformers/open_router_reasoning.rs @@ -0,0 +1,174 @@ +use std::fmt; + +use forge_domain::{Effort, ReasoningConfig}; +use serde::{Deserialize, Serialize}; + +/// OpenRouter-specific effort level. +/// +/// Mirrors [`forge_domain::Effort`] but maps [`Effort::Max`] to `"xhigh"` +/// because OpenRouter does not recognise the `"max"` string — its highest +/// supported value is `"xhigh"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OpenRouterEffort { + None, + Minimal, + Low, + Medium, + High, + /// Serialises as `"xhigh"`. Also used when the domain effort is `Max`. + Xhigh, +} + +impl fmt::Display for OpenRouterEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::Xhigh => "xhigh", + }; + f.write_str(s) + } +} + +impl From for OpenRouterEffort { + fn from(effort: Effort) -> Self { + match effort { + Effort::None => Self::None, + Effort::Minimal => Self::Minimal, + Effort::Low => Self::Low, + Effort::Medium => Self::Medium, + Effort::High => Self::High, + // Both XHigh and Max map to "xhigh" — OpenRouter's maximum. + Effort::XHigh | Effort::Max => Self::Xhigh, + } + } +} + +/// OpenRouter-specific reasoning configuration. +/// +/// Used as the wire type for the `reasoning` field in OpenRouter requests. +/// Mirrors [`forge_domain::ReasoningConfig`] but uses [`OpenRouterEffort`] +/// so that `effort: max` is transparently normalised to `"xhigh"` during JSON +/// serialization. OpenRouter does not recognise `"max"` — `"xhigh"` is its +/// highest supported effort level. +/// +/// Built from [`forge_domain::ReasoningConfig`] via `From` in the +/// `From for Request` conversion, so no transformer step is required. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +// FIXME: Rename to `ReasoningConfig` and move to `openai/request` +pub struct OpenRouterReasoningConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +impl From for OpenRouterReasoningConfig { + fn from(config: ReasoningConfig) -> Self { + Self { + effort: config.effort.map(OpenRouterEffort::from), + max_tokens: config.max_tokens, + exclude: config.exclude, + enabled: config.enabled, + } + } +} + +#[cfg(test)] +mod tests { + use forge_domain::{Effort, ReasoningConfig}; + use pretty_assertions::assert_eq; + + use super::*; + + // ── OpenRouterEffort conversions ────────────────────────────────────────── + + #[test] + fn test_max_maps_to_xhigh() { + let actual = OpenRouterEffort::from(Effort::Max); + let expected = OpenRouterEffort::Xhigh; + assert_eq!(actual, expected); + } + + #[test] + fn test_xhigh_maps_to_xhigh() { + let actual = OpenRouterEffort::from(Effort::XHigh); + let expected = OpenRouterEffort::Xhigh; + assert_eq!(actual, expected); + } + + #[test] + fn test_all_other_efforts_preserved() { + assert_eq!(OpenRouterEffort::from(Effort::None), OpenRouterEffort::None); + assert_eq!(OpenRouterEffort::from(Effort::Minimal), OpenRouterEffort::Minimal); + assert_eq!(OpenRouterEffort::from(Effort::Low), OpenRouterEffort::Low); + assert_eq!(OpenRouterEffort::from(Effort::Medium), OpenRouterEffort::Medium); + assert_eq!(OpenRouterEffort::from(Effort::High), OpenRouterEffort::High); + } + + // ── Display ─────────────────────────────────────────────────────────────── + + #[test] + fn test_display_xhigh() { + assert_eq!(OpenRouterEffort::Xhigh.to_string(), "xhigh"); + } + + #[test] + fn test_display_all_variants() { + assert_eq!(OpenRouterEffort::None.to_string(), "none"); + assert_eq!(OpenRouterEffort::Minimal.to_string(), "minimal"); + assert_eq!(OpenRouterEffort::Low.to_string(), "low"); + assert_eq!(OpenRouterEffort::Medium.to_string(), "medium"); + assert_eq!(OpenRouterEffort::High.to_string(), "high"); + } + + // ── Serialization ───────────────────────────────────────────────────────── + + #[test] + fn test_xhigh_serializes_as_xhigh_string() { + let config = OpenRouterReasoningConfig { + effort: Some(OpenRouterEffort::Xhigh), + max_tokens: None, + exclude: None, + enabled: None, + }; + let actual = serde_json::to_value(&config).unwrap(); + assert_eq!(actual["effort"], "xhigh"); + } + + #[test] + fn test_max_to_xhigh_round_trip_serializes_as_xhigh() { + let domain_config = ReasoningConfig { + effort: Some(Effort::Max), + max_tokens: None, + exclude: None, + enabled: None, + }; + let or_config = OpenRouterReasoningConfig::from(domain_config); + let actual = serde_json::to_value(&or_config).unwrap(); + assert_eq!(actual["effort"], "xhigh"); + } + + #[test] + fn test_all_fields_preserved_in_conversion() { + let domain_config = ReasoningConfig { + effort: Some(Effort::High), + max_tokens: Some(4000), + exclude: Some(true), + enabled: Some(true), + }; + let actual = serde_json::to_value(OpenRouterReasoningConfig::from(domain_config)).unwrap(); + assert_eq!(actual["effort"], "high"); + assert_eq!(actual["max_tokens"], 4000); + assert_eq!(actual["exclude"], true); + assert_eq!(actual["enabled"], true); + } +} diff --git a/crates/forge_app/src/dto/openai/transformers/set_reasoning_effort.rs b/crates/forge_app/src/dto/openai/transformers/set_reasoning_effort.rs index 8cef9f7659..6447112589 100644 --- a/crates/forge_app/src/dto/openai/transformers/set_reasoning_effort.rs +++ b/crates/forge_app/src/dto/openai/transformers/set_reasoning_effort.rs @@ -43,6 +43,7 @@ impl Transformer for SetReasoningEffort { request.reasoning_effort = effort; request.reasoning = None; + request.reasoning_or = None; } request diff --git a/crates/forge_app/src/dto/openai/transformers/zai_reasoning.rs b/crates/forge_app/src/dto/openai/transformers/zai_reasoning.rs index ce20882101..e2eb0438b8 100644 --- a/crates/forge_app/src/dto/openai/transformers/zai_reasoning.rs +++ b/crates/forge_app/src/dto/openai/transformers/zai_reasoning.rs @@ -38,6 +38,7 @@ impl Transformer for SetZaiThinking { ThinkingType::Disabled }, }); + request.reasoning_or = None; } request