diff --git a/crates/codegen/kigi-sampler/src/kimi_compat.rs b/crates/codegen/kigi-sampler/src/kimi_compat.rs index 1237a8d..4be4e44 100644 --- a/crates/codegen/kigi-sampler/src/kimi_compat.rs +++ b/crates/codegen/kigi-sampler/src/kimi_compat.rs @@ -292,8 +292,9 @@ mod tests { json!({ "type": "enabled", "effort": "high" }) ); - // Canonical `xhigh` is spelled `max` on the Kimi wire (the K3 - // valid_efforts vocabulary is low/high/max). + // Legacy canonical `xhigh` (pre-Max configs/sessions) is spelled + // `max` on the Kimi wire (the K3 valid_efforts vocabulary is + // low/high/max — there is no `xhigh` there). let mut body = json!({ "model": "k3", "reasoning_effort": "xhigh" }); adapt_chat_completions_body(&mut body); assert_eq!( @@ -301,6 +302,15 @@ mod tests { json!({ "type": "enabled", "effort": "max" }) ); + // Canonical `max` (what the K3 menu token parses to since the + // ReasoningEffort::Max split) passes through unchanged. + let mut body = json!({ "model": "k3", "reasoning_effort": "max" }); + adapt_chat_completions_body(&mut body); + assert_eq!( + body["thinking"], + json!({ "type": "enabled", "effort": "max" }) + ); + // kimi.py:218: "off" (our ReasoningEffort::None) → disabled, and no // effort key (a disabled+effort combination would be contradictory). let mut body = json!({ "reasoning_effort": "none" }); diff --git a/crates/codegen/kigi-sampling-types/src/conversation.rs b/crates/codegen/kigi-sampling-types/src/conversation.rs index 9e24d3a..c3a10e5 100644 --- a/crates/codegen/kigi-sampling-types/src/conversation.rs +++ b/crates/codegen/kigi-sampling-types/src/conversation.rs @@ -250,8 +250,13 @@ pub struct AssistantItem { /// `response.reasoning.effort` (Responses API). Stored beside /// `model_id`/`model_fingerprint` so per-response effort survives /// mid-session model/effort switches. `None` for synthetic items and - /// backends that don't echo it. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// backends that don't echo it. Lenient on read: an unknown token from a + /// newer kigi drops to `None` rather than failing the history line. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "crate::types::lenient_reasoning_effort_opt" + )] pub reasoning_effort: Option, } @@ -5076,13 +5081,35 @@ mod tests { } } + #[test] + fn assistant_item_unknown_reasoning_effort_token_degrades_to_none() { + // History lines written by a NEWER kigi (grown effort vocabulary) + // must not fail this binary's history parse — the lenient + // deserializer must be wired on the AssistantItem field itself. + let json = serde_json::json!({ + "role": "assistant", + "content": "hi", + "reasoning_effort": "hypermax" + }); + let item: AssistantItem = serde_json::from_value(json).expect("history line must survive"); + assert_eq!(item.reasoning_effort, None); + let json = serde_json::json!({ + "role": "assistant", + "content": "hi", + "reasoning_effort": "max" + }); + let item: AssistantItem = serde_json::from_value(json).unwrap(); + assert_eq!(item.reasoning_effort, Some(crate::ReasoningEffort::Max)); + } + #[test] fn test_messages_request_wire_format_for_supported_variants() { for (variant, expected) in [ (crate::ReasoningEffort::Low, "low"), (crate::ReasoningEffort::Medium, "medium"), (crate::ReasoningEffort::High, "high"), - (crate::ReasoningEffort::Xhigh, "max"), + (crate::ReasoningEffort::Xhigh, "xhigh"), + (crate::ReasoningEffort::Max, "max"), ] { let req = messages_test_request(Some(variant)); let msgs = build_messages_request(&req); @@ -5131,6 +5158,7 @@ mod tests { (crate::ReasoningEffort::Medium, "medium"), (crate::ReasoningEffort::High, "high"), (crate::ReasoningEffort::Xhigh, "xhigh"), + (crate::ReasoningEffort::Max, "max"), ] { let req = ConversationRequest::from_items(vec![ConversationItem::user("hi")]) .with_model("test"); diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index 29cc79d..25b5cdf 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -787,6 +787,10 @@ pub enum ReasoningEffort { Medium, High, Xhigh, + /// Distinct top tier above `xhigh` (OpenAI Responses and Anthropic + /// Messages both accept `xhigh` AND `max` as separate levels in 2026; + /// the Kimi wire spells its top tier `max` with no `xhigh`). + Max, } impl ReasoningEffort { @@ -798,11 +802,26 @@ impl ReasoningEffort { Self::Medium => crate::rs::ReasoningEffort::Medium, Self::High => crate::rs::ReasoningEffort::High, Self::Xhigh => crate::rs::ReasoningEffort::Xhigh, + // INTERIM: async-openai (0.33 pinned; upstream through 0.41 + // still lacks it) has no `Max` variant. The Responses backend + // gains true `max` via a post-serialize body patch in the OpenAI + // provider cycle; until then Max downgrades to xhigh — reachable + // via `[model.*] reasoning_effort = "max"` on a responses-backend + // model, hence the loud warn. + Self::Max => { + tracing::warn!( + "reasoning effort `max` downgraded to `xhigh` on the \ + Responses backend (typed wire enum has no max yet)" + ); + crate::rs::ReasoningEffort::Xhigh + } } } /// Inverse of [`to_responses_api`](Self::to_responses_api): the effort the - /// Responses API echoes back on `response.reasoning.effort`. + /// Responses API echoes back on `response.reasoning.effort`. (`max` + /// echoes cannot reach here — async-openai's enum has no such variant; + /// the OpenAI provider cycle handles them before typed parsing.) pub fn from_responses_api(effort: crate::rs::ReasoningEffort) -> Self { match effort { crate::rs::ReasoningEffort::None => Self::None, @@ -822,17 +841,21 @@ impl ReasoningEffort { Self::Medium => "medium", Self::High => "high", Self::Xhigh => "xhigh", + Self::Max => "max", } } - /// Anthropic Messages API `output_config.effort` string; `None` for unsupported variants. + /// Anthropic Messages API effort string; `None` for unsupported variants. + /// `xhigh` and `max` are distinct levels on the 2026 Messages API (both + /// appear in `GET /v1/models` `capabilities.effort`). pub fn to_messages_api(self) -> Option<&'static str> { match self { Self::None | Self::Minimal => None, Self::Low => Some("low"), Self::Medium => Some("medium"), Self::High => Some("high"), - Self::Xhigh => Some("max"), + Self::Xhigh => Some("xhigh"), + Self::Max => Some("max"), } } } @@ -853,7 +876,8 @@ impl std::str::FromStr for ReasoningEffort { "low" => Ok(Self::Low), "medium" => Ok(Self::Medium), "high" => Ok(Self::High), - "xhigh" | "max" => Ok(Self::Xhigh), // max is a CLI/UX alias of xhigh + "xhigh" => Ok(Self::Xhigh), + "max" => Ok(Self::Max), _ => Err(format!( "invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max)" )), @@ -861,11 +885,32 @@ impl std::str::FromStr for ReasoningEffort { } } -/// Canonical wire parse only (`max` → `Xhigh`); remapped menu ids need a model catalog. +/// Canonical wire parse; remapped menu ids need a model catalog. pub fn parse_canonical_effort_token(token: &str) -> Option { token.parse().ok() } +/// Deserialize an optional effort LENIENTLY for persisted stores (session +/// summaries, chat history): a token this binary doesn't know — written by a +/// newer kigi after the effort vocabulary grew, exactly what happened when +/// `max` split from `xhigh` — degrades to `None` with a warning instead of +/// failing the whole record, so listings and resume survive version +/// rollback. Non-string values still error (real corruption stays loud), and +/// config-TOML parsing stays strict — its layer warn-skips explicitly. +pub fn lenient_reasoning_effort_opt<'de, D>(d: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(d)?; + Ok(raw.and_then(|s| match s.parse::() { + Ok(effort) => Some(effort), + Err(error) => { + tracing::warn!(token = %s, %error, "persisted reasoning_effort unknown; dropping"); + None + } + })) +} + pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort"; pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort"; @@ -1224,6 +1269,7 @@ mod tests { ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Xhigh, + ReasoningEffort::Max, ] { let json = serde_json::to_string(&v).unwrap(); assert_eq!(json, format!("\"{}\"", v.as_str()), "serialize {v:?}"); @@ -1231,31 +1277,32 @@ mod tests { assert_eq!(back, v, "round-trip {v:?}"); } assert!(serde_json::from_str::("\"BOGUS\"").is_err()); - assert!(serde_json::from_str::("\"max\"").is_err()); } #[test] - fn reasoning_effort_from_str_accepts_max_as_xhigh() { + fn reasoning_effort_from_str_max_and_xhigh_are_distinct() { assert_eq!( "max".parse::().unwrap(), - ReasoningEffort::Xhigh + ReasoningEffort::Max ); assert_eq!( "MAX".parse::().unwrap(), - ReasoningEffort::Xhigh + ReasoningEffort::Max ); assert_eq!( "xhigh".parse::().unwrap(), ReasoningEffort::Xhigh ); - assert_eq!(ReasoningEffort::Xhigh.as_str(), "xhigh"); + // Messages API: distinct wire tokens per level (2026 capabilities). + assert_eq!(ReasoningEffort::Xhigh.to_messages_api(), Some("xhigh")); + assert_eq!(ReasoningEffort::Max.to_messages_api(), Some("max")); } #[test] fn parse_canonical_effort_token_helper() { assert_eq!( parse_canonical_effort_token("max"), - Some(ReasoningEffort::Xhigh) + Some(ReasoningEffort::Max) ); assert_eq!( parse_canonical_effort_token("high"), diff --git a/crates/codegen/kigi-shell/src/agent/handlers/model_switch.rs b/crates/codegen/kigi-shell/src/agent/handlers/model_switch.rs index 6af9598..f5f671d 100644 --- a/crates/codegen/kigi-shell/src/agent/handlers/model_switch.rs +++ b/crates/codegen/kigi-shell/src/agent/handlers/model_switch.rs @@ -110,6 +110,29 @@ pub(crate) async fn apply( .models_manager .model_supports_reasoning_effort(model_id.0.as_ref()) { + // Legacy migration: pre-split sessions persisted canonical + // `xhigh` for models whose live menu now spells the top tier + // `max` (K3). The wire is identical either way (kimi_compat + // renames xhigh→max), but the menu has no xhigh-valued row, so + // display/active-row would drift from the model vocabulary and + // the stale token would be re-persisted forever. Migrate once. + let eff = if eff == kigi_sampling_types::ReasoningEffort::Xhigh + && !agent + .models_manager + .model_offers_effort(model_id.0.as_ref(), eff) + && agent.models_manager.model_offers_effort( + model_id.0.as_ref(), + kigi_sampling_types::ReasoningEffort::Max, + ) { + tracing::info!( + session_id = % session_id.0, + "set_session_model: migrating legacy xhigh override to max \ + (model menu offers max, not xhigh)" + ); + kigi_sampling_types::ReasoningEffort::Max + } else { + eff + }; tracing::info!( session_id = % session_id.0, effort = % eff, "set_session_model: applying reasoning_effort override from meta" diff --git a/crates/codegen/kigi-shell/src/agent/models.rs b/crates/codegen/kigi-shell/src/agent/models.rs index 2c24a91..d728ef5 100644 --- a/crates/codegen/kigi-shell/src/agent/models.rs +++ b/crates/codegen/kigi-shell/src/agent/models.rs @@ -561,6 +561,22 @@ impl ModelsManager { .unwrap_or(false) } + /// Whether the model's effort menu offers this canonical value (legacy + /// built-in set when the menu is empty). Used to migrate pre-split + /// `xhigh` overrides onto `max`-vocabulary models at restore. + pub fn model_offers_effort( + &self, + model_id: &str, + effort: kigi_sampling_types::ReasoningEffort, + ) -> bool { + self.inner + .models + .read() + .get(model_id) + .map(|e| model_offers_reasoning_effort(e.info(), effort)) + .unwrap_or(false) + } + /// The catalog default reasoning effort for `model_id`, if the catalog /// pins one. Used as the final fallback when neither the session handle /// nor the global config sets an explicit effort, so surfaced config stays @@ -2117,6 +2133,9 @@ pub(crate) fn resolve_model_catalog( /// Uses the server `reasoning_efforts` menu when present; otherwise the /// built-in low/medium/high/xhigh set (same as the pager legacy menu — no /// `none`/`minimal`). +/// `max` is NOT in the legacy built-in set: models gain it only via an +/// explicit server/BYOK menu entry (Kimi K3's `max` token), so an empty-menu +/// model rejects it rather than sending a level its endpoint may 400 on. fn model_offers_reasoning_effort(info: &config::ModelInfo, effort: ReasoningEffort) -> bool { if !info.supports_reasoning_effort { return false; @@ -3811,6 +3830,51 @@ mod tests { assert_eq!(key.0.as_ref(), "kigi"); } + /// The restore-migration inputs: a K3-shaped menu (low/high/max tokens) + /// offers Max but NOT Xhigh; a legacy empty-menu model offers Xhigh but + /// NOT Max. `model_switch` relies on exactly this pair to migrate + /// pre-split `xhigh` overrides onto `max`-vocabulary models. + #[test] + fn offers_effort_distinguishes_k3_menu_from_legacy_set() { + let k3_wire: kigi_models::WireModel = serde_json::from_value(serde_json::json!({ + "id": "k3", + "context_length": 1_048_576, + "supports_reasoning": true, + "supports_thinking_type": "only", + "think_efforts": { + "support": true, + "valid_efforts": ["low", "high", "max"], + "default_effort": "max" + } + })) + .unwrap(); + let k3_cfg = crate::agent::models_fetch::platform_wire_model_to_entry( + kigi_models::PlatformId::KimiCode, + k3_wire, + "https://api.kimi.com/coding/v1", + ); + let k3 = config::ModelInfo::from_config(&k3_cfg); + assert!(model_offers_reasoning_effort(&k3, ReasoningEffort::Max)); + assert!(model_offers_reasoning_effort(&k3, ReasoningEffort::Low)); + assert!( + !model_offers_reasoning_effort(&k3, ReasoningEffort::Xhigh), + "K3's menu has no xhigh token — the migration precondition" + ); + + let mut legacy_cfg = k3_cfg.clone(); + legacy_cfg.reasoning_efforts = Vec::new(); + legacy_cfg.supports_reasoning_effort = true; + let legacy = config::ModelInfo::from_config(&legacy_cfg); + assert!(model_offers_reasoning_effort( + &legacy, + ReasoningEffort::Xhigh + )); + assert!( + !model_offers_reasoning_effort(&legacy, ReasoningEffort::Max), + "legacy built-in set must not offer max (endpoint may 400)" + ); + } + fn test_available_keys(keys: &[&str]) -> IndexMap { keys.iter() .map(|k| { diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 3b343c8..5763d61 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -338,7 +338,7 @@ fn think_efforts_to_options( .collect() } -fn platform_wire_model_to_entry( +pub(crate) fn platform_wire_model_to_entry( platform: kigi_models::PlatformId, wire: kigi_models::WireModel, base_url: &str, @@ -703,7 +703,9 @@ mod tests { "https://api.kimi.com/coding/v1", ); assert!(entry.supports_reasoning_effort); - assert_eq!(entry.reasoning_effort, Some(ReasoningEffort::Xhigh)); + // The wire token "max" is canonical Max since the Xhigh/Max split; + // kimi_compat still spells it "max" on the inference wire. + assert_eq!(entry.reasoning_effort, Some(ReasoningEffort::Max)); let ids: Vec<&str> = entry .reasoning_efforts .iter() @@ -723,7 +725,7 @@ mod tests { [ ReasoningEffort::Low, ReasoningEffort::High, - ReasoningEffort::Xhigh + ReasoningEffort::Max ], ); let max = entry diff --git a/crates/codegen/kigi-shell/src/agent/session_config.rs b/crates/codegen/kigi-shell/src/agent/session_config.rs index 896ede8..852974b 100644 --- a/crates/codegen/kigi-shell/src/agent/session_config.rs +++ b/crates/codegen/kigi-shell/src/agent/session_config.rs @@ -59,6 +59,7 @@ fn effort_label(effort: ReasoningEffort) -> String { ReasoningEffort::Medium => "Medium", ReasoningEffort::High => "High", ReasoningEffort::Xhigh => "X-High", + ReasoningEffort::Max => "Max", } .to_string() } diff --git a/crates/codegen/kigi-shell/src/session/persistence.rs b/crates/codegen/kigi-shell/src/session/persistence.rs index ca4cab7..8c3c4a5 100644 --- a/crates/codegen/kigi-shell/src/session/persistence.rs +++ b/crates/codegen/kigi-shell/src/session/persistence.rs @@ -879,7 +879,14 @@ pub struct Summary { /// `None` for sessions created before this field existed. #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox_profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Lenient on read: an unknown token written by a newer kigi (grown + /// effort vocabulary) drops to `None` rather than hiding the whole + /// session from listings / failing resume after a version rollback. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "kigi_sampling_types::lenient_reasoning_effort_opt" + )] pub reasoning_effort: Option, } @@ -1002,6 +1009,22 @@ mod is_hidden_tests { } } + #[test] + fn summary_unknown_reasoning_effort_token_degrades_to_none() { + // A summary written by a NEWER kigi with a grown effort vocabulary + // must not vanish from listings or fail resume on this binary. + let mut s = summary_with_kind(None); + s.reasoning_effort = Some(ReasoningEffort::Max); + let json = serde_json::to_string(&s).unwrap(); + let future = json.replace("\"max\"", "\"hypermax\""); + assert_ne!(json, future, "fixture must actually carry the token"); + let back: Summary = serde_json::from_str(&future).expect("record must survive"); + assert_eq!(back.reasoning_effort, None); + // Known tokens (including post-split "max") still round-trip. + let back: Summary = serde_json::from_str(&json).unwrap(); + assert_eq!(back.reasoning_effort, Some(ReasoningEffort::Max)); + } + #[test] fn summary_round_trips_and_defaults_reasoning_effort() { let mut s = summary_with_kind(None); diff --git a/crates/codegen/kigi-shell/tests/test_built_binary_e2e.rs b/crates/codegen/kigi-shell/tests/test_built_binary_e2e.rs index a5eb0a3..8742b2c 100644 --- a/crates/codegen/kigi-shell/tests/test_built_binary_e2e.rs +++ b/crates/codegen/kigi-shell/tests/test_built_binary_e2e.rs @@ -1401,15 +1401,17 @@ async fn headless_reasoning_efforts_payload_parses_and_legacy_effort_rides_wire( assert_headless_success(&result, "kigi -p reasoning_efforts list", Some(&server)); assert_no_crashes(&result.stderr); - // The legacy effort scalar rides the chat-completions request unchanged. + // The chat-completions body is always adapted before send: the effort + // scalar becomes Kimi's `thinking` field, with canonical `xhigh` + // spelled `max` on the wire (kimi_compat rename). let effort_on_wire = server.requests().iter().any(|r| { r.body.as_ref().is_some_and(|body| { - body.pointer("/reasoning_effort").and_then(|v| v.as_str()) == Some("xhigh") + body.pointer("/thinking/effort").and_then(|v| v.as_str()) == Some("max") }) }); assert!( effort_on_wire, - "legacy reasoning_effort=xhigh must reach the wire\n{}", + "legacy reasoning_effort=xhigh must reach the wire as thinking.effort=max\n{}", server.request_log_summary() ); } diff --git a/crates/codegen/kigi-tui/src/acp/model_state.rs b/crates/codegen/kigi-tui/src/acp/model_state.rs index afb03eb..0142342 100644 --- a/crates/codegen/kigi-tui/src/acp/model_state.rs +++ b/crates/codegen/kigi-tui/src/acp/model_state.rs @@ -247,9 +247,10 @@ impl ModelState { { return Some(option.value); } - // Canonical level (e.g. "high", "max"→xhigh) only if the model menu - // actually offers that value — not free-form power-user aliases that - // would 400 on the server (e.g. `none` on kigi-4.5). + // Canonical level (e.g. "high", "max") only if the model menu + // actually offers that value — not free-form power-user tokens that + // would 400 on the server (e.g. `none` on kigi-4.5, or `max` on a + // model whose vocabulary tops out at xhigh). let parsed = token.parse::().ok()?; options .iter() diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/session/take_deferred.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/session/take_deferred.rs index 32589fb..980a6de 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/session/take_deferred.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/session/take_deferred.rs @@ -183,16 +183,47 @@ fn stashed_model_keeps_model_when_unsupported() { } #[test] -fn effort_only_accepts_max_as_xhigh() { +fn effort_max_rejected_when_model_offers_no_max() { + // Since the Xhigh/Max split, "max" is its own canonical level — a model + // whose menu has no max-valued option rejects it with the offered list + // (previously the parse alias silently rode it onto the xhigh option). let models = models_with_current(true); let out = take_deferred_model_switch(None, &models, Some("max")); assert_eq!( out, DeferredSwitchOutcome { - switch: Some(( - models.current.clone().unwrap(), - Some(ReasoningEffort::Xhigh) - )), + switch: None, + effort_error: Some(EffortTokenError::UnknownToken { + token: "max".into(), + offered: vec!["deep".into(), "high".into()], + }), + } + ); +} + +#[test] +fn effort_only_accepts_canonical_max_when_offered() { + // K3-shaped menu: the "max" wire token carries canonical Max. + let id = acp::ModelId::new(Arc::from("k3")); + let meta = serde_json::json!({ + "supportsReasoningEffort": true, + "reasoningEffort": "max", + "reasoningEfforts": [ + { "id": "low", "value": "low", "label": "Low" }, + { "id": "high", "value": "high", "label": "High" }, + { "id": "max", "value": "max", "label": "Max" }, + ], + }); + let info = acp::ModelInfo::new(id.clone(), id.0.to_string()).meta(meta.as_object().cloned()); + let mut models = ModelState::default(); + models.available.insert(id.clone(), info); + models.current = Some(id); + models.reasoning_effort = Some(ReasoningEffort::High); + let out = take_deferred_model_switch(None, &models, Some("max")); + assert_eq!( + out, + DeferredSwitchOutcome { + switch: Some((models.current.clone().unwrap(), Some(ReasoningEffort::Max))), effort_error: None, } ); diff --git a/crates/codegen/kigi-tui/src/slash/commands/effort_levels.rs b/crates/codegen/kigi-tui/src/slash/commands/effort_levels.rs index f34e071..bb8bd03 100644 --- a/crates/codegen/kigi-tui/src/slash/commands/effort_levels.rs +++ b/crates/codegen/kigi-tui/src/slash/commands/effort_levels.rs @@ -5,7 +5,10 @@ use kigi_shell::sampling::types::{ReasoningEffort, ReasoningEffortOption}; use crate::slash::command::ArgItem; /// Effort levels in the built-in fallback menu (strongest first). `none`/`minimal` -/// are still accepted by `ReasoningEffort::from_str` for power users. +/// are still accepted by `ReasoningEffort::from_str` for power users. `max` is +/// deliberately absent: it exists only where a model's server menu offers it +/// (e.g. Kimi K3) — the legacy fallback reproduces the historical rows, and +/// offering `max` on models that reject it would 400. pub(crate) const EFFORT_LEVELS: &[ReasoningEffort] = &[ ReasoningEffort::Xhigh, ReasoningEffort::High, @@ -20,7 +23,8 @@ pub(crate) fn effort_description(level: ReasoningEffort) -> &'static str { ReasoningEffort::Low => "Faster, lighter reasoning", ReasoningEffort::Medium => "Balanced reasoning", ReasoningEffort::High => "Heavy reasoning", - ReasoningEffort::Xhigh => "Maximum reasoning", + ReasoningEffort::Xhigh => "Extra-heavy reasoning", + ReasoningEffort::Max => "Maximum reasoning", } } diff --git a/crates/codegen/kigi-tui/tests/pty_e2e/reasoning_efforts_menu_renders_and_remaps_on_wire.rs b/crates/codegen/kigi-tui/tests/pty_e2e/reasoning_efforts_menu_renders_and_remaps_on_wire.rs index b94e64c..3cd2fdc 100644 --- a/crates/codegen/kigi-tui/tests/pty_e2e/reasoning_efforts_menu_renders_and_remaps_on_wire.rs +++ b/crates/codegen/kigi-tui/tests/pty_e2e/reasoning_efforts_menu_renders_and_remaps_on_wire.rs @@ -69,13 +69,16 @@ async fn reasoning_efforts_menu_renders_and_remaps_on_wire() { .wait_for_text("second turn", Duration::from_secs(30)) .expect("second turn rendered"); - let sent_xhigh = content + // The chat-completions body is always adapted before send: the + // `reasoning_effort` scalar is folded into Kimi's `thinking` field and + // canonical `xhigh` is spelled `max` on the wire (kimi_compat). + let sent_effort = content .request_bodies() .iter() - .any(|b| b.pointer("/reasoning_effort").and_then(|v| v.as_str()) == Some("xhigh")); + .any(|b| b.pointer("/thinking/effort").and_then(|v| v.as_str()) == Some("max")); assert!( - sent_xhigh, - "`/effort deep` must send the mapped canonical reasoning_effort=xhigh\nbodies: {:#?}", + sent_effort, + "`/effort deep` must send the remapped level as thinking.effort=max\nbodies: {:#?}", content.request_bodies() );