From 2e9213a7845e8557b36956c6d896dcc1fdfdd0a0 Mon Sep 17 00:00:00 2001 From: Zyy Date: Mon, 27 Jul 2026 16:27:41 +0800 Subject: [PATCH] fix(backend): derive Codex metadata from provider catalogs Share Codex Responses request semantics across OpenAI and xAI providers so reasoning effort, presets, request kind, subagent kind, and compaction observations stay consistent. Read xAI reasoning efforts from official catalog feature options instead of model-name hardcoding, and make Codex service tiers data-driven through ModelPresentation rather than API adapter defaults. Co-authored-by: Codex --- .../crates/gateway-api/src/openai/models.rs | 20 ++- .../crates/gateway-api/tests/openai/models.rs | 24 +++- .../crates/gateway-core/src/routing/mod.rs | 63 +++++++++ .../gateway-protocol/src/openai/codex.rs | 126 ++++++++++++++++++ .../crates/gateway-protocol/src/openai/mod.rs | 7 + .../gateway-protocol/tests/openai/codex.rs | 61 +++++++++ .../gateway-protocol/tests/openai/mod.rs | 1 + .../crates/providers/openai/src/provider.rs | 11 +- .../src/transport/protocol/responses.rs | 108 +-------------- backend/crates/providers/xai/src/provider.rs | 56 +++----- .../providers/xai/src/transport/catalog.rs | 93 ++++++++++--- .../providers/xai/src/transport/request.rs | 3 +- .../providers/xai/tests/provider/contract.rs | 96 +++++++++++++ .../providers/xai/tests/transport/catalog.rs | 29 ++++ .../providers/xai/tests/transport/request.rs | 15 +++ 15 files changed, 545 insertions(+), 168 deletions(-) create mode 100644 backend/crates/gateway-protocol/src/openai/codex.rs create mode 100644 backend/crates/gateway-protocol/tests/openai/codex.rs diff --git a/backend/crates/gateway-api/src/openai/models.rs b/backend/crates/gateway-api/src/openai/models.rs index 64a56641e..a30f22b5e 100644 --- a/backend/crates/gateway-api/src/openai/models.rs +++ b/backend/crates/gateway-api/src/openai/models.rs @@ -189,6 +189,22 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value { } else { vec!["text"] }; + let additional_speed_tiers = presentation + .service_tiers() + .iter() + .filter_map(|tier| tier.speed_tier()) + .collect::>(); + let service_tiers = presentation + .service_tiers() + .iter() + .map(|tier| { + json!({ + "id": tier.id(), + "name": tier.name(), + "description": tier.description(), + }) + }) + .collect::>(); json!({ "slug": id, @@ -200,8 +216,8 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value { "visibility": if presentation.hidden() { "hide" } else { "list" }, "supported_in_api": true, "priority": index.saturating_add(1), - "additional_speed_tiers": [], - "service_tiers": [], + "additional_speed_tiers": additional_speed_tiers, + "service_tiers": service_tiers, "availability_nux": Value::Null, "upgrade": Value::Null, "base_instructions": CODEX_BASE_INSTRUCTIONS, diff --git a/backend/crates/gateway-api/tests/openai/models.rs b/backend/crates/gateway-api/tests/openai/models.rs index 7aec8d66e..7c0ff6860 100644 --- a/backend/crates/gateway-api/tests/openai/models.rs +++ b/backend/crates/gateway-api/tests/openai/models.rs @@ -10,7 +10,9 @@ use gateway_core::engine::execution::{ StartedExecution, }; use gateway_core::error::{GatewayError, GatewayErrorKind}; -use gateway_core::routing::{ModelPresentation, PublicModelId, PublicModelProfile}; +use gateway_core::routing::{ + ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile, +}; use tower::ServiceExt; use super::{api_router, authenticated_client}; @@ -73,7 +75,15 @@ impl ExecutionService for ModelsExecution { ) .with_context_window_tokens(Some(500_000)) .with_image_input(true) - .with_agent_tools(true, true), + .with_agent_tools(true, true) + .with_service_tiers(vec![ + ModelServiceTier::new( + "priority", + "Fast", + "Route the request through Codex fast mode.", + ) + .with_speed_tier("fast"), + ]), )] } @@ -145,7 +155,15 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() { assert_eq!(model["default_reasoning_level"], "medium"); assert_eq!(model["context_window"], 500_000); assert_eq!(model["apply_patch_tool_type"], "freeform"); - assert_eq!(model["service_tiers"], serde_json::json!([])); + assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"])); + assert_eq!( + model["service_tiers"], + serde_json::json!([{ + "id": "priority", + "name": "Fast", + "description": "Route the request through Codex fast mode." + }]) + ); assert_eq!( model["supported_reasoning_levels"], serde_json::json!([ diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index 82ce80adc..4eeeb16f1 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -152,9 +152,61 @@ pub struct ModelPresentation { search_tool: bool, image_detail_original: bool, verbosity: bool, + service_tiers: Vec, hidden: bool, } +/// Provider 声明给 Codex 客户端的服务档位。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelServiceTier { + id: String, + name: String, + description: String, + speed_tier: Option, +} + +impl ModelServiceTier { + #[must_use] + pub fn new( + id: impl Into, + name: impl Into, + description: impl Into, + ) -> Self { + Self { + id: id.into(), + name: name.into(), + description: description.into(), + speed_tier: None, + } + } + + #[must_use] + pub fn with_speed_tier(mut self, speed_tier: impl Into) -> Self { + self.speed_tier = Some(speed_tier.into()); + self + } + + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + #[must_use] + pub fn description(&self) -> &str { + &self.description + } + + #[must_use] + pub fn speed_tier(&self) -> Option<&str> { + self.speed_tier.as_deref() + } +} + impl ModelPresentation { #[must_use] pub fn new(display_name: Option, description: Option) -> Self { @@ -213,6 +265,12 @@ impl ModelPresentation { self } + #[must_use] + pub fn with_service_tiers(mut self, service_tiers: Vec) -> Self { + self.service_tiers = service_tiers; + self + } + #[must_use] pub const fn with_hidden(mut self, hidden: bool) -> Self { self.hidden = hidden; @@ -274,6 +332,11 @@ impl ModelPresentation { self.verbosity } + #[must_use] + pub fn service_tiers(&self) -> &[ModelServiceTier] { + &self.service_tiers + } + #[must_use] pub const fn hidden(&self) -> bool { self.hidden diff --git a/backend/crates/gateway-protocol/src/openai/codex.rs b/backend/crates/gateway-protocol/src/openai/codex.rs new file mode 100644 index 000000000..c314eb0e0 --- /dev/null +++ b/backend/crates/gateway-protocol/src/openai/codex.rs @@ -0,0 +1,126 @@ +use serde_json::{Map, Value}; + +const MULTI_AGENT_MODE_OPEN_TAG: &str = ""; +const MULTI_AGENT_MODE_CLOSE_TAG: &str = ""; +const PROACTIVE_MULTI_AGENT_MODE_PREFIX: &str = "Proactive multi-agent delegation is active."; + +/// 从 OpenAI Responses 请求中提取的稳定 Codex 请求语义。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CodexResponsesRequestSemantics { + /// 客户端原始选择的推理强度。 + pub reasoning_effort: Option, + /// Codex turn metadata 中的请求类型。 + pub request_kind: Option, + /// Codex turn metadata 中的子代理类型。 + pub subagent_kind: Option, + /// 网关侧派生出的推理预设。 + pub reasoning_preset: Option<&'static str>, + /// 请求是否为 Codex 压缩请求。 + pub compact: bool, +} + +/// 从 Responses body 与非 wire 协议上下文中提取 Codex 语义。 +#[must_use] +pub fn codex_responses_request_semantics( + body: &Map, + context: &Map, +) -> CodexResponsesRequestSemantics { + codex_responses_request_semantics_with_turn_metadata( + body, + non_empty_string(context.get("turn_metadata")), + ) +} + +/// 调用方已解析权威 turn metadata 时,从 Responses body 中提取 Codex 语义。 +#[must_use] +pub fn codex_responses_request_semantics_with_turn_metadata( + body: &Map, + turn_metadata: Option<&str>, +) -> CodexResponsesRequestSemantics { + let reasoning_effort = body + .get("reasoning") + .and_then(Value::as_object) + .and_then(|reasoning| non_empty_string(reasoning.get("effort"))) + .map(ToOwned::to_owned); + let parsed_turn_metadata = turn_metadata + .or_else(|| request_turn_metadata(body)) + .and_then(|value| serde_json::from_str::(value).ok()); + let request_kind = parsed_turn_metadata + .as_ref() + .and_then(|metadata| non_empty_string(metadata.get("request_kind"))) + .map(ToOwned::to_owned); + let subagent_kind = parsed_turn_metadata + .as_ref() + .and_then(|metadata| non_empty_string(metadata.get("subagent_kind"))) + .map(ToOwned::to_owned); + let proactive_multi_agent = latest_multi_agent_mode(input_items(body).iter()) + .is_some_and(|mode| mode.starts_with(PROACTIVE_MULTI_AGENT_MODE_PREFIX)); + let compact_trigger = input_items(body) + .iter() + .any(|item| non_empty_string(item.get("type")) == Some("compaction_trigger")); + let reasoning_preset = (subagent_kind.is_none() + && reasoning_effort.as_deref() == Some("max") + && proactive_multi_agent) + .then_some("ultra"); + let compact = request_kind.as_deref() == Some("compaction") || compact_trigger; + + CodexResponsesRequestSemantics { + reasoning_effort, + request_kind, + subagent_kind, + reasoning_preset, + compact, + } +} + +fn request_turn_metadata(body: &Map) -> Option<&str> { + non_empty_string(body.get("turnMetadata")) + .or_else(|| non_empty_string(body.get("turn_metadata"))) + .or_else(|| non_empty_string(body.get("x-codex-turn-metadata"))) + .or_else(|| client_metadata_string(body, "x-codex-turn-metadata")) + .or_else(|| client_metadata_string(body, "turnMetadata")) + .or_else(|| client_metadata_string(body, "turn_metadata")) +} + +fn client_metadata_string<'a>(body: &'a Map, key: &str) -> Option<&'a str> { + body.get("client_metadata") + .and_then(Value::as_object) + .and_then(|metadata| non_empty_string(metadata.get(key))) +} + +fn input_items(body: &Map) -> &[Value] { + body.get("input") + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice) +} + +fn latest_multi_agent_mode<'a>( + input: impl DoubleEndedIterator, +) -> Option<&'a str> { + input.rev().find_map(|item| { + if non_empty_string(item.get("role")) != Some("developer") { + return None; + } + item.get("content")? + .as_array()? + .iter() + .rev() + .filter_map(|content| non_empty_string(content.get("text"))) + .find_map(multi_agent_mode_from_text) + }) +} + +fn multi_agent_mode_from_text(text: &str) -> Option<&str> { + let close = text.rfind(MULTI_AGENT_MODE_CLOSE_TAG)?; + let before_close = &text[..close]; + let open = before_close.rfind(MULTI_AGENT_MODE_OPEN_TAG)?; + let body = &before_close[open + MULTI_AGENT_MODE_OPEN_TAG.len()..]; + Some(body.trim()) +} + +fn non_empty_string(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} diff --git a/backend/crates/gateway-protocol/src/openai/mod.rs b/backend/crates/gateway-protocol/src/openai/mod.rs index 92ee03347..3583e73dc 100644 --- a/backend/crates/gateway-protocol/src/openai/mod.rs +++ b/backend/crates/gateway-protocol/src/openai/mod.rs @@ -9,7 +9,14 @@ pub const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str = /// Codex memory consolidation 请求标记。 pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request"; +/// OpenAI Responses 请求中携带的 Codex 专属语义。 +pub mod codex; /// OpenAI/Codex 事件语义、用量与限流字段编解码。 pub mod events; /// Server-Sent Events 帧的解析与编码。 pub mod sse; + +pub use codex::{ + CodexResponsesRequestSemantics, codex_responses_request_semantics, + codex_responses_request_semantics_with_turn_metadata, +}; diff --git a/backend/crates/gateway-protocol/tests/openai/codex.rs b/backend/crates/gateway-protocol/tests/openai/codex.rs new file mode 100644 index 000000000..052ebf9ab --- /dev/null +++ b/backend/crates/gateway-protocol/tests/openai/codex.rs @@ -0,0 +1,61 @@ +use gateway_protocol::openai::codex_responses_request_semantics; +use serde_json::{Map, Value, json}; + +#[test] +fn codex_semantics_should_use_protocol_context_before_body_metadata() { + let body = json!({ + "model": "grok-4.5", + "reasoning": {"effort": " xhigh "}, + "client_metadata": { + "x-codex-turn-metadata": "{\"request_kind\":\"review\"}" + }, + "input": [ + {"type": "message", "role": "user", "content": "history"}, + {"type": "compaction_trigger"} + ] + }); + let body = object(body); + let context = Map::from_iter([( + "turn_metadata".to_owned(), + json!("{\"request_kind\":\"compaction\",\"subagent_kind\":\"compact\"}"), + )]); + + let semantics = codex_responses_request_semantics(&body, &context); + + assert_eq!(semantics.reasoning_effort.as_deref(), Some("xhigh")); + assert_eq!(semantics.request_kind.as_deref(), Some("compaction")); + assert_eq!(semantics.subagent_kind.as_deref(), Some("compact")); + assert_eq!(semantics.reasoning_preset, None); + assert!(semantics.compact); +} + +#[test] +fn codex_semantics_should_detect_ultra_preset_from_proactive_multi_agent() { + let body = object(json!({ + "model": "gpt-test", + "reasoning": {"effort": "max"}, + "input": [ + { + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "Proactive multi-agent delegation is active." + }] + } + ] + })); + + let semantics = codex_responses_request_semantics(&body, &Map::new()); + + assert_eq!(semantics.reasoning_effort.as_deref(), Some("max")); + assert_eq!(semantics.reasoning_preset, Some("ultra")); + assert!(!semantics.compact); +} + +fn object(value: Value) -> Map { + let Value::Object(object) = value else { + panic!("test value must be an object"); + }; + object +} diff --git a/backend/crates/gateway-protocol/tests/openai/mod.rs b/backend/crates/gateway-protocol/tests/openai/mod.rs index 395b02310..ebfb9ccfd 100644 --- a/backend/crates/gateway-protocol/tests/openai/mod.rs +++ b/backend/crates/gateway-protocol/tests/openai/mod.rs @@ -1,2 +1,3 @@ +mod codex; mod events; mod sse; diff --git a/backend/crates/providers/openai/src/provider.rs b/backend/crates/providers/openai/src/provider.rs index 00bd2065d..90f0ebf19 100644 --- a/backend/crates/providers/openai/src/provider.rs +++ b/backend/crates/providers/openai/src/provider.rs @@ -177,14 +177,9 @@ impl Provider for CodexProvider { input.extend(encoded.input().iter().cloned()); encoded.set_input(input); } - let reasoning_effort = encoded - .reasoning() - .and_then(|reasoning| reasoning.get("effort")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - (encoded.semantics(), reasoning_effort) + let semantics = encoded.semantics(); + let reasoning_effort = semantics.reasoning_effort.clone(); + (semantics, reasoning_effort) }) .unwrap_or_default(); ProviderRequestObservation { diff --git a/backend/crates/providers/openai/src/transport/protocol/responses.rs b/backend/crates/providers/openai/src/transport/protocol/responses.rs index ab2c050e3..4bbeafe5e 100644 --- a/backend/crates/providers/openai/src/transport/protocol/responses.rs +++ b/backend/crates/providers/openai/src/transport/protocol/responses.rs @@ -1,13 +1,12 @@ use std::fmt; -use gateway_protocol::openai::events; +use gateway_protocol::openai::{ + CodexResponsesRequestSemantics as CodexRequestSemantics, + codex_responses_request_semantics_with_turn_metadata, events, +}; use serde::Serialize; use serde_json::{Map, Value}; -const MULTI_AGENT_MODE_OPEN_TAG: &str = ""; -const MULTI_AGENT_MODE_CLOSE_TAG: &str = ""; -const PROACTIVE_MULTI_AGENT_MODE_PREFIX: &str = "Proactive multi-agent delegation is active."; - /// Codex Responses 上游请求体。 /// /// 发往上游的 Responses 请求。`body` 持有客户端原始 JSON object,逐字段(含顺序、 @@ -93,19 +92,6 @@ pub enum PreviousResponseScope { ExternalUnknown, } -/// Responses 请求携带的 Codex 运行语义。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct CodexRequestSemantics { - /// Codex turn metadata 中的请求类型。 - pub request_kind: Option, - /// Codex turn metadata 中的子代理类型。 - pub subagent_kind: Option, - /// Codex 客户端选择的推理预设。 - pub reasoning_preset: Option<&'static str>, - /// 请求是否为远端压缩。 - pub compact: bool, -} - impl Serialize for CodexResponsesRequest { /// 上游 body 序列化即原始 `body` map(HTTP SSE 直发;WebSocket 在外层前置 `type`)。 fn serialize(&self, serializer: S) -> Result @@ -608,34 +594,12 @@ impl CodexResponsesRequest { /// 提取 Codex 请求类型、子代理类型、推理预设与压缩语义。 pub fn semantics(&self) -> CodexRequestSemantics { - let turn_metadata = self.turn_metadata.as_deref().or_else(|| { - self.client_metadata()? - .get("x-codex-turn-metadata")? - .as_str() - }); - let effort = self - .reasoning() - .and_then(|value| value.get("effort")) - .and_then(Value::as_str); - let proactive_multi_agent = self - .latest_multi_agent_mode() - .is_some_and(|mode| mode.starts_with(PROACTIVE_MULTI_AGENT_MODE_PREFIX)); - let compact_trigger = self - .input() - .iter() - .any(|item| item.get("type").and_then(Value::as_str) == Some("compaction_trigger")); - codex_request_semantics_from_parts( - turn_metadata, - effort, - proactive_multi_agent, - compact_trigger, + codex_responses_request_semantics_with_turn_metadata( + self.body(), + self.turn_metadata.as_deref(), ) } - fn latest_multi_agent_mode(&self) -> Option<&str> { - latest_multi_agent_mode(self.input().iter()) - } - /// 设置 / 合并 client metadata。 pub fn set_client_metadata(&mut self, client_metadata: Option) { match client_metadata { @@ -664,61 +628,3 @@ impl CodexResponsesRequest { } } } - -fn non_empty_owned_string(value: &str) -> Option { - let value = value.trim(); - (!value.is_empty()).then(|| value.to_string()) -} - -pub(crate) fn codex_request_semantics_from_parts( - turn_metadata: Option<&str>, - effort: Option<&str>, - proactive_multi_agent: bool, - compact_trigger: bool, -) -> CodexRequestSemantics { - let turn_metadata = turn_metadata.and_then(|value| serde_json::from_str::(value).ok()); - let request_kind = turn_metadata - .as_ref() - .and_then(|metadata| metadata.get("request_kind")) - .and_then(Value::as_str) - .and_then(non_empty_owned_string); - let subagent_kind = turn_metadata - .as_ref() - .and_then(|metadata| metadata.get("subagent_kind")) - .and_then(Value::as_str) - .and_then(non_empty_owned_string); - let reasoning_preset = - (subagent_kind.is_none() && effort == Some("max") && proactive_multi_agent) - .then_some("ultra"); - let compact = request_kind.as_deref() == Some("compaction") || compact_trigger; - CodexRequestSemantics { - request_kind, - subagent_kind, - reasoning_preset, - compact, - } -} - -fn latest_multi_agent_mode<'a>( - input: impl DoubleEndedIterator, -) -> Option<&'a str> { - input.rev().find_map(|item| { - if item.get("role").and_then(Value::as_str) != Some("developer") { - return None; - } - item.get("content")? - .as_array()? - .iter() - .rev() - .filter_map(|content| content.get("text").and_then(Value::as_str)) - .find_map(multi_agent_mode_from_text) - }) -} - -fn multi_agent_mode_from_text(text: &str) -> Option<&str> { - let close = text.rfind(MULTI_AGENT_MODE_CLOSE_TAG)?; - let before_close = &text[..close]; - let open = before_close.rfind(MULTI_AGENT_MODE_OPEN_TAG)?; - let body = &before_close[open + MULTI_AGENT_MODE_OPEN_TAG.len()..]; - Some(body.trim()) -} diff --git a/backend/crates/providers/xai/src/provider.rs b/backend/crates/providers/xai/src/provider.rs index b74d6dc5a..4b195b4e3 100644 --- a/backend/crates/providers/xai/src/provider.rs +++ b/backend/crates/providers/xai/src/provider.rs @@ -37,6 +37,7 @@ use gateway_core::task::{ WorkerKind, WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError, }; +use gateway_protocol::openai::codex_responses_request_semantics; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use url::Url; @@ -124,19 +125,17 @@ impl Provider for GrokBuildProvider { let Operation::Generate(request) = operation else { return ProviderRequestObservation::default(); }; - let reasoning_effort = request - .protocol_payload() - .body() - .get("reasoning") - .and_then(Value::as_object) - .and_then(|reasoning| reasoning.get("effort")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); + let payload = request.protocol_payload(); + if payload.protocol() != "openai" { + return ProviderRequestObservation::default(); + } + let semantics = codex_responses_request_semantics(payload.body(), payload.context()); ProviderRequestObservation { - reasoning_effort, - ..ProviderRequestObservation::default() + reasoning_effort: semantics.reasoning_effort, + reasoning_preset: semantics.reasoning_preset.map(str::to_owned), + request_kind: semantics.request_kind, + subagent_kind: semantics.subagent_kind, + compact: semantics.compact, } } @@ -426,12 +425,6 @@ fn default_grok_model_presentation() -> ModelPresentation { Some("Grok 4.5".to_owned()), Some("xAI Grok 4.5 frontier model with reasoning and vision.".to_owned()), ) - .with_reasoning( - Some("medium".to_owned()), - ["low", "medium", "high", "xhigh"] - .map(str::to_owned) - .to_vec(), - ) .with_context_window_tokens(Some(500_000)) .with_image_input(true) .with_agent_tools(true, true) @@ -452,25 +445,20 @@ fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation { .default_reasoning_effort() .map(|effort| effort.as_str().to_owned()); let (default_reasoning, reasoning_efforts) = match reasoning_evidence { - GrokCatalogCapabilityEvidence::DeclaredNative if !catalog_reasoning_efforts.is_empty() => ( - catalog_default_reasoning.or_else(|| catalog_reasoning_efforts.first().cloned()), - catalog_reasoning_efforts, - ), - GrokCatalogCapabilityEvidence::DeclaredNative => ( - catalog_default_reasoning.or_else(|| Some("medium".to_owned())), - ["low", "medium", "high", "xhigh"] - .map(str::to_owned) - .to_vec(), - ), + GrokCatalogCapabilityEvidence::DeclaredNative if !catalog_reasoning_efforts.is_empty() => { + let default_reasoning = catalog_default_reasoning + .filter(|default| { + catalog_reasoning_efforts + .iter() + .any(|effort| effort == default) + }) + .or_else(|| catalog_reasoning_efforts.first().cloned()); + (default_reasoning, catalog_reasoning_efforts) + } + GrokCatalogCapabilityEvidence::DeclaredNative => (None, Vec::new()), GrokCatalogCapabilityEvidence::DeclaredUnsupported => { (Some("none".to_owned()), vec!["none".to_owned()]) } - GrokCatalogCapabilityEvidence::Unknown if known_grok_4_5 => ( - catalog_default_reasoning.or_else(|| Some("medium".to_owned())), - ["low", "medium", "high", "xhigh"] - .map(str::to_owned) - .to_vec(), - ), GrokCatalogCapabilityEvidence::Unknown => (None, Vec::new()), }; let context_window_tokens = model diff --git a/backend/crates/providers/xai/src/transport/catalog.rs b/backend/crates/providers/xai/src/transport/catalog.rs index e9d3b12f9..febbe70dd 100644 --- a/backend/crates/providers/xai/src/transport/catalog.rs +++ b/backend/crates/providers/xai/src/transport/catalog.rs @@ -901,6 +901,7 @@ struct GrokModelWire { reasoning_effort: Option, #[serde(default, rename = "reasoningEfforts", alias = "reasoning_efforts")] reasoning_efforts: Vec, + features: Option, #[serde(rename = "supportsBackendSearch", alias = "supports_backend_search")] supports_backend_search: Option, #[serde(rename = "streamToolCalls", alias = "stream_tool_calls")] @@ -908,6 +909,21 @@ struct GrokModelWire { hidden: Option, } +#[derive(Debug, Deserialize)] +struct GrokModelFeaturesWire { + reasoning: Option, + #[serde(rename = "reasoningEffortOptions", alias = "reasoning_effort_options")] + reasoning_effort_options: Option, +} + +#[derive(Debug, Deserialize)] +struct GrokReasoningEffortOptionsWire { + #[serde(default, rename = "supportedEfforts", alias = "supported_efforts")] + supported_efforts: Vec, + #[serde(rename = "defaultEffort", alias = "default_effort")] + default_effort: Option, +} + #[derive(Debug, Deserialize)] #[serde(untagged)] enum GrokReasoningEffortOptionWire { @@ -993,29 +1009,49 @@ fn normalize_model(wire: GrokModelWire) -> Result>(), + options + .default_effort + .filter(|effort| *effort != GrokCatalogReasoningEffort::Unknown), + ) + }, + ); + let reasoning_efforts = if feature_reasoning_efforts.is_empty() { + top_level_reasoning_efforts + } else { + feature_reasoning_efforts + }; + let default_reasoning_effort = feature_default_reasoning_effort + .or_else(|| { + wire.reasoning_effort + .filter(|effort| *effort != GrokCatalogReasoningEffort::Unknown) }) - .collect::>(); - let default_reasoning_effort = wire - .reasoning_effort - .filter(|effort| *effort != GrokCatalogReasoningEffort::Unknown) - .or(menu_default) + .or(top_level_menu_default) + .or_else(|| reasoning_efforts.first().copied()); + let default_reasoning_effort = default_reasoning_effort + .filter(|default| reasoning_efforts.iter().any(|effort| effort == default)) .or_else(|| reasoning_efforts.first().copied()); let reasoning_effort = if reasoning_efforts.is_empty() { - GrokCatalogCapabilityEvidence::from_wire(wire.supports_reasoning_effort) + GrokCatalogCapabilityEvidence::from_wire( + wire.supports_reasoning_effort.or(feature_reasoning), + ) } else { GrokCatalogCapabilityEvidence::DeclaredNative }; @@ -1044,6 +1080,25 @@ fn normalize_model(wire: GrokModelWire) -> Result, + menu_default: &mut Option, +) -> Vec { + values + .into_iter() + .filter_map(|option| { + let (effort, is_default) = option.into_parts(); + if effort == GrokCatalogReasoningEffort::Unknown { + return None; + } + if is_default && menu_default.is_none() { + *menu_default = Some(effort); + } + Some(effort) + }) + .collect() +} + fn responses_evidence( supported_in_api: Option, api_backend: Option, diff --git a/backend/crates/providers/xai/src/transport/request.rs b/backend/crates/providers/xai/src/transport/request.rs index f966575cc..6f82ff4a4 100644 --- a/backend/crates/providers/xai/src/transport/request.rs +++ b/backend/crates/providers/xai/src/transport/request.rs @@ -193,9 +193,10 @@ impl GrokResponsesRequest { if should_consume_terminal_compaction_trigger { consume_terminal_compaction_trigger(&mut body)?; } - // `provider_options` 是已废弃的网关私有 envelope,不是 xAI 上游协议字段。 + // 这些字段属于 Codex/OpenAI 侧请求控制,不是 xAI 上游协议字段。 // OpenAI 透明路径会保留未知字段;这里只在 xAI adapter 内做最小剥离。 body.remove("provider_options"); + body.remove("service_tier"); let session_seed = explicit_session_seed(request, &body); let enable_cache_route = allow_cache_route && session_seed.is_some(); let identity = resolve_session_identity( diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index 521bc0ee3..7dd8d4a4a 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -72,6 +72,27 @@ const CATALOG_WITHOUT_FEATURE_METADATA: &[u8] = br#"{ "supportsReasoningEffort": true }] }"#; +const CATALOG_WITH_FEATURE_REASONING_OPTIONS: &[u8] = br#"{ + "object": "list", + "data": [{ + "id": "grok-4.5", + "model": "grok-4.5", + "contextWindow": 500000, + "maxCompletionTokens": 131072, + "apiBackend": "responses", + "supportedInApi": true, + "reasoningEfforts": ["high", {"value": "medium", "default": true}, "low"], + "features": { + "reasoning": true, + "reasoningEffortOptions": { + "supportedEfforts": ["low", "medium", "high", "xhigh"], + "defaultEffort": "high" + } + }, + "supportsReasoningEffort": true, + "streamToolCalls": true + }] +}"#; const SUCCESS_SSE: &[u8] = concat!( "event: response.created\n", "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_xai\",\"model\":\"grok-4.5\"}}\n\n", @@ -381,6 +402,19 @@ impl GrokModelCatalogTransport for CatalogWithoutFeatureMetadataTransport { } } +struct CatalogWithFeatureReasoningOptionsTransport; + +impl GrokModelCatalogTransport for CatalogWithFeatureReasoningOptionsTransport { + fn execute(&self, _: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_> { + Box::pin(async { + Ok(GrokModelCatalogTransportResponse::new( + CATALOG_WITH_FEATURE_REASONING_OPTIONS, + None, + )) + }) + } +} + struct UnavailableCatalogTransport; impl GrokModelCatalogTransport for UnavailableCatalogTransport { @@ -547,6 +581,13 @@ fn operation_with_reasoning_effort(effort: &str) -> Operation { Operation::Generate(GenerateRequest::from_protocol_payload(payload)) } +fn object(value: serde_json::Value) -> Map { + let serde_json::Value::Object(object) = value else { + panic!("request body must be an object"); + }; + object +} + fn operation_with_invalid_tools() -> Operation { let payload = ProtocolPayload::json_object( "openai", @@ -708,6 +749,35 @@ async fn request_observation_preserves_raw_xai_reasoning_effort() { ); } +#[tokio::test] +async fn request_observation_preserves_codex_semantics_for_xai() { + let provider = provider(StubSelector::success(), StubInferenceTransport::success()).await; + let payload = ProtocolPayload::json_object( + "openai", + object(json!({ + "model": "client-model", + "reasoning": {"effort": "xhigh"}, + "input": [ + {"type": "message", "role": "user", "content": "history"}, + {"type": "compaction_trigger"} + ] + })), + ) + .expect("OpenAI payload") + .with_context(Map::from_iter([( + "turn_metadata".to_owned(), + json!("{\"request_kind\":\"compaction\",\"subagent_kind\":\"compact\"}"), + )])); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload)); + + let observation = provider.request_observation(&operation); + + assert_eq!(observation.reasoning_effort.as_deref(), Some("xhigh")); + assert_eq!(observation.request_kind.as_deref(), Some("compaction")); + assert_eq!(observation.subagent_kind.as_deref(), Some("compact")); + assert!(observation.compact); +} + fn operation_with_state(body: serde_json::Value, state: ProviderSessionState) -> Operation { let serde_json::Value::Object(body) = body else { panic!("request body must be an object"); @@ -1924,11 +1994,37 @@ async fn provider_publishes_default_codex_profile_when_catalog_is_unavailable() .presentation() .expect("fallback Grok model presentation"); assert_eq!(presentation.display_name(), Some("Grok 4.5")); + assert_eq!(presentation.default_reasoning_effort(), None); + assert!(presentation.supported_reasoning_efforts().is_empty()); assert_eq!(presentation.context_window_tokens(), Some(500_000)); assert!(presentation.agent_tools()); assert!(presentation.parallel_tool_calls()); } +#[tokio::test] +async fn provider_publishes_reasoning_efforts_from_feature_options() { + let provider = provider_with_catalog_transport( + StubSelector::success(), + StubInferenceTransport::success(), + StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable), + Arc::new(CatalogWithFeatureReasoningOptionsTransport), + ) + .await; + let capabilities = provider + .query_model_capabilities() + .await + .expect("capabilities"); + let presentation = capabilities[0] + .presentation() + .expect("Grok model presentation"); + + assert_eq!(presentation.default_reasoning_effort(), Some("high")); + assert_eq!( + presentation.supported_reasoning_efforts(), + ["low", "medium", "high", "xhigh"] + ); +} + #[tokio::test] async fn missing_catalog_tool_metadata_keeps_build_tools_routable() { let provider = provider_with_catalog_transport( diff --git a/backend/crates/providers/xai/tests/transport/catalog.rs b/backend/crates/providers/xai/tests/transport/catalog.rs index 3491ffacb..e80d07c17 100644 --- a/backend/crates/providers/xai/tests/transport/catalog.rs +++ b/backend/crates/providers/xai/tests/transport/catalog.rs @@ -376,6 +376,35 @@ fn reasoning_effort_menu_should_be_capability_evidence_without_legacy_flag() { ); } +#[test] +fn reasoning_effort_options_should_read_official_features_shape() { + let snapshot = parse_grok_model_catalog( + br#"{"object":"list","data":[{"id":"grok-4.5","reasoningEfforts":["high"],"features":{"reasoning":true,"reasoningEffortOptions":{"supportedEfforts":["low","medium","high","xhigh"],"defaultEffort":"high"}}}]}"#, + None, + ) + .expect("official feature reasoning options should parse"); + let capabilities = snapshot.models()[0].capabilities(); + + assert_eq!( + ( + capabilities.reasoning_effort(), + capabilities + .reasoning_efforts() + .iter() + .map(|effort| effort.as_str()) + .collect::>(), + capabilities + .default_reasoning_effort() + .map(|effort| effort.as_str()), + ), + ( + GrokCatalogCapabilityEvidence::DeclaredNative, + vec!["low", "medium", "high", "xhigh"], + Some("high"), + ) + ); +} + #[test] fn unknown_reasoning_effort_values_should_not_fail_the_snapshot() { let snapshot = parse_grok_model_catalog( diff --git a/backend/crates/providers/xai/tests/transport/request.rs b/backend/crates/providers/xai/tests/transport/request.rs index 868079a35..1ad82edfa 100644 --- a/backend/crates/providers/xai/tests/transport/request.rs +++ b/backend/crates/providers/xai/tests/transport/request.rs @@ -92,6 +92,21 @@ fn encoder_should_strip_openai_only_client_metadata_before_grok_build() { ); } +#[test] +fn encoder_should_strip_openai_service_tier_before_grok_build() { + let request = raw_request(json!({ + "model": "client-model", + "input": "hello", + "service_tier": "priority" + })); + + let encoded = GrokResponsesRequest::encode(&request, "grok-routed", &client_key()) + .expect("sanitized request"); + let body = Value::Object(encoded.body().clone()); + + assert_eq!(body.pointer("/service_tier"), None); +} + #[test] fn account_identity_should_be_removed_without_touching_prompt_content() { let request = raw_request(json!({