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!({