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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user