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 .forge/skills/test-reasoning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions .forge/skills/test-reasoning/scripts/test-reasoning.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 27 additions & 1 deletion crates/forge_app/src/dto/openai/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,34 @@ pub struct Request {
pub initiator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
#[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<forge_domain::ReasoningConfig>,
/// OpenRouter wire representation of the reasoning config.
///
/// Serialized as `"reasoning"` in the JSON body. Populated in
/// [`From<Context>`] 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<crate::dto::openai::transformers::open_router_reasoning::OpenRouterReasoningConfig>,

// FIXME: This is unused - drop it
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,

// FIXME: This is unused - drop it
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
}
Expand Down Expand Up @@ -405,6 +427,10 @@ impl From<Context> 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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/forge_app/src/dto/openai/transformers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
174 changes: 174 additions & 0 deletions crates/forge_app/src/dto/openai/transformers/open_router_reasoning.rs
Original file line number Diff line number Diff line change
@@ -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<Effort> 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<Context> 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<OpenRouterEffort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}

impl From<ReasoningConfig> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ impl Transformer for SetReasoningEffort {

request.reasoning_effort = effort;
request.reasoning = None;
request.reasoning_or = None;
}

request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ impl Transformer for SetZaiThinking {
ThinkingType::Disabled
},
});
request.reasoning_or = None;
}

request
Expand Down
Loading