fix(openai): preserve native Codex model catalogs

Forward complete upstream model objects and client versions without rebuilding metadata. Keep account-scoped catalogs in a bounded, coalesced cache with explicit invalidation and preserve model aliases without mixing fields.

Cover service tiers, native metadata, account isolation, cache behavior, and failure handling with regression tests; document the catalog contract.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-13 02:02:17 +08:00
co-authored by Codex
parent b98c2f01a5
commit b6e17db0c6
21 changed files with 1207 additions and 68 deletions
@@ -6,7 +6,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use gateway_core::routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -14,7 +14,7 @@ use crate::ApiState;
use super::{
auth::{authenticate_client, client_access_error_response},
error::model_not_found_response,
error::{model_not_found_response, openai_error_response},
};
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
@@ -37,19 +37,53 @@ pub(crate) async fn models(
Err(error) => return client_access_error_response(error),
};
if query
if let Some(version) = query
.client_version
.as_deref()
.is_some_and(|version| !version.trim().is_empty())
.filter(|version| !version.trim().is_empty())
{
let models = service
.public_model_profiles(&client)
if version.len() > 64
|| !version
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".-+".contains(&byte))
{
return openai_error_response(
StatusCode::BAD_REQUEST,
"Invalid client_version",
"invalid_request_error",
"invalid_client_version",
)
.into_response();
}
let catalog = match service.client_model_catalog(&client, version).await {
Ok(catalog) => catalog,
Err(_) => return catalog_unavailable_response(),
};
let models = catalog
.iter()
.enumerate()
.map(|(index, profile)| codex_model_json(profile, index))
.collect::<Vec<_>>();
.map(|(index, entry)| match entry {
PublicModelDescriptor::Adapted(profile) => Ok(codex_model_json(profile, index)),
PublicModelDescriptor::Native { model, payload } => {
if payload.protocol() != "codex" {
return Err(());
}
let mut value: Value =
serde_json::from_slice(payload.body()).map_err(|_| ())?;
let object = value.as_object_mut().ok_or(())?;
// 别名只改变请求标识,能力、提示词、顺序等仍由上游目录决定。
object.insert("slug".to_owned(), Value::String(model.as_str().to_owned()));
Ok(value)
}
})
.collect::<Result<Vec<_>, ()>>();
let Ok(models) = models else {
return catalog_unavailable_response();
};
return (
StatusCode::OK,
// 目录经过账号选择/别名/多 Provider 聚合,不能借用上游 ETag 表示改写后的正文。
[("cache-control", "private, no-store")],
Json(json!({
"models": models,
})),
@@ -72,6 +106,16 @@ pub(crate) async fn models(
.into_response()
}
fn catalog_unavailable_response() -> Response {
openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Model catalog is temporarily unavailable",
"server_error",
"model_catalog_unavailable",
)
.into_response()
}
/// `GET /v1/models/{model_id}`。
pub(crate) async fn model_detail(
State(state): State<ApiState>,
@@ -10,7 +10,7 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use gateway_core::routing::{ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId};
use uuid::Uuid;
use super::auth::ClientApiKeyAuthError;
@@ -52,11 +52,14 @@ impl OpenAiService {
.collect()
}
pub(crate) fn public_model_profiles(
pub(crate) async fn client_model_catalog(
&self,
client: &AuthenticatedClient,
) -> Vec<PublicModelProfile> {
self.execution.public_model_profiles(client)
client_version: &str,
) -> Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable> {
self.execution
.client_model_catalog(client, "codex", client_version)
.await
}
pub(crate) fn contains_public_model(
+144 -12
View File
@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use axum::{
body::{Body, to_bytes},
@@ -11,7 +11,8 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::{
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
ModelPresentation, ModelServiceTier, ProviderCatalogUnavailable, PublicModelDescriptor,
PublicModelId, PublicModelProfile,
};
use tower::ServiceExt;
@@ -20,6 +21,8 @@ use super::{api_router, authenticated_client, authenticated_client_with_min_vers
pub(super) struct ModelsExecution {
client: AuthenticatedClient,
presentation: Option<ModelPresentation>,
native: Option<Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>>,
requested_versions: Mutex<Vec<String>>,
}
impl ModelsExecution {
@@ -27,6 +30,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
@@ -61,6 +66,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
presentation: Some(presentation),
native: None,
requested_versions: Mutex::default(),
})
}
@@ -68,6 +75,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
@@ -79,11 +88,48 @@ impl ModelsExecution {
None,
),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
fn profiles(&self) -> Vec<PublicModelProfile> {
self.presentation
.as_ref()
.map(|presentation| {
PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
presentation.clone(),
)
})
.into_iter()
.collect()
}
}
impl ExecutionService for ModelsExecution {
fn client_model_catalog<'a>(
&'a self,
_client: &'a AuthenticatedClient,
protocol: &'a str,
version: &'a str,
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
Box::pin(async move {
assert_eq!(protocol, "codex");
self.requested_versions
.lock()
.expect("versions")
.push(version.to_owned());
self.native.clone().unwrap_or_else(|| {
Ok(self
.profiles()
.into_iter()
.map(PublicModelDescriptor::Adapted)
.collect())
})
})
}
fn authenticate(
&self,
plaintext: &str,
@@ -102,16 +148,6 @@ impl ExecutionService for ModelsExecution {
.collect()
}
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
let Some(presentation) = &self.presentation else {
return Vec::new();
};
vec![PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
presentation.clone(),
)]
}
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
matches!(model.as_str(), "model-a" | "model-b")
}
@@ -145,6 +181,102 @@ fn authorized_request(path: &str) -> Request<Body> {
.expect("build models request")
}
#[tokio::test]
async fn native_catalog_preserves_complete_objects_and_only_rewrites_alias_slug() {
let original = serde_json::json!({
"slug": "gpt-native", "display_name": "Official name", "priority": 47,
"base_instructions": "Official instructions\nDo not replace.",
"model_messages": {"token_budget": "upstream template", "future_section": [null, {"a": true}]},
"service_tiers": [{"id": "priority", "name": "Fast", "description": "Official speed tier"}],
"tool_mode": "code_mode_only", "use_responses_lite": true,
"future_field": {"nested": [1, null, "value"]}, "explicit_null": null
});
let entry = |model: &str| PublicModelDescriptor::Native {
model: PublicModelId::new(model).expect("model"),
payload: gateway_core::operation::RawJsonPayload::new(
"codex",
serde_json::to_vec(&original).expect("JSON").into(),
)
.expect("payload"),
};
let execution = Arc::new(ModelsExecution {
client: authenticated_client("sk_models_test"),
presentation: None,
native: Some(Ok(vec![entry("gpt-native"), entry("my-alias")])),
requested_versions: Mutex::default(),
});
let response = api_router(execution.clone())
.await
.oneshot(authorized_request(
"/v1/models?client_version=0.154.0-alpha.3",
))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["cache-control"], "private, no-store");
assert!(response.headers().get("etag").is_none());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(value["models"][0], original);
let mut alias = original;
alias["slug"] = serde_json::json!("my-alias");
assert_eq!(value["models"][1], alias);
assert_eq!(
*execution.requested_versions.lock().expect("versions"),
["0.154.0-alpha.3"]
);
}
#[tokio::test]
async fn native_catalog_failure_does_not_publish_a_synthetic_or_empty_success() {
let execution = Arc::new(ModelsExecution {
client: authenticated_client("sk_models_test"),
presentation: None,
native: Some(Err(ProviderCatalogUnavailable)),
requested_versions: Mutex::default(),
});
let response = api_router(execution)
.await
.oneshot(authorized_request("/v1/models?client_version=0.154.0"))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(value["error"]["code"], "model_catalog_unavailable");
assert!(value.get("models").is_none());
}
#[tokio::test]
async fn catalog_rejects_unbounded_or_control_character_versions_before_fetching() {
for version in [
"x".repeat(65),
"0.154.0%0A".to_owned(),
"0.154.0%20".to_owned(),
] {
let execution = ModelsExecution::new();
let response = api_router(execution.clone())
.await
.oneshot(authorized_request(&format!(
"/v1/models?client_version={version}"
)))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(
execution
.requested_versions
.lock()
.expect("versions")
.is_empty()
);
}
}
#[tokio::test]
async fn models_should_encode_the_service_visible_catalog() {
let response = api_router(ModelsExecution::new())
@@ -37,7 +37,8 @@ use crate::lifecycle::CancellationToken;
use crate::operation::{Operation, ProviderSessionState};
use crate::policy::{ClientApiKeyId, ClientPolicy};
use crate::routing::{
PublicModelId, PublicModelProfile, RoutingContext, RuntimeSnapshot, UpstreamModelId,
ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId, RoutingContext,
RuntimeSnapshot, UpstreamModelId,
};
use crate::runtime::RuntimeSnapshotHandle;
@@ -187,8 +188,13 @@ pub trait ExecutionService: Send + Sync {
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError>;
fn public_models(&self, client: &AuthenticatedClient) -> Vec<PublicModelId>;
fn public_model_profiles(&self, _client: &AuthenticatedClient) -> Vec<PublicModelProfile> {
Vec::new()
fn client_model_catalog<'a>(
&'a self,
_client: &'a AuthenticatedClient,
_protocol: &'a str,
_client_version: &'a str,
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
Box::pin(async { Err(ProviderCatalogUnavailable) })
}
fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool;
fn start(
@@ -920,10 +926,77 @@ impl ExecutionService for DefaultExecutionService {
.public_models_for_scope(client.policy.account_scope())
}
fn public_model_profiles(&self, client: &AuthenticatedClient) -> Vec<PublicModelProfile> {
client
.snapshot
.public_model_profiles_for_scope(client.policy.account_scope())
fn client_model_catalog<'a>(
&'a self,
client: &'a AuthenticatedClient,
protocol: &'a str,
client_version: &'a str,
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
Box::pin(async move {
let scope = client.policy.account_scope();
let mut result = Vec::new();
let mut seen = BTreeSet::new();
for kind in scope.provider_kinds() {
let provider = self.providers.get(kind).ok_or(ProviderCatalogUnavailable)?;
let Some(models) = provider
.query_client_model_catalog(scope, protocol, client_version)
.await?
else {
for profile in client.snapshot.public_model_profiles_for_provider(kind) {
if seen.insert(profile.model().clone()) {
result.push(PublicModelDescriptor::Adapted(profile));
}
}
continue;
};
// 保留上游顺序;映射只选择完整的目标对象,不能跨账号/模型混拼字段。
let by_id = models
.iter()
.map(|entry| (entry.model.as_str(), entry))
.collect::<std::collections::BTreeMap<_, _>>();
for entry in &models {
let target = client.snapshot.mapped_model(entry.model.as_str());
let Some(source) = by_id.get(target.as_str()) else {
continue;
};
let model = PublicModelId::new(entry.model.as_str().to_owned())
.map_err(|_| ProviderCatalogUnavailable)?;
// 原生目录可能比路由快照更新,不能向客户端公布当前已知不可路由的模型。
if !client
.snapshot
.contains_public_model_for_provider(&model, kind)
{
continue;
}
if seen.insert(model.clone()) {
result.push(PublicModelDescriptor::Native {
model,
payload: source.payload.clone(),
});
}
}
for model in client.snapshot.public_models_for_provider(kind) {
let target = client.snapshot.mapped_model(model.as_str());
if target == model.as_str() || seen.contains(&model) {
continue;
}
if !client
.snapshot
.contains_public_model_for_provider(&model, kind)
{
continue;
}
if let Some(source) = by_id.get(target.as_str()) {
seen.insert(model.clone());
result.push(PublicModelDescriptor::Native {
model,
payload: source.payload.clone(),
});
}
}
}
Ok(result)
})
}
fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool {
@@ -503,6 +503,20 @@ pub trait Provider: Send + Sync {
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError>;
/// 读取当前客户端协议的原生目录,必须限定到认证时冻结的账号范围。
/// `None` 表示不提供该协议的原生目录;读取失败不能伪装成不支持。
async fn query_client_model_catalog(
&self,
_scope: &crate::account::scope::FrozenAccountScope,
_protocol: &str,
_client_version: &str,
) -> Result<
Option<Vec<crate::routing::ProviderModelDescriptor>>,
crate::routing::ProviderCatalogUnavailable,
> {
Ok(None)
}
/// 选择一个未被排除的资源并返回 cold [`ProviderStream`]。
///
/// 返回成功、返回错误或准备 future 被取消前,均不得发送本次请求的上游握手或业务
@@ -225,10 +225,10 @@ impl fmt::Debug for ProtocolPayload {
}
}
/// 客户端协议交给 Provider 的未经重编码 JSON 正文。
/// 协议 adapter 与 Provider 之间不透明传递的 JSON 正文。
///
/// 协议 adapter 只确定路由端点;Core 保留原始字节与非 wire 上下文,
/// 不验证、解析或改写正文。
/// Core 保留字节与非 wire 上下文,不验证、解析或改写正文;
/// 请求载荷与原生模型目录均由协议 adapter 和对应 Provider 共同拥有语义。
#[derive(Clone, PartialEq, Eq)]
pub struct RawJsonPayload {
protocol: String,
@@ -237,7 +237,7 @@ pub struct RawJsonPayload {
}
impl RawJsonPayload {
/// 创建未经重编码的协议 JSON 正文。
/// 创建不透明的协议 JSON 正文。
///
/// # Errors
///
@@ -260,7 +260,7 @@ impl RawJsonPayload {
&self.protocol
}
/// 返回 adapter 收到的原始 JSON 字节。
/// 返回协议正文的 JSON 字节。
#[must_use]
pub const fn body(&self) -> &Bytes {
&self.body
@@ -1,12 +1,32 @@
//! 快照编译需要的 Provider 目录事实,不暴露执行或账号选择能力。
//! 快照编译与客户端读取需要的 Provider 目录合同,不暴露执行或账号选择能力。
use std::collections::BTreeMap;
use futures::future::BoxFuture;
use crate::identity::ProviderKind;
use crate::operation::RawJsonPayload;
use super::{ModelCapabilities, ModelPresentation, UpstreamModelId};
use super::{
ModelCapabilities, ModelPresentation, PublicModelId, PublicModelProfile, UpstreamModelId,
};
/// Provider 原生客户端目录条目;Core 只解释模型标识,不解释协议正文。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelDescriptor {
pub model: UpstreamModelId,
pub payload: RawJsonPayload,
}
/// 对外目录保留原生正文;只有没有原生目录的 Provider 才使用通用画像适配。
#[derive(Debug, Clone)]
pub enum PublicModelDescriptor {
Native {
model: PublicModelId,
payload: RawJsonPayload,
},
Adapted(PublicModelProfile),
}
/// Provider 实时目录编译后的单模型能力。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -10,7 +10,7 @@ pub use crate::account::scope::{
pub use crate::identity::ProviderKind;
pub use catalog::{
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderModelCapabilities,
ProviderModelCapabilities, ProviderModelDescriptor, PublicModelDescriptor,
};
pub use snapshot::RuntimeSnapshot;
@@ -996,6 +996,108 @@ fn probe_observation_store_failure_preserves_the_provider_error() {
struct FailingProvider;
struct NativeCatalogProvider {
fail: bool,
}
#[async_trait]
impl Provider for NativeCatalogProvider {
fn name(&self) -> &'static str {
"openai"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::default()
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(Vec::new())
}
async fn query_client_model_catalog(
&self,
scope: &FrozenAccountScope,
protocol: &str,
version: &str,
) -> Result<
Option<Vec<gateway_core::routing::ProviderModelDescriptor>>,
gateway_core::routing::ProviderCatalogUnavailable,
> {
assert!(scope.allows(&ProviderAccountId::new("acct_start").expect("account")));
assert!(!scope.allows(&ProviderAccountId::new("acct_other").expect("account")));
assert_eq!((protocol, version), ("codex", "0.154.0"));
if self.fail {
return Err(gateway_core::routing::ProviderCatalogUnavailable);
}
Ok(Some(
["gpt-new", "gpt-start"]
.into_iter()
.map(|id| gateway_core::routing::ProviderModelDescriptor {
model: UpstreamModelId::new(id).expect("id"),
payload: RawJsonPayload::new(
"codex",
serde_json::to_vec(&json!({"slug":id,"future":null}))
.expect("JSON")
.into(),
)
.expect("payload"),
})
.collect(),
))
}
async fn execute(
&self,
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
panic!("catalog must not use metered execution")
}
}
#[test]
fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() {
for fail in [false, true] {
let snapshot = start_snapshot().with_model_mappings(BTreeMap::from([
("alias".to_owned(), "gpt-start".to_owned()),
("missing-alias".to_owned(), "unavailable-model".to_owned()),
]));
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([Arc::new(NativeCatalogProvider { fail }) as Arc<dyn Provider>])
.expect("registry"),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let client = service.authenticate("sk_start_test").expect("authenticate");
let result = block_on(service.client_model_catalog(&client, "codex", "0.154.0"));
if fail {
assert!(
result.is_err(),
"native failure cannot use the global synthesized catalog"
);
continue;
}
let models = result.expect("models");
let pairs = models
.iter()
.map(|entry| {
let gateway_core::routing::PublicModelDescriptor::Native { model, payload } = entry
else {
panic!("native required")
};
(model.as_str(), payload.body())
})
.collect::<Vec<_>>();
assert_eq!(
pairs.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
["gpt-start", "alias"]
);
assert_eq!(pairs[0].1, pairs[1].1, "Core preserves alias payload bytes");
}
}
#[async_trait]
impl Provider for FailingProvider {
fn name(&self) -> &'static str {
@@ -7,25 +7,32 @@ use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use gateway_core::account::{
CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
};
use gateway_core::provider_ports::{
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCatalogScope,
};
use gateway_core::routing::{ProviderCatalogGeneration, ProviderKind, UpstreamModelId};
use gateway_core::routing::{
FrozenAccountScope, ProviderCatalogGeneration, ProviderKind, ProviderModelDescriptor,
UpstreamModelId,
};
use secrecy::ExposeSecret;
use thiserror::Error;
use tokio::sync::Notify;
use tokio::sync::{Notify, OnceCell};
use tokio::time::Instant;
use uuid::Uuid;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use crate::transport::profile::CodexWireProfileState;
use crate::transport::profile::{CodexWireProfile, CodexWireProfileState};
use crate::transport::{CodexBackendClient, CodexCatalogModel, CodexRequestContext};
const MAX_RESPONSE_ETAG_BYTES: usize = 256;
const PLAN_CATALOG_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
const MAX_CATALOG_FETCH_ATTEMPTS: usize = 3;
const MAX_PLAN_CATALOG_MODELS: usize = 2_048;
const MAX_CLIENT_CATALOGS: usize = 32;
const CLIENT_CATALOG_TIMEOUT: Duration = Duration::from_secs(15);
const CLIENT_CATALOG_ERROR_TTL: Duration = Duration::from_secs(5);
/// OpenAI 以套餐划分的模型目录作用域。
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
@@ -128,7 +135,7 @@ impl fmt::Debug for CodexCredentialCatalogSnapshot {
}
}
#[derive(Debug, Error)]
#[derive(Debug, Clone, Error)]
pub enum CodexCredentialCatalogError {
#[error("Codex model catalog has no eligible account")]
NoEligibleCredential,
@@ -158,6 +165,44 @@ struct CatalogCacheState {
snapshot: Option<CodexCredentialCatalogSnapshot>,
}
#[derive(PartialEq, Eq)]
struct ClientCatalogKey {
account_id: ProviderAccountId,
revision: CredentialRevision,
plan: Option<String>,
upstream_account_id: Option<String>,
client_version: String,
profile: CodexWireProfile,
}
type ClientCatalogResult = Result<Vec<ProviderModelDescriptor>, CodexCredentialCatalogError>;
struct CachedClientCatalog {
// 负缓存从请求结束计时,不能让一次慢失败耗尽后续请求的退避窗口。
completed_at: Instant,
result: ClientCatalogResult,
}
struct ClientCatalogEntry {
key: ClientCatalogKey,
created_at: Instant,
value: Arc<OnceCell<CachedClientCatalog>>,
}
impl ClientCatalogEntry {
fn is_fresh(&self) -> bool {
let Some(value) = self.value.get() else {
return self.created_at.elapsed() < PLAN_CATALOG_CACHE_TTL;
};
let ttl = if value.result.is_err() {
CLIENT_CATALOG_ERROR_TTL
} else {
PLAN_CATALOG_CACHE_TTL
};
value.completed_at.elapsed() < ttl
}
}
struct FetchedAccountModels {
models: Vec<CodexCatalogModel>,
etag: Option<String>,
@@ -182,6 +227,7 @@ pub struct CodexCredentialCatalogService {
base_url: String,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
cache: Arc<RwLock<CatalogCacheState>>,
client_catalogs: Arc<Mutex<Vec<ClientCatalogEntry>>>,
etags: Arc<Mutex<CatalogEtagState>>,
etag_notification: Arc<Notify>,
}
@@ -201,11 +247,102 @@ impl CodexCredentialCatalogService {
base_url,
plan_catalog_cache,
cache: Arc::new(RwLock::new(CatalogCacheState::default())),
client_catalogs: Arc::new(Mutex::new(Vec::new())),
etags: Arc::new(Mutex::new(CatalogEtagState::default())),
etag_notification: Arc::new(Notify::new()),
}
}
/// 原生客户端目录使用 Key 范围内排序稳定的首个成功账号,不复用套餐级画像。
/// 同一账号/凭据版本/客户端版本的并发读取合并;原生正文仅保留在有界进程缓存。
pub async fn client_model_catalog(
&self,
scope: &FrozenAccountScope,
client_version: &str,
) -> ClientCatalogResult {
let now = SystemTime::now();
let mut accounts = self.repository.list_for_provider().await?;
accounts
.retain(|account| scope.allows(account.id()) && eligible_catalog_account(account, now));
accounts.sort_by(|left, right| left.id().cmp(right.id()));
let mut last_error = CodexCredentialCatalogError::NoEligibleCredential;
for account in accounts.iter().take(MAX_CATALOG_FETCH_ATTEMPTS) {
let profile = self.profile.snapshot();
let key = ClientCatalogKey {
account_id: account.id().clone(),
revision: account.revision(),
plan: account.plan_type().map(str::to_owned),
upstream_account_id: account.upstream_account_id().map(str::to_owned),
client_version: client_version.to_owned(),
profile: profile.clone(),
};
let value = {
let mut cache = self
.client_catalogs
.lock()
.map_err(|_| CodexCredentialCatalogError::Cache)?;
cache.retain(ClientCatalogEntry::is_fresh);
if let Some(entry) = cache.iter().find(|entry| entry.key == key) {
Arc::clone(&entry.value)
} else {
// 不驱逐正在读取的目录,避免并发请求绕过缓存容量限制制造额外出站。
if cache.len() >= MAX_CLIENT_CATALOGS {
let Some(index) = cache.iter().position(|entry| entry.value.initialized())
else {
return Err(CodexCredentialCatalogError::Cache);
};
cache.remove(index);
}
let value = Arc::new(OnceCell::new());
cache.push(ClientCatalogEntry {
key,
created_at: Instant::now(),
value: Arc::clone(&value),
});
value
}
};
let result = value
.get_or_init(|| async {
// 冻结画像,确保实际请求与缓存身份一致;不传递客户端 Bearer 给上游。
let client = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
CodexWireProfileState::new(profile),
);
let result = tokio::time::timeout(
CLIENT_CATALOG_TIMEOUT,
self.fetch_account_models(&client, account, Some(client_version)),
)
.await
.map_err(|_| CodexCredentialCatalogError::Upstream {
detail: "client model catalog timed out".to_owned(),
})
.and_then(|result| result)
.map(|fetched| {
fetched
.models
.into_iter()
.map(|model| ProviderModelDescriptor {
model: model.request_model().clone(),
payload: model.document().clone(),
})
.collect()
});
CachedClientCatalog {
completed_at: Instant::now(),
result,
}
})
.await;
match &result.result {
Ok(models) => return Ok(models.clone()),
Err(error) => last_error = error.clone(),
}
}
Err(last_error)
}
#[must_use]
pub fn catalog_generation(&self) -> ProviderCatalogGeneration {
let cache = self
@@ -377,7 +514,7 @@ impl CodexCredentialCatalogService {
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let mut last_error = None;
for account in candidates.into_iter().take(MAX_CATALOG_FETCH_ATTEMPTS) {
match self.fetch_account_models(client, &account).await {
match self.fetch_account_models(client, &account, None).await {
Ok(fetched) => return Ok(fetched),
Err(error) => last_error = Some(error),
}
@@ -423,6 +560,7 @@ impl CodexCredentialCatalogService {
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
client_version: Option<&str>,
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let credential = self
.repository
@@ -439,12 +577,15 @@ impl CodexCredentialCatalogService {
.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
})?
.fetch_models_with_context(CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
&request_id,
None,
))
.fetch_models_with_context(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
&request_id,
None,
),
client_version,
)
.await;
let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
@@ -461,9 +602,19 @@ impl CodexCredentialCatalogService {
.write()
.map_err(|_| CodexCredentialCatalogError::Cache)?;
if cache.snapshot.take().is_some() {
cache.revision = cache.revision.saturating_add(1);
cache.generation = cache.generation.saturating_add(1);
}
cache.revision = cache.revision.saturating_add(1);
drop(cache);
self.invalidate_client_catalogs()?;
Ok(())
}
fn invalidate_client_catalogs(&self) -> Result<(), CodexCredentialCatalogError> {
self.client_catalogs
.lock()
.map_err(|_| CodexCredentialCatalogError::Cache)?
.clear();
Ok(())
}
@@ -486,6 +637,7 @@ impl CodexCredentialCatalogService {
}
};
if changed {
self.invalidate_client_catalogs()?;
self.etag_notification.notify_one();
}
Ok(changed)
@@ -613,6 +765,9 @@ impl CodexCredentialCatalogService {
cache.generation = cache.generation.saturating_add(1);
}
drop(cache);
if changed {
self.invalidate_client_catalogs()?;
}
self.record_applied_catalog_etags(fetched.etags)?;
Ok(snapshot)
}
@@ -291,6 +291,25 @@ impl Provider for CodexProvider {
.collect())
}
async fn query_client_model_catalog(
&self,
scope: &gateway_core::account::scope::FrozenAccountScope,
protocol: &str,
client_version: &str,
) -> Result<
Option<Vec<gateway_core::routing::ProviderModelDescriptor>>,
gateway_core::routing::ProviderCatalogUnavailable,
> {
if protocol != "codex" {
return Ok(None);
}
self.catalog
.client_model_catalog(scope, client_version)
.await
.map(Some)
.map_err(|_| gateway_core::routing::ProviderCatalogUnavailable)
}
async fn execute(
&self,
request: ProviderRequest,
@@ -615,6 +615,7 @@ pub(super) fn codex_model_presentation(model: &CodexCatalogModel) -> ModelPresen
capabilities.image_detail_original() == CodexCatalogCapabilityEvidence::DeclaredNative,
)
.with_verbosity(capabilities.verbosity() == CodexCatalogCapabilityEvidence::DeclaredNative)
.with_service_tiers(model.metadata().service_tiers().to_vec())
.with_hidden(hidden)
}
@@ -3,7 +3,9 @@
use std::collections::BTreeSet;
use std::num::NonZeroU64;
use gateway_core::routing::UpstreamModelId;
use bytes::Bytes;
use gateway_core::operation::RawJsonPayload;
use gateway_core::routing::{ModelServiceTier, UpstreamModelId};
use reqwest::header::{ETAG, HeaderMap};
use serde::Deserialize;
@@ -13,6 +15,7 @@ pub const MAX_CODEX_MODEL_CATALOG_BYTES: usize = 1024 * 1024;
const MAX_CATALOG_MODELS: usize = 2_048;
const MAX_DISPLAY_NAME_BYTES: usize = 256;
const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
const MAX_SERVICE_TIER_ID_BYTES: usize = 64;
const MAX_ETAG_BYTES: usize = 256;
/// 上游目录对一项能力给出的明确证据。
@@ -133,6 +136,7 @@ pub struct CodexCatalogMetadata {
description: Option<String>,
priority: Option<i32>,
visibility: Option<CodexCatalogVisibility>,
service_tiers: Vec<ModelServiceTier>,
}
impl CodexCatalogMetadata {
@@ -153,6 +157,12 @@ impl CodexCatalogMetadata {
pub const fn visibility(&self) -> Option<CodexCatalogVisibility> {
self.visibility
}
/// 返回上游明确声明的服务档位;缺失时为空,不根据模型名补齐。
#[must_use]
pub fn service_tiers(&self) -> &[ModelServiceTier] {
&self.service_tiers
}
}
/// Codex 官方 picker 可见性。
@@ -173,6 +183,7 @@ pub enum CodexCatalogVisibility {
/// 一个已完整校验的 Codex 真实模型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexCatalogModel {
document: RawJsonPayload,
request_model: UpstreamModelId,
display_name: String,
capabilities: CodexCatalogCapabilities,
@@ -181,6 +192,12 @@ pub struct CodexCatalogModel {
}
impl CodexCatalogModel {
/// 完整原生对象只用于客户端目录,不写入控制面画像或日志。
#[must_use]
pub const fn document(&self) -> &RawJsonPayload {
&self.document
}
/// 返回实际写入上游请求的模型 slug。
#[must_use]
pub const fn request_model(&self) -> &UpstreamModelId {
@@ -270,7 +287,7 @@ pub enum CodexModelCatalogError {
#[derive(Debug, Deserialize)]
struct CodexModelsWire {
models: Vec<CodexModelWire>,
models: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
@@ -289,6 +306,15 @@ struct CodexModelWire {
context_window: Option<i64>,
max_context_window: Option<i64>,
input_modalities: Option<Vec<CodexInputModalityWire>>,
#[serde(default)]
service_tiers: Vec<CodexServiceTierWire>,
}
#[derive(Debug, Deserialize)]
struct CodexServiceTierWire {
id: String,
name: String,
description: String,
}
#[derive(Debug, Deserialize)]
@@ -309,7 +335,7 @@ enum CodexInputModalityWire {
///
/// # Errors
///
/// 任一条目、重复 slug 或 ETag 不合法时整轮失败;上游新增的未知字段会忽略。
/// 任一条目、重复 slug 或 ETag 不合法时整轮失败;未知字段保留在原生正文中。
pub fn parse_codex_model_catalog(
body: &[u8],
etag: Option<&str>,
@@ -330,7 +356,16 @@ pub fn parse_codex_model_catalog(
let mut seen = BTreeSet::new();
let mut models = Vec::with_capacity(wire.models.len());
for model in wire.models {
let model = normalize_model(model)?;
let wire =
CodexModelWire::deserialize(&model).map_err(|_| CodexModelCatalogError::InvalidWire)?;
let document = RawJsonPayload::new(
"codex",
Bytes::from(
serde_json::to_vec(&model).map_err(|_| CodexModelCatalogError::InvalidWire)?,
),
)
.map_err(|_| CodexModelCatalogError::InvalidWire)?;
let model = normalize_model(wire, document)?;
if !seen.insert(model.request_model.as_str().to_owned()) {
return Err(CodexModelCatalogError::DuplicateModelSlug);
}
@@ -351,7 +386,10 @@ pub(super) fn catalog_etag(headers: &HeaderMap) -> Result<Option<String>, CodexM
.transpose()
}
fn normalize_model(wire: CodexModelWire) -> Result<CodexCatalogModel, CodexModelCatalogError> {
fn normalize_model(
wire: CodexModelWire,
document: RawJsonPayload,
) -> Result<CodexCatalogModel, CodexModelCatalogError> {
if !valid_model_slug(&wire.slug) {
return Err(CodexModelCatalogError::InvalidModelSlug);
}
@@ -361,6 +399,17 @@ fn normalize_model(wire: CodexModelWire) -> Result<CodexCatalogModel, CodexModel
if let Some(description) = wire.description.as_deref() {
validate_public_text(description, MAX_DESCRIPTION_BYTES, true)?;
}
// name 供客户端生成命令,id 才是请求选档值;不能从命令名推导或改写 id。
let service_tiers = wire
.service_tiers
.into_iter()
.map(|tier| {
validate_public_text(&tier.id, MAX_SERVICE_TIER_ID_BYTES, false)?;
validate_public_text(&tier.name, MAX_DISPLAY_NAME_BYTES, false)?;
validate_public_text(&tier.description, MAX_DESCRIPTION_BYTES, true)?;
Ok(ModelServiceTier::new(tier.id, tier.name, tier.description))
})
.collect::<Result<Vec<_>, CodexModelCatalogError>>()?;
let context_window_tokens = optional_positive(wire.context_window)?;
let max_context_window_tokens = optional_positive(wire.max_context_window)?;
@@ -382,6 +431,7 @@ fn normalize_model(wire: CodexModelWire) -> Result<CodexCatalogModel, CodexModel
}
Ok(CodexCatalogModel {
document,
request_model,
display_name: wire.display_name,
capabilities: CodexCatalogCapabilities {
@@ -405,6 +455,7 @@ fn normalize_model(wire: CodexModelWire) -> Result<CodexCatalogModel, CodexModel
description: wire.description,
priority: wire.priority,
visibility: wire.visibility,
service_tiers,
},
})
}
@@ -495,10 +495,11 @@ impl CodexBackendClient {
Some(key)
}
/// 获取后端模型目录条目。
/// 客户端目录按调用方版本协商;后台目录仍使用经过核验的服务端画像版本。
pub async fn fetch_models_with_context(
&self,
context: CodexRequestContext<'_>,
client_version: Option<&str>,
) -> CodexClientResult<CodexModelCatalogSnapshot> {
let endpoint = endpoint_url(&self.base_url, "codex/models");
let profile = self.profile.snapshot();
@@ -506,7 +507,10 @@ impl CodexBackendClient {
let response = self
.client
.get(endpoint)
.query(&[("client_version", profile.codex_version.as_str())])
.query(&[(
"client_version",
client_version.unwrap_or(profile.codex_version.as_str()),
)])
.headers(headers)
.send()
.await?;
@@ -5,12 +5,16 @@ use chrono::{TimeZone as _, Utc};
use futures::executor::block_on;
use gateway_core::account::{ProviderAccount, ProviderAccountId};
use gateway_core::provider_ports::ProviderCatalogCachePort;
use gateway_core::routing::{
ClientRoutingScope, FrozenAccountScope, ModelServiceTier, ProviderKind, RuntimeAccount,
RuntimeAccountDirectory,
};
use provider_openai::OFFICIAL_CODEX_BASE_URL;
use provider_openai::credential::{
CodexCredentialCatalogError, CodexCredentialCatalogService, ImportCodexOAuthCredential,
};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use wiremock::matchers::{method, path};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use crate::support::{MemoryAccountStore, catalog_cache, profile, secret};
@@ -113,6 +117,289 @@ async fn seed_account_with_plan(
store.account(account_id).expect("seeded account")
}
fn client_scope(accounts: &[ProviderAccount]) -> FrozenAccountScope {
FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(
accounts
.iter()
.map(|account| {
(
account.id().clone(),
RuntimeAccount::new(
ProviderKind::new("openai").expect("provider"),
Default::default(),
),
)
})
.collect(),
)),
ClientRoutingScope::all_accounts(),
)
}
#[tokio::test]
async fn client_catalog_is_account_and_version_scoped_and_keeps_original_objects() {
let store = Arc::new(MemoryAccountStore::default());
let first = seed_account(&store, "acct_client_a").await;
let second = seed_account(&store, "acct_client_b").await;
let server = MockServer::start().await;
for (account, version, text) in [
("acct_client_a", "0.154.0", "a-original"),
("acct_client_b", "0.154.0", "b-original"),
("acct_client_a", "0.155.0", "a-new-client"),
] {
Mock::given(method("GET")).and(path("/codex/models"))
.and(header("authorization", format!("Bearer access-{account}")))
.and(header("chatgpt-account-id", format!("chatgpt-{account}")))
.and(query_param("client_version", version))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"models":[{
"slug":"gpt-native", "display_name":"Native", "base_instructions":text,
"future_field":{"null":null,"list":[1,true]}, "model_messages":{"new_template":text}
}]}))).expect(1).mount(&server).await;
}
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
for (accounts, version, text) in [
(vec![second.clone(), first.clone()], "0.154.0", "a-original"),
(vec![second.clone()], "0.154.0", "b-original"),
(vec![first.clone()], "0.155.0", "a-new-client"),
(vec![first], "0.154.0", "a-original"),
] {
let models = service
.client_model_catalog(&client_scope(&accounts), version)
.await
.expect("catalog");
let document: serde_json::Value =
serde_json::from_slice(models[0].payload.body()).expect("document");
assert_eq!(
document,
serde_json::json!({
"slug":"gpt-native", "display_name":"Native", "base_instructions":text,
"future_field":{"null":null,"list":[1,true]}, "model_messages":{"new_template":text}
})
);
}
assert!(
service
.client_model_catalog(&client_scope(&[]), "0.154.0")
.await
.is_err()
);
server.verify().await;
}
#[tokio::test]
async fn client_catalog_coalesces_reads_and_invalidates_on_etag_expiry_and_explicit_reset() {
let store = Arc::new(MemoryAccountStore::default());
let account = seed_account(&store, "acct_client_cache").await;
let scope = client_scope(&[account]);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(OFFICIAL_FIXTURE, "application/json")
.set_delay(std::time::Duration::from_millis(20)),
)
.expect(4)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let (first, second) = tokio::join!(
service.client_model_catalog(&scope, "0.154.0"),
service.client_model_catalog(&scope, "0.154.0")
);
assert_eq!(first.expect("first"), second.expect("second"));
service
.invalidate()
.expect("reset without a routing snapshot");
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("after reset");
assert!(service.observe_response_etag("catalog-new").expect("ETag"));
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("after ETag");
assert!(
!service
.observe_response_etag("catalog-new")
.expect("same ETag")
);
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("still cached");
tokio::time::pause();
tokio::time::advance(std::time::Duration::from_secs(301)).await;
tokio::time::resume();
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("after TTL");
server.verify().await;
}
#[tokio::test]
async fn client_catalog_cache_does_not_survive_credential_rotation() {
let store = Arc::new(MemoryAccountStore::default());
let account = seed_account(&store, "acct_client_rotate").await;
let scope = client_scope(std::slice::from_ref(&account));
let server = MockServer::start().await;
for (token, text) in [
("access-acct_client_rotate", "old"),
("rotated-access", "new"),
] {
Mock::given(method("GET"))
.and(path("/codex/models"))
.and(header("authorization", format!("Bearer {token}")))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"models":[{
"slug":"gpt-native", "display_name":"Native", "base_instructions":text
}]})),
)
.expect(1)
.mount(&server)
.await;
}
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let first = service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("old");
store
.repository()
.rotate_refreshed_oauth_secret(&account, secret("rotated-access"), None, None)
.await
.expect("rotate");
let second = service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("new");
assert_ne!(first, second);
server.verify().await;
}
#[tokio::test]
async fn failed_client_catalog_never_falls_back_to_an_out_of_scope_account_or_plan_cache() {
let store = Arc::new(MemoryAccountStore::default());
let allowed = seed_account(&store, "acct_client_allowed").await;
seed_account(&store, "acct_client_outside").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.and(header("authorization", "Bearer access-acct_client_allowed"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let scope = client_scope(&[allowed]);
for _ in 0..2 {
assert!(
service
.client_model_catalog(&scope, "0.154.0")
.await
.is_err()
);
}
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
server.verify().await;
}
#[tokio::test]
async fn raw_instructions_only_change_advances_catalog_generation() {
let store = Arc::new(MemoryAccountStore::default());
seed_account(&store, "acct_raw_generation").await;
let server = MockServer::start().await;
Mock::given(method("GET")).and(path("/codex/models"))
.respond_with(SequencedCatalogResponder {
calls: Arc::new(AtomicUsize::new(0)),
bodies: [
br#"{"models":[{"slug":"gpt-native","display_name":"Native","model_messages":{"future":"old"}}]}"#,
br#"{"models":[{"slug":"gpt-native","display_name":"Native","model_messages":{"future":"new"}}]}"#,
],
}).expect(2).mount(&server).await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
service.synchronize().await.expect("initial");
let generation = service.catalog_generation();
service.refresh_catalogs().await.expect("refresh");
assert!(service.catalog_generation() > generation);
server.verify().await;
}
#[tokio::test]
async fn client_catalog_evicts_old_entries_after_32_distinct_versions() {
let store = Arc::new(MemoryAccountStore::default());
let account = seed_account(&store, "acct_client_bounded").await;
let scope = client_scope(&[account]);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_raw(OFFICIAL_FIXTURE, "application/json"))
.expect(34)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
for patch in 0..33 {
service
.client_model_catalog(&scope, &format!("0.154.{patch}"))
.await
.expect("catalog");
}
service
.client_model_catalog(&scope, "0.154.32")
.await
.expect("newest is cached");
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("oldest was evicted");
server.verify().await;
}
#[tokio::test]
async fn client_catalog_retries_after_short_failure_cache_expires() {
let store = Arc::new(MemoryAccountStore::default());
let account = seed_account(&store, "acct_client_retry").await;
let scope = client_scope(&[account]);
let server = MockServer::start().await;
let failure = Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount_as_scoped(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
assert!(
service
.client_model_catalog(&scope, "0.154.0")
.await
.is_err()
);
drop(failure);
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_raw(OFFICIAL_FIXTURE, "application/json"))
.expect(1)
.mount(&server)
.await;
assert!(
service
.client_model_catalog(&scope, "0.154.0")
.await
.is_err()
);
tokio::time::pause();
tokio::time::advance(std::time::Duration::from_secs(6)).await;
tokio::time::resume();
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("recovered after failure TTL");
server.verify().await;
}
#[test]
fn catalog_starts_without_a_snapshot_or_generation() {
let store = Arc::new(MemoryAccountStore::default());
@@ -192,6 +479,52 @@ async fn plan_catalog_cache_is_shared_and_manual_refresh_replaces_it() {
server.verify().await;
}
#[tokio::test]
async fn service_tier_changes_should_replace_cached_metadata_and_advance_generation() {
let store = Arc::new(MemoryAccountStore::default());
seed_account(&store, "acct_service_tiers").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(SequencedCatalogResponder {
calls: Arc::new(AtomicUsize::new(0)),
bodies: [
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4","service_tiers":[{"id":"priority","name":"Fast","description":"Priority processing."}]}]}"#,
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4","service_tiers":[]}]}"#,
],
})
.expect(2)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let initial = service.synchronize().await.expect("initial catalog");
let generation = service.catalog_generation().get();
let cached = service.synchronize().await.expect("cached catalog");
assert_eq!(cached.models(), initial.models());
assert_eq!(
cached.models()[0].metadata().service_tiers(),
[ModelServiceTier::new(
"priority",
"Fast",
"Priority processing."
)]
);
assert_eq!(service.catalog_generation().get(), generation);
let refreshed = service.refresh_catalogs().await.expect("updated catalog");
assert!(refreshed.models()[0].metadata().service_tiers().is_empty());
assert_eq!(service.catalog_generation().get(), generation + 1);
assert_eq!(
service
.cached()
.expect("cache read")
.expect("cached snapshot")
.models(),
refreshed.models()
);
}
#[tokio::test]
async fn plan_catalog_refresh_stops_after_three_failed_accounts() {
let store = Arc::new(MemoryAccountStore::default());
@@ -33,9 +33,9 @@ use gateway_core::operation::{
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
ProviderModel, PublicModelId, RoutingContext, RuntimeAccount, RuntimeAccountDirectory,
RuntimeSnapshot, UpstreamModelId,
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ModelServiceTier,
ProviderKind, ProviderModel, PublicModelId, RoutingContext, RuntimeAccount,
RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::upstream::UpstreamSendState;
use provider_openai::config::DEFAULT_STREAM_MAX_RETRIES;
@@ -6307,6 +6307,41 @@ async fn provider_compiles_catalog_presentation_for_codex_models() {
assert!(presentation.image_detail_original());
assert!(presentation.verbosity());
assert!(!presentation.hidden());
assert_eq!(
presentation.service_tiers(),
[ModelServiceTier::new(
"priority",
"Fast",
"Priority processing."
)]
);
let scope = FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
ProviderAccountId::new("acct_presentation").expect("account"),
RuntimeAccount::new(
ProviderKind::new("openai").expect("provider"),
BTreeSet::new(),
),
)]))),
ClientRoutingScope::all_accounts(),
);
let native = provider
.query_client_model_catalog(&scope, "codex", "0.154.0")
.await
.expect("native catalog")
.expect("supported");
let original: Value = serde_json::from_slice(OFFICIAL_FIXTURE).expect("fixture");
let document: Value = serde_json::from_slice(native[0].payload.body()).expect("document");
assert_eq!(document, original["models"][0]);
assert!(
server
.received_requests()
.await
.expect("requests")
.iter()
.any(|request| request.url.query() == Some("client_version=0.154.0"))
);
}
#[tokio::test]
@@ -1,15 +1,43 @@
use chrono::{TimeZone, Utc};
use gateway_core::routing::ModelServiceTier;
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use provider_openai::transport::{
CodexBackendClient, CodexCatalogCapabilityEvidence, CodexCatalogVisibility, CodexClientError,
CodexModelCatalogError, CodexRequestContext, MAX_CODEX_MODEL_CATALOG_BYTES,
build_reqwest_client, parse_codex_model_catalog,
};
use serde_json::{Value, json};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const OFFICIAL_FIXTURE: &[u8] = include_bytes!("fixtures/official_models_snapshot.json");
#[test]
fn native_document_keeps_unknown_nested_fields_nulls_and_model_instructions() {
let original = json!({
"slug": "gpt-native", "display_name": "Native",
"base_instructions": "original model instructions\nwith line breaks",
"model_messages": {"future_instructions": {"template": "do not replace"}},
"future_field": [null, {"new": true}], "explicit_null": null,
"service_tiers": [{"id":"priority", "name":"Fast", "description":"1.5x speed", "future":true}]
});
let body = serde_json::to_vec(&json!({"models":[original]})).expect("body");
let snapshot = parse_codex_model_catalog(&body, None).expect("catalog");
let model = &snapshot.models()[0];
let preserved: Value = serde_json::from_slice(model.document().body()).expect("document");
assert_eq!(preserved, original);
assert!(!format!("{model:?}").contains("original model instructions"));
assert!(!format!("{model:?}").contains("future_instructions"));
let mut changed = original;
changed["model_messages"]["future_instructions"]["template"] = json!("new content");
let changed = parse_codex_model_catalog(
&serde_json::to_vec(&json!({"models":[changed]})).expect("body"),
None,
)
.expect("catalog");
assert_ne!(snapshot.models(), changed.models());
}
#[test]
fn official_fixture_should_produce_safe_full_snapshot() {
let snapshot = parse_codex_model_catalog(OFFICIAL_FIXTURE, Some("W/\"codex-v1\""))
@@ -65,7 +93,7 @@ fn official_fixture_should_produce_safe_full_snapshot() {
}
#[test]
fn non_whitelisted_wire_fields_should_not_survive_normalization() {
fn raw_instructions_and_unknown_fields_should_not_appear_in_snapshot_debug() {
let snapshot =
parse_codex_model_catalog(OFFICIAL_FIXTURE, None).expect("official fixture should parse");
let debug = format!("{snapshot:?}");
@@ -77,6 +105,91 @@ fn non_whitelisted_wire_fields_should_not_survive_normalization() {
);
}
#[test]
fn service_tiers_should_preserve_declared_ids_names_and_descriptions() {
let body = json!({"models": [{
"slug": "gpt-future",
"display_name": "Future",
"service_tiers": [
{"id": "priority", "name": "Fast", "description": "Priority processing."},
{"id": "deferred", "name": "Economy", "description": ""}
]
}]});
let snapshot = parse_codex_model_catalog(&serde_json::to_vec(&body).expect("JSON"), None)
.expect("declared service tiers");
assert_eq!(
snapshot.models()[0].metadata().service_tiers(),
[
ModelServiceTier::new("priority", "Fast", "Priority processing."),
ModelServiceTier::new("deferred", "Economy", ""),
]
);
}
#[test]
fn missing_or_empty_service_tiers_should_not_infer_fast_from_legacy_metadata() {
for service_tiers in [None, Some(json!([]))] {
let mut model = json!({
"slug": "gpt-5.4",
"display_name": "GPT-5.4",
"additional_speed_tiers": ["fast"]
});
if let Some(service_tiers) = service_tiers {
model["service_tiers"] = service_tiers;
}
let body = json!({"models": [model]});
let snapshot = parse_codex_model_catalog(&serde_json::to_vec(&body).expect("JSON"), None)
.expect("optional service tiers");
assert!(snapshot.models()[0].metadata().service_tiers().is_empty());
}
}
#[test]
fn invalid_service_tier_metadata_should_fail_the_entire_snapshot() {
for (field, value) in [
("id", String::new()),
("id", "p".repeat(65)),
("name", " ".to_owned()),
("name", "f".repeat(257)),
("name", "Fast\u{1b}".to_owned()),
("description", "d".repeat(4097)),
("description", "unsafe\u{0}".to_owned()),
] {
let mut tier = json!({"id": "priority", "name": "Fast", "description": "Priority."});
tier[field] = Value::String(value);
let body = json!({"models": [{
"slug": "gpt-5.4", "display_name": "GPT-5.4", "service_tiers": [tier]
}]});
assert_eq!(
parse_codex_model_catalog(&serde_json::to_vec(&body).expect("JSON"), None),
Err(CodexModelCatalogError::InvalidMetadata),
"invalid {field}"
);
}
}
#[test]
fn malformed_service_tiers_should_fail_the_official_wire_contract() {
for service_tiers in [
Value::Null,
json!("fast"),
json!([{"id": "priority", "name": "Fast"}]),
json!([{"id": 1, "name": "Fast", "description": "Priority."}]),
] {
let body = json!({"models": [{
"slug": "gpt-5.4", "display_name": "GPT-5.4", "service_tiers": service_tiers
}]});
assert_eq!(
parse_codex_model_catalog(&serde_json::to_vec(&body).expect("JSON"), None),
Err(CodexModelCatalogError::InvalidWire)
);
}
}
#[test]
fn invalid_etag_should_fail_the_entire_snapshot() {
let result = parse_codex_model_catalog(OFFICIAL_FIXTURE, Some("raw-unquoted-etag"));
@@ -241,7 +354,7 @@ async fn fetch_should_send_official_catalog_headers_and_query() {
profile(),
);
let snapshot = client
.fetch_models_with_context(context())
.fetch_models_with_context(context(), None)
.await
.expect("fetch strict snapshot");
@@ -285,7 +398,7 @@ async fn fetch_should_reject_streamed_body_over_hard_limit() {
profile(),
);
let result = client.fetch_models_with_context(context()).await;
let result = client.fetch_models_with_context(context(), None).await;
assert!(matches!(
result,
@@ -13,6 +13,13 @@
"supports_image_detail_original": true,
"supports_search_tool": true,
"support_verbosity": true,
"service_tiers": [
{
"id": "priority",
"name": "Fast",
"description": "Priority processing."
}
],
"supported_reasoning_levels": [
{
"effort": "low",
@@ -32,7 +32,7 @@ async fn each_core_endpoint_uses_the_current_bundled_release() {
});
let context = request_context("audit", Some("audit-account"));
client
.fetch_models_with_context(context)
.fetch_models_with_context(context, None)
.await
.expect_err("fixture rejection");
client
+27 -1
View File
@@ -183,8 +183,34 @@ message/type/code;没有结构化错误时使用稳定安全文案,不把原
`GET /v1/models` 默认返回 OpenAI 兼容列表 `{"object": "list", "data": [...]}`;请求携带非空
`client_version` query 参数(Codex 客户端)时改为返回 Codex 专用目录合同 `{"models": [...]}`。
OpenAI Provider 按客户端传入的 `client_version` 请求上游目录,完整保留每个模型 JSON 对象,包括
`base_instructions`、`model_messages`、`service_tiers`、工具与能力字段,以及未知嵌套字段、显式 `null`
和字段缺失的区别。Core 不解释这些协议字段,API 不再根据通用模型画像重建 OpenAI 目录。
模型别名仅替换 `slug`,不替换上游展示名、提示词、能力或 `priority`;保持原生模型顺序,新增别名附在后面。
目录仍按当前路由快照的模型存在性过滤,避免公布已知无法路由的模型;新模型需待后台目录对账后进入列表。
xAI 没有 Codex 原生目录,继续使用明确的通用画像适配。
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 在其中按账号 ID 排序,使用首个成功读取的
合格账号,最多尝试三个账号;同名模型不跨账号或套餐混拼字段。因此单账号、无别名时可保持该账号的
模型对象一致,多账号/多 Provider 聚合不代表“与某个官方账号的整个目录完全一致”,也不会固定后续推理账号。
读取失败返回 `503 model_catalog_unavailable`,不以简化模板或空成功响应覆盖客户端缓存。
原生目录使用 Provider 内的有界缓存:最多 32 份,成功 TTL 为 5 分钟,失败短缓存为 5 秒,单次上游
读取超时为 15 秒。缓存按账号、凭据 revision、套餐、上游账号身份、客户端版本和请求画像隔离;并发
读取合并,新的目录 ETag、后台目录内容变化或显式失效会清理缓存。每次读取仍重新检查账号资格和 Key 范围,
不会因缓存命中跨越权限。完整对象不写 PostgreSQL 或 Redis;现有套餐 Redis cache 仍只保存模型 ID。
成功目录响应带 `Cache-Control: private, no-store`,不借用上游 ETag 标识经过选择/别名/聚合后的正文。
Codex 自己仍会写 `models_cache.json`;Responses 的 `x-models-etag` 保持原协议,这可能让客户端再次
读取目录,但通常命中上述服务端缓存,不表示每轮都重新请求上游。
`service_tiers` 的 `name` 用于生成 `/fast` 等命令,`id` 是请求使用的 `service_tier` 值,二者不能互换。
上游未声明或明确返回空数组时不补档位,也不根据模型名或旧 `additional_speed_tiers` 字段推断 Fast。
该合同对齐
[官方 Codex 0.154.0 的模型元数据](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/openai_models.rs)
与其动态服务档位命令;升级网关后,已有客户端缓存需要在重新加载模型目录后才能反映修复。
Codex 专用目录中的 `context_window` 与 `max_context_window` 分别表示默认上下文窗口和客户端本地
覆盖的上限。OpenAI Provider 分别传递上游目录中的对应字段,缺失时保留 `null`;网关不通过部署配置
覆盖的上限。OpenAI Provider 原样保留上游对应字段,缺失与 `null` 不互相转换;网关不通过部署配置
覆盖这些值。Codex 客户端配置 `model_context_window` 后,按该值与非空 `max_context_window` 的较小值
使用窗口;上限为空时保留客户端本地值。xAI 目录只声明一个窗口,其 Provider 继续以该值作为客户端覆盖上限。
+7
View File
@@ -121,6 +121,13 @@ Provider 模型能力、目录代次与 `ProviderCatalogPort` 由 `routing::cata
该只读合同,不反向依赖执行注册表;现有 `ProviderRegistry` 直接实现目录端口,仍只维护一份 Provider
注册集合。已知空目录与查询失败的未知目录保持不同语义,目录替身无需实现请求执行。
客户端原生模型目录同样由 Provider 拥有,通过 `ExecutionService` 和现有 Registry 的只读调用传递。
`routing::catalog` 的原生条目只包含模型 ID 与不透明协议正文,Core 负责冻结账号范围和整对象模型映射,
不解析上游字段;API 负责协议输出,不直接依赖具体 Provider。原生目录失败不会退回通用画像,只有不提供
该协议原生目录的 Provider(如 xAI 的 Codex 适配)才走画像转换。目录读取不创建计量请求或推理 attempt。
OpenAI 的账号/凭据 revision/客户端版本隔离、并发合并、TTL、容量与失效均归属已有 credential catalog
service;客户端原生对象只进有界进程缓存,不扩展套餐 Redis ID cache 或持久化模型字段副本。
`engine::observation` 统一维护单次响应的用量、费用、时间和响应 ID,并负责重试前清理;协调器继续
独占发送、提交、重试和终结顺序。Provider 上报费用优先于本地估算,丢弃的 attempt 不得污染最终计量。
Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请求重试而清空已产生的费用或未知计费状态。