fix(openai): preserve upstream model context window limits (#68)

Preserve upstream default and maximum context windows independently,
including unknown values, and keep xAI limits provider-owned. Add catalog
and API regression coverage and document the client contract.

Co-authored-by: zhangwentian <2han9wen71an@gmail.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
2han9wen71an
2026-09-12 07:00:11 +08:00
committed by GitHub
co-authored by Codex
parent 5205c07d60
commit eb3baef3aa
8 changed files with 141 additions and 29 deletions
@@ -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,
@@ -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<ModelPresentation>,
}
impl ModelsExecution {
pub(super) fn new() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
profiles: false,
presentation: None,
})
}
fn with_profiles() -> Arc<Self> {
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<Self> {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
profiles: true,
presentation: Some(presentation),
})
}
fn with_cli_min() -> Arc<Self> {
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<PublicModelProfile> {
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())
@@ -139,6 +139,7 @@ pub struct ModelPresentation {
default_reasoning_effort: Option<String>,
supported_reasoning_efforts: Vec<String>,
context_window_tokens: Option<u64>,
max_context_window_tokens: Option<u64>,
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<u64>,
) -> 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<u64> {
self.max_context_window_tokens
}
#[must_use]
pub const fn image_input(&self) -> bool {
self.image_input
@@ -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(
@@ -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());
@@ -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,
@@ -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());
}
+5
View File
@@ -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