diff --git a/backend/crates/gateway-api/src/openai/models.rs b/backend/crates/gateway-api/src/openai/models.rs index 448881405..28a79e599 100644 --- a/backend/crates/gateway-api/src/openai/models.rs +++ b/backend/crates/gateway-api/src/openai/models.rs @@ -124,6 +124,7 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value { .iter() .any(|effort| effort != "none"); let context_window = presentation.context_window_tokens(); + let max_context_window = presentation.max_context_window_tokens(); let input_modalities = if presentation.image_input() { vec!["text", "image"] } else { @@ -173,7 +174,7 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value { "supports_parallel_tool_calls": presentation.parallel_tool_calls(), "supports_image_detail_original": presentation.image_detail_original(), "context_window": context_window, - "max_context_window": context_window, + "max_context_window": max_context_window, "effective_context_window_percent": 95, "experimental_supported_tools": [], "input_modalities": input_modalities, diff --git a/backend/crates/gateway-api/tests/openai/models.rs b/backend/crates/gateway-api/tests/openai/models.rs index 1960bc4b2..153b764f2 100644 --- a/backend/crates/gateway-api/tests/openai/models.rs +++ b/backend/crates/gateway-api/tests/openai/models.rs @@ -19,28 +19,55 @@ use super::{api_router, authenticated_client, authenticated_client_with_min_vers pub(super) struct ModelsExecution { client: AuthenticatedClient, - profiles: bool, + presentation: Option, } impl ModelsExecution { pub(super) fn new() -> Arc { Arc::new(Self { client: authenticated_client("sk_models_test"), - profiles: false, + presentation: None, }) } fn with_profiles() -> Arc { + Self::with_presentation( + ModelPresentation::new( + Some("Grok 4.5".to_owned()), + Some("xAI Grok 4.5 frontier model.".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_max_context_window_tokens(Some(900_000)) + .with_image_input(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"), + ]), + ) + } + + fn with_presentation(presentation: ModelPresentation) -> Arc { Arc::new(Self { client: authenticated_client("sk_models_test"), - profiles: true, + presentation: Some(presentation), }) } fn with_cli_min() -> Arc { Arc::new(Self { client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")), - profiles: false, + presentation: None, }) } @@ -51,7 +78,7 @@ impl ModelsExecution { Some("26.825.51511"), None, ), - profiles: false, + presentation: None, }) } } @@ -76,32 +103,12 @@ impl ExecutionService for ModelsExecution { } fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec { - if !self.profiles { + let Some(presentation) = &self.presentation else { return Vec::new(); - } + }; vec![PublicModelProfile::new( PublicModelId::new("grok-4.5").expect("model"), - ModelPresentation::new( - Some("Grok 4.5".to_owned()), - Some("xAI Grok 4.5 frontier model.".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) - .with_service_tiers(vec![ - ModelServiceTier::new( - "priority", - "Fast", - "Route the request through Codex fast mode.", - ) - .with_speed_tier("fast"), - ]), + presentation.clone(), )] } @@ -289,6 +296,7 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() { assert_eq!(model["slug"], "grok-4.5"); assert_eq!(model["default_reasoning_level"], "medium"); assert_eq!(model["context_window"], 500_000); + assert_eq!(model["max_context_window"], 900_000); assert_eq!(model["apply_patch_tool_type"], "freeform"); assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"])); assert_eq!( @@ -322,6 +330,34 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() { ); } +#[tokio::test] +async fn models_should_preserve_unknown_context_windows() { + for (context_window, max_context_window) in + [(None, None), (Some(272_000), None), (None, Some(872_000))] + { + let presentation = ModelPresentation::default() + .with_context_window_tokens(context_window) + .with_max_context_window_tokens(max_context_window); + let response = api_router(ModelsExecution::with_presentation(presentation)) + .await + .oneshot(authorized_request("/v1/models?client_version=0.145.0")) + .await + .expect("list Codex models response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read Codex models body"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON"); + let model = &value["models"][0]; + + assert_eq!(model["context_window"], serde_json::json!(context_window)); + assert_eq!( + model["max_context_window"], + serde_json::json!(max_context_window) + ); + } +} + #[tokio::test] async fn models_should_keep_the_codex_contract_when_profiles_are_empty() { let response = api_router(ModelsExecution::new()) diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index a55c341d1..ed57e0534 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -139,6 +139,7 @@ pub struct ModelPresentation { default_reasoning_effort: Option, supported_reasoning_efforts: Vec, context_window_tokens: Option, + max_context_window_tokens: Option, image_input: bool, agent_tools: bool, parallel_tool_calls: bool, @@ -227,6 +228,16 @@ impl ModelPresentation { self } + /// 目录声明的可覆盖上限;客户端本地 context window 覆盖会被它钳制。 + #[must_use] + pub const fn with_max_context_window_tokens( + mut self, + max_context_window_tokens: Option, + ) -> Self { + self.max_context_window_tokens = max_context_window_tokens; + self + } + #[must_use] pub const fn with_image_input(mut self, image_input: bool) -> Self { self.image_input = image_input; @@ -295,6 +306,11 @@ impl ModelPresentation { self.context_window_tokens } + #[must_use] + pub const fn max_context_window_tokens(&self) -> Option { + self.max_context_window_tokens + } + #[must_use] pub const fn image_input(&self) -> bool { self.image_input diff --git a/backend/crates/providers/openai/src/provider/observation.rs b/backend/crates/providers/openai/src/provider/observation.rs index 1eff055f7..8f821107b 100644 --- a/backend/crates/providers/openai/src/provider/observation.rs +++ b/backend/crates/providers/openai/src/provider/observation.rs @@ -562,6 +562,12 @@ pub(super) fn codex_model_presentation(model: &CodexCatalogModel) -> ModelPresen .context_window_tokens() .map(std::num::NonZeroU64::get), ) + .with_max_context_window_tokens( + model + .limits() + .max_context_window_tokens() + .map(std::num::NonZeroU64::get), + ) .with_image_input(capabilities.image_input() == CodexCatalogCapabilityEvidence::DeclaredNative) // Codex Responses 工具协议随 API 支持一并可用;只有明确不支持才关闭。 .with_agent_tools( diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 783c72d8c..83fc0965f 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -6249,6 +6249,7 @@ async fn provider_compiles_catalog_presentation_for_codex_models() { assert_eq!(presentation.supported_reasoning_efforts(), ["low", "high"]); assert_eq!(presentation.default_reasoning_effort(), Some("low")); assert_eq!(presentation.context_window_tokens(), Some(272_000)); + assert_eq!(presentation.max_context_window_tokens(), Some(272_000)); assert!(presentation.image_input()); assert!(presentation.agent_tools()); assert!(presentation.parallel_tool_calls()); @@ -6258,6 +6259,48 @@ async fn provider_compiles_catalog_presentation_for_codex_models() { assert!(!presentation.hidden()); } +#[tokio::test] +async fn provider_preserves_independent_catalog_context_windows() { + for (context_window, max_context_window) in [ + (Some(272_000), Some(872_000)), + (Some(272_000), None), + (None, Some(872_000)), + (None, None), + ] { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_context_windows").await; + let server = MockServer::start().await; + let mut model = json!({"slug": "gpt-5.6-terra", "display_name": "GPT-5.6-Terra"}); + if let Some(context_window) = context_window { + model["context_window"] = json!(context_window); + } + if let Some(max_context_window) = max_context_window { + model["max_context_window"] = json!(max_context_window); + } + Mock::given(method("GET")) + .and(path("/codex/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": [model]}))) + .expect(1) + .mount(&server) + .await; + let provider = provider_with_base_url(&store, server.uri()); + + let capabilities = provider + .query_model_capabilities() + .await + .expect("catalog model capabilities"); + let presentation = capabilities[0].presentation().expect("model presentation"); + + assert_eq!( + ( + presentation.context_window_tokens(), + presentation.max_context_window_tokens(), + ), + (context_window, max_context_window), + ); + } +} + #[tokio::test] async fn provider_routes_catalog_model_when_supported_in_api_is_false() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/xai/src/provider/mod.rs b/backend/crates/providers/xai/src/provider/mod.rs index feff8fbf1..241fd1511 100644 --- a/backend/crates/providers/xai/src/provider/mod.rs +++ b/backend/crates/providers/xai/src/provider/mod.rs @@ -563,6 +563,7 @@ fn default_grok_model_presentation() -> ModelPresentation { Some("xAI Grok 4.5 frontier model with reasoning and vision.".to_owned()), ) .with_context_window_tokens(Some(500_000)) + .with_max_context_window_tokens(Some(500_000)) .with_image_input(true) .with_agent_tools(true, true) } @@ -621,6 +622,8 @@ fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation { ) .with_reasoning(default_reasoning, reasoning_efforts) .with_context_window_tokens(context_window_tokens) + // Grok 目录只声明一个窗口,继续将它作为客户端可覆盖上限。 + .with_max_context_window_tokens(context_window_tokens) .with_image_input(known_grok_4_5) .with_agent_tools( tool_evidence != GrokCatalogCapabilityEvidence::DeclaredUnsupported, diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index 87d340dfa..1fbcb2e63 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -3183,6 +3183,7 @@ async fn provider_compiles_realtime_catalog_capabilities() { ["low", "medium", "high", "xhigh"] ); assert_eq!(presentation.context_window_tokens(), Some(1_000_000)); + assert_eq!(presentation.max_context_window_tokens(), Some(1_000_000)); assert!(presentation.agent_tools()); } @@ -3209,6 +3210,7 @@ async fn provider_publishes_default_codex_profile_when_catalog_is_unavailable() assert_eq!(presentation.default_reasoning_effort(), None); assert!(presentation.supported_reasoning_efforts().is_empty()); assert_eq!(presentation.context_window_tokens(), Some(500_000)); + assert_eq!(presentation.max_context_window_tokens(), Some(500_000)); assert!(presentation.agent_tools()); assert!(presentation.parallel_tool_calls()); } diff --git a/docs/api.md b/docs/api.md index 980426ab5..4f4e247b0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -155,6 +155,11 @@ Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行 `GET /v1/models` 默认返回 OpenAI 兼容列表 `{"object": "list", "data": [...]}`;请求携带非空 `client_version` query 参数(Codex 客户端)时改为返回 Codex 专用目录合同 `{"models": [...]}`。 +Codex 专用目录中的 `context_window` 与 `max_context_window` 分别表示默认上下文窗口和客户端本地 +覆盖的上限。OpenAI Provider 分别传递上游目录中的对应字段,缺失时保留 `null`;网关不通过部署配置 +覆盖这些值。Codex 客户端配置 `model_context_window` 后,按该值与非空 `max_context_window` 的较小值 +使用窗口;上限为空时保留客户端本地值。xAI 目录只声明一个窗口,其 Provider 继续以该值作为客户端覆盖上限。 + OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段和字段顺序保持不变(受控模型 映射除外),HTTP SSE 与 WebSocket 的上游业务事件字节原样转发,response ID 按 opaque 值处理而不 假设 UUID 或固定长度;OpenAI 上游错误 envelope 和允许下发的 opaque header 值也不由 canonical