mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user