Split canonical ReasoningEffort::Max out of Xhigh (providers P0c-1)

OpenAI (Responses) and Anthropic (Messages) treat xhigh and max as
DISTINCT effort levels in 2026, and the Kimi K3 wire's top tier is max —
the old parse alias (max→Xhigh) conflated them. Canonical Max now exists:
parse/as_str/serde split, Messages mapping sends xhigh and max as their
own tokens (was Xhigh→"max"), and the K3 menu token max carries
canonical Max end to end.

Kimi wire is byte-identical in all four flows (menu pick, restored
legacy xhigh session, --reasoning-effort flag, /effort command) —
adversarially traced and pinned: kimi_compat's string-level xhigh→max
rename covers legacy tokens, max passes through verbatim.

From the review:
- Rollback safety: persisted reasoning_effort (session summaries, chat
  history) deserializes leniently — unknown future tokens degrade to
  None with a warning instead of hiding sessions or failing resume.
- Restore migration: a pre-split xhigh override onto a model whose menu
  offers max but not xhigh (K3) migrates once, healing display/active-row
  drift and re-persisting the live vocabulary.
- /effort max now rejects (with the offered list) on models whose menu
  lacks a max row instead of silently applying xhigh; deliberate, tested.
- The interim Responses-backend Max→xhigh downgrade (async-openai has no
  Max variant through 0.41) warns loudly; real max wiring lands with the
  OpenAI provider cycle via post-serialize body patch.
- Two rusted ignored-e2e wire pins asserted the pre-adapt reasoning_effort
  key (deleted by the body adapter since ea0ce9d); they now pin the real
  thinking.effort=max shape.
This commit is contained in:
2026-07-21 02:36:37 -04:00
parent c5ddaec71e
commit 83e6935189
13 changed files with 276 additions and 37 deletions
+12 -2
View File
@@ -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" });
@@ -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<crate::ReasoningEffort>,
}
@@ -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");
+58 -11
View File
@@ -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<ReasoningEffort> {
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<Option<ReasoningEffort>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<String>::deserialize(d)?;
Ok(raw.and_then(|s| match s.parse::<ReasoningEffort>() {
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::<ReasoningEffort>("\"BOGUS\"").is_err());
assert!(serde_json::from_str::<ReasoningEffort>("\"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::<ReasoningEffort>().unwrap(),
ReasoningEffort::Xhigh
ReasoningEffort::Max
);
assert_eq!(
"MAX".parse::<ReasoningEffort>().unwrap(),
ReasoningEffort::Xhigh
ReasoningEffort::Max
);
assert_eq!(
"xhigh".parse::<ReasoningEffort>().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"),
@@ -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"
@@ -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<acp::ModelId, acp::ModelInfo> {
keys.iter()
.map(|k| {
@@ -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
@@ -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()
}
@@ -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<String>,
#[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<ReasoningEffort>,
}
@@ -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);
@@ -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()
);
}
@@ -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::<ReasoningEffort>().ok()?;
options
.iter()
@@ -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,
}
);
@@ -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",
}
}
@@ -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()
);