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