From 223021630e659ef3e599564f03eaa9f61d303f06 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 17 Sep 2026 05:15:31 +0800 Subject: [PATCH] fix(openai): unify API key and OAuth passthrough Share the simulated client profile and existing request/response paths across authentication modes. Forward native capabilities and unknown models upstream while preserving credential isolation and routing permissions. Co-authored-by: Codex --- .../gateway-core/src/engine/provider.rs | 11 + .../gateway-core/src/routing/catalog.rs | 7 +- .../gateway-core/src/routing/snapshot.rs | 38 +-- .../gateway-core/tests/routing/snapshot.rs | 37 ++- .../openai/src/credential/catalog.rs | 13 - .../openai/src/credential/selector.rs | 62 +---- backend/crates/providers/openai/src/lib.rs | 1 - .../openai/src/provider/execution.rs | 10 +- .../providers/openai/src/provider/mod.rs | 6 +- .../openai/src/transport/client_json.rs | 9 + .../providers/openai/src/transport/headers.rs | 92 +++---- .../openai/tests/credential/catalog.rs | 16 +- .../openai/tests/credential/contract.rs | 16 +- .../openai/tests/provider/contract.rs | 255 ++++++++++++++++-- docs/api.md | 17 +- docs/architecture.md | 2 + release/notes.md | 2 +- 17 files changed, 390 insertions(+), 204 deletions(-) diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index e0352bc6c..cabefdad9 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -485,6 +485,11 @@ pub trait Provider: Send + Sync { /// 返回当前进程已经成功发布的目录代次。 fn catalog_generation(&self) -> ProviderCatalogGeneration; + /// 发现型目录不用于提前拒绝上游可能支持的新模型或别名。 + fn model_catalog_is_exhaustive(&self) -> bool { + true + } + /// 解释 Provider 差异化观测字段;不参与路由和传输。 fn request_observation( &self, @@ -652,6 +657,12 @@ impl ProviderRegistry { } impl ProviderCatalogPort for ProviderRegistry { + fn model_catalog_is_exhaustive(&self, provider: &ProviderKind) -> bool { + self.providers + .get(provider) + .is_none_or(|provider| provider.model_catalog_is_exhaustive()) + } + fn catalog_generations(&self) -> BTreeMap { self.providers .iter() diff --git a/backend/crates/gateway-core/src/routing/catalog.rs b/backend/crates/gateway-core/src/routing/catalog.rs index d419c7a6a..0d58638ab 100644 --- a/backend/crates/gateway-core/src/routing/catalog.rs +++ b/backend/crates/gateway-core/src/routing/catalog.rs @@ -113,12 +113,17 @@ pub struct ProviderCatalogUnavailable; /// 快照编译与对账使用的对象安全目录端口。 pub trait ProviderCatalogPort: Send + Sync { + /// 目录是否完整到足以根据缺项拒绝请求;发现型目录交由上游验证模型名。 + fn model_catalog_is_exhaustive(&self, _provider: &ProviderKind) -> bool { + true + } + /// 返回全部已注册 Provider 的目录代次;注册集合在初始化后保持不变。 /// /// 即使某个目录暂时不可读,也必须保留它的 Provider 与最近成功发布的代次。 fn catalog_generations(&self) -> BTreeMap; - /// 查询指定 Provider 的模型事实;成功的空列表表示已知无模型,失败表示未知。 + /// 查询模型发现事实;只有完整目录的空列表能证明无模型,失败表示未知。 fn query_model_capabilities( &self, provider: &ProviderKind, diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 38c3c843b..60875a02b 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -310,14 +310,16 @@ async fn compile_runtime_snapshot( ) -> Result { let registered_providers = provider_kinds.iter().cloned().collect::>(); - // 目录查询失败表示未知;查询成功后,即使为空,也必须与“已知缺少模型”区分。 + // 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证。 let mut provider_models = Vec::new(); - let mut known_provider_catalogs = BTreeSet::new(); + let mut exhaustive_provider_catalogs = BTreeSet::new(); for provider in &provider_kinds { let Ok(models) = catalogs.query_model_capabilities(provider).await else { continue; }; - known_provider_catalogs.insert(provider.clone()); + if catalogs.model_catalog_is_exhaustive(provider) { + exhaustive_provider_catalogs.insert(provider.clone()); + } provider_models.extend(models.into_iter().map(|model| { let compiled = ProviderModel::new( provider.clone(), @@ -493,7 +495,7 @@ async fn compile_runtime_snapshot( .with_client_queue_policy(client_queue_policy) .with_model_mappings(model_mappings) .with_account_directory(account_directory) - .with_known_provider_catalogs(known_provider_catalogs) + .with_exhaustive_provider_catalogs(exhaustive_provider_catalogs) .with_min_codex_client_versions(min_client_versions) }) } @@ -511,7 +513,7 @@ pub struct RuntimeSnapshot { Arc>>, model_mappings: Arc>, provider_catalog_generations: Arc>, - known_provider_catalogs: Arc>, + exhaustive_provider_catalogs: Arc>, account_directory: Arc, client_policies: Arc>, min_codex_client_versions: CodexClientMinVersions, @@ -556,7 +558,7 @@ impl RuntimeSnapshot { } } - let mut known_provider_catalogs = BTreeSet::new(); + let mut exhaustive_provider_catalogs = BTreeSet::new(); let mut model_map = BTreeMap::>::new(); let mut presentation_map = @@ -568,7 +570,7 @@ impl RuntimeSnapshot { capabilities, presentation, } = model; - known_provider_catalogs.insert(provider.clone()); + exhaustive_provider_catalogs.insert(provider.clone()); if !provider_set.contains(&provider) { return Err(RoutingError::NotFound { entity: "provider", @@ -615,7 +617,7 @@ impl RuntimeSnapshot { provider_model_presentations: Arc::new(presentation_map), model_mappings: Arc::new(BTreeMap::new()), provider_catalog_generations: Arc::new(BTreeMap::new()), - known_provider_catalogs: Arc::new(known_provider_catalogs), + exhaustive_provider_catalogs: Arc::new(exhaustive_provider_catalogs), account_directory: Arc::new(RuntimeAccountDirectory::default()), client_policies: Arc::new(client_policy_map), min_codex_client_versions: CodexClientMinVersions::default(), @@ -641,8 +643,8 @@ impl RuntimeSnapshot { } #[must_use] - fn with_known_provider_catalogs(mut self, providers: BTreeSet) -> Self { - self.known_provider_catalogs = Arc::new(providers); + fn with_exhaustive_provider_catalogs(mut self, providers: BTreeSet) -> Self { + self.exhaustive_provider_catalogs = Arc::new(providers); self } @@ -779,7 +781,7 @@ impl RuntimeSnapshot { .collect() } - /// 已取得目录时以映射后的上游模型为准;目录不可用时由 Provider 在发送前判定。 + /// 完整目录按映射后的模型判定;发现型或不可用目录不作为能力白名单。 #[must_use] pub fn contains_public_model_for_provider( &self, @@ -789,11 +791,13 @@ impl RuntimeSnapshot { if !self.providers.contains(provider) { return false; } - let upstream_model = self.mapped_model(public_model.as_str()); - match self.provider_models.get(provider) { - Some(models) => models.keys().any(|model| model.as_str() == upstream_model), - None => !self.known_provider_catalogs.contains(provider), + if !self.exhaustive_provider_catalogs.contains(provider) { + return true; } + let upstream_model = self.mapped_model(public_model.as_str()); + self.provider_models + .get(provider) + .is_some_and(|models| models.keys().any(|model| model.as_str() == upstream_model)) } #[must_use] @@ -883,7 +887,7 @@ impl RuntimeSnapshot { }; emulated } - None if self.known_provider_catalogs.contains(provider) => continue, + None if self.exhaustive_provider_catalogs.contains(provider) => continue, None => BTreeSet::new(), }; candidates.push(ProviderCandidate { @@ -899,7 +903,7 @@ impl RuntimeSnapshot { let mut scoped_providers = providers.intersection(&self.providers).peekable(); if scoped_providers.peek().is_some() && scoped_providers.all(|provider| { - self.known_provider_catalogs.contains(provider) + self.exhaustive_provider_catalogs.contains(provider) && !self.contains_public_model_for_provider(public_model, provider) }) { diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index 64e8a69f4..5732f2967 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -53,13 +53,14 @@ enum TestCatalog { NoProviders, Unavailable, Empty, + Discovery, } impl ProviderCatalogPort for TestCatalog { fn catalog_generations(&self) -> BTreeMap { match self { Self::NoProviders => BTreeMap::new(), - Self::Unavailable | Self::Empty => catalog_generations(0), + Self::Unavailable | Self::Empty | Self::Discovery => catalog_generations(0), } } @@ -71,9 +72,20 @@ impl ProviderCatalogPort for TestCatalog { match self { Self::NoProviders | Self::Unavailable => Err(ProviderCatalogUnavailable), Self::Empty => Ok(Vec::new()), + Self::Discovery => Ok(vec![ProviderModelCapabilities::new( + UpstreamModelId::new("listed-model").expect("model"), + ModelCapabilities::new( + std::collections::BTreeSet::from([OperationKind::Generate]), + None, + ), + )]), } }) } + + fn model_catalog_is_exhaustive(&self, _: &ProviderKind) -> bool { + !matches!(self, Self::Discovery) + } } fn catalog_generations(generation: u64) -> BTreeMap { @@ -142,6 +154,29 @@ fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() { assert_eq!(snapshot.client_policies().count(), 1); } +#[test] +fn discovery_catalog_should_not_reject_an_unlisted_model() { + let compiler = RuntimeSnapshotCompiler::new( + Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), + Arc::new(TestCatalog::Discovery), + ); + let snapshot = block_on(compiler.compile()).expect("compile discovery catalog"); + let provider = ProviderKind::new("alpha").expect("provider"); + let model = PublicModelId::new("unknown-upstream-model").expect("model"); + assert!(snapshot.contains_public_model_for_provider(&model, &provider)); + snapshot + .plan( + &model, + &super::operation(), + snapshot.all_account_scope(), + &gateway_core::routing::RoutingContext { + required_provider: Some(provider), + ..gateway_core::routing::RoutingContext::default() + }, + ) + .expect("upstream decides model availability"); +} + #[test] fn known_empty_catalog_should_report_model_not_found() { let compiler = RuntimeSnapshotCompiler::new( diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index e6e3ec819..8a8d95b2e 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -438,19 +438,6 @@ impl CodexCredentialCatalogService { .map(|models| models.map(<[String]>::to_vec)) } - pub fn observed_model_support( - &self, - account: &ProviderAccount, - model: &str, - ) -> Result, CodexCredentialCatalogError> { - let Some(snapshot) = self.cached()? else { - return Ok(None); - }; - Ok(Some(snapshot.account_models(account)?.is_some_and( - |models| models.iter().any(|candidate| candidate == model), - ))) - } - /// 优先读取套餐目录 cache;缺失时才用当前账号所属套餐的有限候选集实时填充。 pub async fn cached_or_refresh_account_catalog( &self, diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index db683bbc6..88e7907ac 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -24,7 +24,6 @@ use thiserror::Error; use url::Url; use super::affinity::{CODEX_ROOT_SESSION_TTL, CodexSessionAffinity}; -use super::catalog::CodexCredentialCatalogService; use super::cookie::CodexCookiePolicy; use super::quota::CodexCredentialQuotaService; use super::refresh::refresh_recovery_deadline; @@ -102,7 +101,6 @@ pub(crate) struct SelectCodexProviderEndpointCredential<'a> { struct CredentialSelectionInput<'a> { requires_websocket: bool, - oauth_only: bool, request_url: &'a Url, attempt: &'a AttemptContext, session_affinity_key: Option<&'a ProviderSessionAffinityKey>, @@ -122,7 +120,6 @@ pub struct CodexCredentialSelector { leases: Arc, session_affinity: Arc, session_exclusions: Arc, - catalog: Arc, quota: Arc, cookie_policy: CodexCookiePolicy, risk_recovery: Mutex>, @@ -258,12 +255,6 @@ struct AffinityTelemetry { account_switch: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ModelCatalogEligibility<'a> { - Required(&'a str), - NotApplicable, -} - impl CodexCredentialSelector { #[must_use] // 选择器显式持有各能力边界,避免把 Provider 私有服务重新包装成通用容器。 @@ -274,7 +265,6 @@ impl CodexCredentialSelector { leases: Arc, session_affinity: Arc, session_exclusions: Arc, - catalog: Arc, quota: Arc, account_feedback: Arc, cookie_policy: CodexCookiePolicy, @@ -285,7 +275,6 @@ impl CodexCredentialSelector { leases, session_affinity, session_exclusions, - catalog, quota, cookie_policy, risk_recovery: Mutex::new(HashMap::new()), @@ -300,18 +289,13 @@ impl CodexCredentialSelector { ) -> Result { let input = CredentialSelectionInput { requires_websocket: false, - oauth_only: false, request_url: request.request_url, attempt: request.attempt, session_affinity_key: request.session_affinity_key, session_affinity_observation: None, }; - self.select_inner( - &input, - None, - ModelCatalogEligibility::Required(request.upstream_model), - ) - .await + self.select_inner(&input, None, Some(request.upstream_model)) + .await } pub(crate) async fn select_with_cyber_policy( @@ -320,11 +304,9 @@ impl CodexCredentialSelector { cyber_policy_session_key: Option<&ProviderSessionAffinityKey>, session_affinity_observation: Option<&CodexSessionAffinity>, requires_websocket: bool, - oauth_only: bool, ) -> Result { let input = CredentialSelectionInput { requires_websocket, - oauth_only, request_url: request.request_url, attempt: request.attempt, session_affinity_key: request.session_affinity_key, @@ -333,7 +315,7 @@ impl CodexCredentialSelector { self.select_inner( &input, cyber_policy_session_key, - ModelCatalogEligibility::Required(request.upstream_model), + Some(request.upstream_model), ) .await } @@ -341,28 +323,26 @@ impl CodexCredentialSelector { /// 为不属于 Responses 文本模型目录的 Provider 原生端点选择账号。 /// /// 账号范围、健康度、配额、并发租约、cookie 与认证准备仍走同一套选择链路; - /// 唯一差异是不能用文本模型目录成员关系淘汰账号。 + /// 原生端点没有 Responses 模型,不套用管理员配置的文本模型权限。 pub(crate) async fn select_for_provider_endpoint( &self, request: &SelectCodexProviderEndpointCredential<'_>, ) -> Result { let input = CredentialSelectionInput { requires_websocket: false, - oauth_only: true, request_url: request.request_url, attempt: request.attempt, session_affinity_key: request.session_affinity.map(CodexSessionAffinity::key), session_affinity_observation: request.session_affinity, }; - self.select_inner(&input, None, ModelCatalogEligibility::NotApplicable) - .await + self.select_inner(&input, None, None).await } async fn select_inner( &self, request: &CredentialSelectionInput<'_>, cyber_policy_session_key: Option<&ProviderSessionAffinityKey>, - model_catalog_eligibility: ModelCatalogEligibility<'_>, + upstream_model: Option<&str>, ) -> Result { let queue_policy = request.attempt.account_selection_policy().queue_policy(); let mut waiting = CapacityWait::new( @@ -393,35 +373,22 @@ impl CodexCredentialSelector { .into_iter() .filter(|account| { account.provider() == &self.provider_kind - && (!request.oauth_only - || account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH) && (diagnostic || request .attempt .account_scope() .is_some_and(|scope| scope.allows(account.id()))) && (diagnostic - || match model_catalog_eligibility { - ModelCatalogEligibility::NotApplicable => true, - ModelCatalogEligibility::Required(upstream_model) => { - if !request.attempt.account_scope().is_some_and(|scope| { + || upstream_model.is_none_or(|upstream_model| { + let allowed = + request.attempt.account_scope().is_some_and(|scope| { scope.allows_model(account.id(), upstream_model) - }) { - model_access_rejected += 1; - return false; - } - let observed_support = self - .catalog - .observed_model_support(account, upstream_model); - if account.authentication_kind() - == super::CODEX_AUTHENTICATION_KIND_API_KEY - { - matches!(observed_support, Ok(Some(true))) - } else { - matches!(observed_support, Ok(None | Some(true))) - } + }); + if !allowed { + model_access_rejected += 1; } - }) + allowed + })) }) .collect::>(); let mut eligible = Vec::with_capacity(accounts.len()); @@ -1379,7 +1346,6 @@ impl fmt::Debug for CodexCredentialSelector { .debug_struct("CodexCredentialSelector") .field("repository", &"ProviderAccountStore") .field("leases", &"ProviderLeasePort") - .field("catalog", &"CodexCredentialCatalogService") .field("quota", &"CodexCredentialQuotaService") .field("cookie_policy", &self.cookie_policy) .finish() diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 49f030037..949d58c13 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -127,7 +127,6 @@ pub async fn initialize( Arc::clone(&leases), session_affinity, session_exclusions, - Arc::clone(&catalog), Arc::clone("a), Arc::clone(&account_feedback), CodexCookiePolicy::official().map_err(|_| OpenAiInitializeError::CookiePolicy)?, diff --git a/backend/crates/providers/openai/src/provider/execution.rs b/backend/crates/providers/openai/src/provider/execution.rs index 0f6953e66..9c7fd73ef 100644 --- a/backend/crates/providers/openai/src/provider/execution.rs +++ b/backend/crates/providers/openai/src/provider/execution.rs @@ -124,9 +124,13 @@ impl CodexProvider { crate::transport::request::scope_turn_metadata(metadata, lease.installation_id(), true) }); let events = cold_json_response_stream(ColdJsonResponse { - client: self.client.for_account(lease.account()).map_err(|_| { - provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) - })?, + client: self + .client + .for_account(lease.account()) + .map_err(|_| { + provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) + })? + .with_authentication(lease.authentication()), response_origin: request.response_origin, endpoint_path: request.endpoint_path, body: request.body, diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 25cd68bcf..c9fb2a0b1 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -279,6 +279,10 @@ impl Provider for CodexProvider { } } + fn model_catalog_is_exhaustive(&self) -> bool { + false + } + async fn query_model_capabilities( &self, ) -> Result, ProviderError> { @@ -403,8 +407,6 @@ impl Provider for CodexProvider { cyber_policy_session_key.as_ref(), session_affinity.as_ref(), requires_websocket, - upstream_request.responses_lite.is_some() - || upstream_request.memgen_request.is_some(), ) .await .map_err(map_selection_error)?; diff --git a/backend/crates/providers/openai/src/transport/client_json.rs b/backend/crates/providers/openai/src/transport/client_json.rs index aa7bd9334..3b861ab5c 100644 --- a/backend/crates/providers/openai/src/transport/client_json.rs +++ b/backend/crates/providers/openai/src/transport/client_json.rs @@ -27,6 +27,15 @@ impl CodexBackendClient { image_turn_id: Option<&str>, context: CodexRequestContext<'_>, ) -> CodexClientResult { + // Provider 端点以 Codex 路径标识;API Key 在自己的 API 前缀下使用对应相对路径。 + let endpoint_path = if self.protocol == super::client::OpenAiUpstreamProtocol::ResponsesApi + { + endpoint_path + .strip_prefix("/codex") + .unwrap_or(endpoint_path) + } else { + endpoint_path + }; let profile = self.profile.snapshot(); let mut headers = self.model_request_headers(&profile, context)?; headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); diff --git a/backend/crates/providers/openai/src/transport/headers.rs b/backend/crates/providers/openai/src/transport/headers.rs index ca619fd4d..d68f42d14 100644 --- a/backend/crates/providers/openai/src/transport/headers.rs +++ b/backend/crates/providers/openai/src/transport/headers.rs @@ -105,14 +105,13 @@ impl CodexBackendClient { profile: &CodexWireProfile, context: CodexRequestContext<'_>, ) -> CodexClientResult { - if self.protocol == OpenAiUpstreamProtocol::ResponsesApi { - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, HeaderValue::from_str(context.authorization)?); - return Ok(headers); - } - let mut headers = - build_codex_model_headers(profile, context.authorization, context.account_id)?; - insert_optional_header(&mut headers, "cookie", context.cookie_header)?; + // 客户端画像与认证方式无关;API Key 不携带 OAuth 账号身份和 Cookie。 + let (account_id, cookie_header) = match self.protocol { + OpenAiUpstreamProtocol::Codex => (context.account_id, context.cookie_header), + OpenAiUpstreamProtocol::ResponsesApi => (None, None), + }; + let mut headers = build_codex_model_headers(profile, context.authorization, account_id)?; + insert_optional_header(&mut headers, "cookie", cookie_header)?; Ok(headers) } @@ -137,13 +136,11 @@ impl CodexBackendClient { let mut headers = self.response_headers(request, context)?; headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - if self.protocol == OpenAiUpstreamProtocol::Codex { - insert_optional_protocol_header( - &mut headers, - X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, - request.responses_lite.as_deref(), - ); - } + insert_optional_protocol_header( + &mut headers, + X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, + request.responses_lite.as_deref(), + ); Ok(headers) } @@ -167,15 +164,6 @@ impl CodexBackendClient { context: CodexRequestContext<'_>, ) -> CodexClientResult { let mut headers = self.model_request_headers(&self.profile.snapshot(), context)?; - if self.protocol == OpenAiUpstreamProtocol::ResponsesApi { - // 上游身份只来自该账号;客户端的 OAuth 画像与 Cookie 不跨域转发。 - headers.insert( - HeaderName::from_static("x-client-request-id"), - HeaderValue::from_str(context.request_id)?, - ); - append_passthrough_headers(&mut headers, request, self.protocol); - return Ok(headers); - } headers.insert( HeaderName::from_static("x-client-request-id"), HeaderValue::from_str(context.request_id)?, @@ -215,46 +203,34 @@ impl CodexBackendClient { None => format!("model={}", request.model()), }; insert_optional_protocol_header(&mut headers, "x-codex-routing-hint", Some(&routing_hint)); - append_passthrough_headers(&mut headers, request, self.protocol); + append_passthrough_headers(&mut headers, request); Ok(headers) } } -fn append_passthrough_headers( - headers: &mut HeaderMap, - request: &CodexResponsesRequest, - protocol: OpenAiUpstreamProtocol, -) { +fn append_passthrough_headers(headers: &mut HeaderMap, request: &CodexResponsesRequest) { for name in request.passthrough_headers.keys() { // 身份与传输字段只由画像/正文生成;其余协议头保留原始多值字节。 - if (protocol == OpenAiUpstreamProtocol::ResponsesApi - && (name.as_str().starts_with("x-codex-") - || name.as_str().starts_with("x-openai-internal-") - || matches!( - name.as_str(), - "session-id" | "thread-id" | "x-openai-actor-authorization" - ))) - || matches!( - name.as_str(), - "originator" - | "user-agent" - | "version" - | "authorization" - | "chatgpt-account-id" - | "cookie" - | "x-openai-internal-codex-residency" - | "openai-beta" - | "accept" - | "content-type" - | "content-encoding" - | "x-codex-routing-hint" - | "x-codex-turn-id" - | "x-oai-attestation" - | "x-oai-is" - | "x-oai-is-update" - | X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER - ) - { + if matches!( + name.as_str(), + "originator" + | "user-agent" + | "version" + | "authorization" + | "chatgpt-account-id" + | "cookie" + | "x-openai-internal-codex-residency" + | "openai-beta" + | "accept" + | "content-type" + | "content-encoding" + | "x-codex-routing-hint" + | "x-codex-turn-id" + | "x-oai-attestation" + | "x-oai-is" + | "x-oai-is-update" + | X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER + ) { continue; } headers.remove(name); diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index ec5141b30..ae118623a 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -654,16 +654,8 @@ async fn free_and_k12_catalog_entitlements_are_isolated_by_plan_scope() { Some(vec!["gpt-5.4".to_owned()]) ); assert_eq!( - service - .observed_model_support(&first_free, "gpt-5.6-sol") - .expect("free support fact"), - Some(false) - ); - assert_eq!( - service - .observed_model_support(&k12, "gpt-5.6-sol") - .expect("K12 support fact"), - Some(true) + service.cached_account_models(&k12).expect("K12 catalog"), + Some(vec!["gpt-5.4".to_owned(), "gpt-5.6-sol".to_owned()]) ); let synchronized_generation = service.catalog_generation().get(); service.invalidate().expect("invalidate mixed-plan catalog"); @@ -679,8 +671,8 @@ async fn free_and_k12_catalog_entitlements_are_isolated_by_plan_scope() { ); assert_eq!( service - .observed_model_support(&k12, "gpt-5.6-sol") - .expect("support fact after invalidation"), + .cached_account_models(&k12) + .expect("catalog after invalidation"), None ); server.verify().await; diff --git a/backend/crates/providers/openai/tests/credential/contract.rs b/backend/crates/providers/openai/tests/credential/contract.rs index de13f5ad6..eb4ef6bab 100644 --- a/backend/crates/providers/openai/tests/credential/contract.rs +++ b/backend/crates/providers/openai/tests/credential/contract.rs @@ -28,9 +28,9 @@ use gateway_core::routing::{ }; use provider_openai::OFFICIAL_CODEX_BASE_URL; use provider_openai::credential::{ - CodexAccountFailure, CodexCookiePolicy, CodexCredentialCatalogService, CodexCredentialCodec, - CodexCredentialQuotaService, CodexCredentialSelector, CredentialSelectionError, - ImportCodexOAuthCredential, SelectCodexCredential, + CodexAccountFailure, CodexCookiePolicy, CodexCredentialCodec, CodexCredentialQuotaService, + CodexCredentialSelector, CredentialSelectionError, ImportCodexOAuthCredential, + SelectCodexCredential, }; use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState}; use secrecy::ExposeSecret; @@ -39,7 +39,7 @@ use url::Url; use crate::support::{ MemoryAccountStore, MemoryCooldownPort, MemorySessionAffinity, MemorySessionExclusions, - TestLeaseCoordinator, account_policy, catalog_cache, profile, secret, + TestLeaseCoordinator, account_policy, profile, secret, }; fn create_account(store: &Arc, id: &str, token: &str) { @@ -165,13 +165,6 @@ fn selector_with_runtime( verified_at: chrono::Utc::now(), }); let http = reqwest::Client::builder().build().expect("HTTP client"); - let catalog = Arc::new(CodexCredentialCatalogService::new( - store.repository(), - profile.clone(), - http.clone(), - OFFICIAL_CODEX_BASE_URL.to_owned(), - catalog_cache(), - )); let quota = Arc::new(CodexCredentialQuotaService::new( store.repository(), profile, @@ -187,7 +180,6 @@ fn selector_with_runtime( leases, session_affinity, Arc::new(MemorySessionExclusions::default()), - catalog, quota, account_feedback, CodexCookiePolicy::official().expect("official cookie policy"), diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 08e70a843..7acd29301 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -332,7 +332,6 @@ fn provider_and_quota_with_runtime_ports( leases, session_affinity, Arc::new(MemorySessionExclusions::default()), - Arc::clone(&catalog), Arc::clone("a), Arc::clone(&account_feedback), CodexCookiePolicy::official().expect("cookie policy"), @@ -7846,6 +7845,163 @@ async fn connection_limit_payload_survives_exhausted_retry_budget() { ); } +#[tokio::test] +async fn api_key_native_endpoints_preserve_bodies_headers_and_own_base_url() { + for prefix in ["", "/v1", "/custom/v2"] { + let upstream = MockServer::start().await; + let oauth = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key( + "acct_provider_contract", + format!("{}{prefix}", upstream.uri()), + provider_openai::credential::ApiKeyTransport::Http, + ) + .await; + let provider = provider_with_base_url(&store, oauth.uri()); + let body = br#"{ "model":"unlisted-model", "future":9007199254740993, "prompt":"first", "prompt":"last" }"#; + let response = br#"{ "output":"opaque", "future":9007199254740993, "usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7} }"#; + for (index, endpoint) in ["/images/generations", "/images/edits", "/alpha/search"] + .into_iter() + .enumerate() + { + Mock::given(method("POST")) + .and(path(format!("{prefix}{endpoint}"))) + .and(header("authorization", "Bearer sk-api-test-only")) + .and(body_bytes(body.to_vec())) + .respond_with( + ResponseTemplate::new(200) + .insert_header("x-upstream-feature", "opaque") + .set_body_raw(response.to_vec(), "application/json"), + ) + .expect(1) + .mount(&upstream) + .await; + let payload = RawJsonPayload::new("openai", Bytes::copy_from_slice(body)).unwrap(); + let operation = match index { + 0 => Operation::GenerateImage(ImageRequest::from_raw_json( + ImageRequestKind::Generation, + payload, + )), + 1 => Operation::GenerateImage(ImageRequest::from_raw_json( + ImageRequestKind::Edit, + payload, + )), + _ => Operation::Search(StandaloneSearchRequest::from_raw_json(payload)), + }; + let mut stream = provider + .execute( + planned_provider_endpoint_request("openai", operation), + context( + &format!("req_api_endpoint_{index}"), + CancellationToken::new(), + ), + ) + .await + .expect("API account eligible without a model catalog"); + let mut raw = None; + let mut header_preserved = false; + let mut usage = false; + while let Some(event) = stream.next().await { + let event = event.expect("raw endpoint response"); + if let Some(body) = event.wire_event().and_then(|wire| wire.raw_json_body()) { + raw = Some(body.clone()); + } + if let Some(observation) = event.response_observation() { + header_preserved |= observation.client_headers().iter().any(|header| { + header.name() == "x-upstream-feature" + && header.value().as_ref() == b"opaque" + }); + } + usage |= event + .canonical_facts() + .iter() + .any(|fact| matches!(fact, GatewayEvent::Usage(_))); + } + assert_eq!(raw.as_deref(), Some(response.as_slice())); + assert!(header_preserved); + if index < 2 { + assert!(usage, "image usage uses the shared accounting path"); + } + } + assert!(oauth.received_requests().await.unwrap().is_empty()); + for request in upstream.received_requests().await.unwrap() { + for name in ["cookie", "chatgpt-account-id"] { + assert!(!request.headers.contains_key(name)); + } + assert_eq!(request.headers["originator"], "codex_cli_rs"); + assert_eq!(request.headers["version"], "0.144.0"); + assert_eq!( + request.headers["user-agent"], + wire_profile().snapshot().user_agent() + ); + } + upstream.verify().await; + } +} + +#[tokio::test] +async fn api_key_responses_forward_lite_memgen_and_native_compaction_without_catalog_gates() { + let upstream = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key( + "acct_provider_contract", + upstream.uri(), + provider_openai::credential::ApiKeyTransport::Http, + ) + .await; + Mock::given(method("GET")) + .and(path("/models")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"data":[{"id":"different-model"}]})), + ) + .expect(1) + .mount(&upstream) + .await; + Mock::given(method("POST")) + .and(path("/responses")) + .and(header("x-openai-internal-codex-responses-lite", "true")) + .and(header("x-openai-memgen-request", "true")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"), + ) + .expect(1) + .mount(&upstream) + .await; + let provider = provider(&store); + provider.query_model_capabilities().await.unwrap(); + let body = json!({"model":"gpt-5.4","input":[{"type":"compaction_trigger"}],"reasoning":{"effort":"future-effort"},"tools":[{"type":"future-tool"}],"future_option":{"nested":true}}); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()) + .unwrap() + .with_context(Map::from_iter([ + ("responses_lite".to_owned(), json!("true")), + ("memgen_request".to_owned(), json!("true")), + ])), + )); + let mut stream = provider + .execute( + planned_request("openai", operation), + context("req_api_native_features", CancellationToken::new()), + ) + .await + .expect("upstream judges capabilities"); + while let Some(event) = stream.next().await { + event.expect("response"); + } + let requests = upstream.received_requests().await.unwrap(); + let request = requests + .iter() + .find(|request| request.method == "POST") + .unwrap(); + let captured: Value = serde_json::from_slice(&request.body).unwrap(); + for field in ["input", "reasoning", "tools", "future_option"] { + assert_eq!(captured[field], body[field]); + } + upstream.verify().await; +} + const API_KEY_DOWNSTREAM_HEADERS: &[&str] = &[ "cf-future-proxy-field", "x-forwarded-for", @@ -7857,21 +8013,29 @@ const API_KEY_DOWNSTREAM_HEADERS: &[&str] = &[ "x-grok-turn-idx", "x-xai-future-field", "session_id", - "session-id", - "thread-id", - "x-codex-future", - "x-openai-internal-future", + "x-openai-actor-authorization", "authorization", "cookie", "chatgpt-account-id", ]; +const API_KEY_BUSINESS_HEADERS: &[&str] = &[ + "session-id", + "thread-id", + "x-codex-future", + "x-openai-internal-future", +]; + fn generate_with_downstream_headers() -> Operation { let mut headers: Vec<_> = API_KEY_DOWNSTREAM_HEADERS .iter() + .chain(API_KEY_BUSINESS_HEADERS) .map(|name| json!([name, STANDARD.encode(b"downstream-value")])) .collect(); headers.push(json!(["x-business-extension", STANDARD.encode(b"keep")])); + for name in ["user-agent", "originator", "version"] { + headers.push(json!([name, STANDARD.encode(b"downstream-profile")])); + } Operation::Generate(GenerateRequest::from_protocol_payload( ProtocolPayload::json_object( "openai", @@ -7965,15 +8129,28 @@ async fn api_key_default_http_uses_own_prefix_plain_json_and_only_own_authentica assert!(!request.headers.contains_key(*name), "leaked {name}"); } assert_eq!(request.headers["x-business-extension"], "keep"); - for header in [ - "cookie", - "chatgpt-account-id", - "originator", - "version", - "x-codex-routing-hint", - ] { + for name in API_KEY_BUSINESS_HEADERS { + assert_eq!(request.headers[*name], "downstream-value", "lost {name}"); + } + for header in ["cookie", "chatgpt-account-id"] { assert!(!request.headers.contains_key(header), "unexpected {header}"); } + assert_eq!(request.headers["originator"], "codex_cli_rs"); + assert_eq!(request.headers["version"], "0.144.0"); + assert_eq!( + request.headers["user-agent"], + wire_profile().snapshot().user_agent() + ); + let model_request = requests + .iter() + .find(|request| request.method == "GET") + .unwrap(); + assert_eq!( + model_request.headers["user-agent"], + wire_profile().snapshot().user_agent() + ); + assert_eq!(model_request.headers["originator"], "codex_cli_rs"); + assert_eq!(model_request.headers["version"], "0.144.0"); let body: Value = serde_json::from_slice(&request.body).expect("ordinary JSON"); assert_eq!(body["stream"], true); assert_eq!(body["model"], "gpt-5.4"); @@ -8047,24 +8224,36 @@ async fn disabled_api_key_diagnostic_preserves_authentication_and_transport_cons ) .expect("search payload"), )); - for request in [ - planned_request("openai", warmup), - planned_provider_endpoint_request("openai", search), - ] { - let result = provider - .execute( - request, - diagnostic_context("req_disabled_api_restricted", account_id), - ) - .await; - let Err(error) = result else { - panic!("diagnostic must preserve authentication and transport restrictions") - }; - assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount); - assert_eq!(error.send_state(), UpstreamSendState::NotSent); + let result = provider + .execute( + planned_request("openai", warmup), + diagnostic_context("req_disabled_api_restricted", account_id), + ) + .await; + let Err(error) = result else { + panic!("diagnostic must preserve authentication and transport restrictions") + }; + assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + Mock::given(method("POST")) + .and(path("/alpha/search")) + .and(header("authorization", "Bearer sk-api-test-only")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"output":"ok"}))) + .expect(1) + .mount(&upstream) + .await; + let mut stream = provider + .execute( + planned_provider_endpoint_request("openai", search), + diagnostic_context("req_disabled_api_search", account_id), + ) + .await + .expect("Search uses the same diagnostic account"); + while let Some(event) = stream.next().await { + event.expect("Search response"); } assert!(oauth.received_requests().await.unwrap().is_empty()); - assert_eq!(upstream.received_requests().await.unwrap().len(), 1); + assert_eq!(upstream.received_requests().await.unwrap().len(), 2); assert!(!store.account(account_id).expect("API account").enabled()); } @@ -8138,7 +8327,12 @@ async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() { ); assert!(!request.headers().contains_key("chatgpt-account-id")); assert!(!request.headers().contains_key("cookie")); - assert!(!request.headers().contains_key("originator")); + assert_eq!(request.headers()["originator"], "codex_cli_rs"); + assert_eq!(request.headers()["version"], "0.144.0"); + assert_eq!( + request.headers()["user-agent"], + wire_profile().snapshot().user_agent() + ); for name in API_KEY_DOWNSTREAM_HEADERS .iter() .filter(|name| **name != "authorization") @@ -8146,6 +8340,9 @@ async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() { assert!(!request.headers().contains_key(*name), "leaked {name}"); } assert_eq!(request.headers()["x-business-extension"], "keep"); + for name in API_KEY_BUSINESS_HEADERS { + assert_eq!(request.headers()[*name], "downstream-value", "lost {name}"); + } }) .await; let frame = websocket.next().await.unwrap().unwrap(); diff --git a/docs/api.md b/docs/api.md index 6046f7454..a2e3ef767 100644 --- a/docs/api.md +++ b/docs/api.md @@ -166,8 +166,10 @@ Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwar `cdn-loop`)以及 `Accept-Encoding` / `Content-Encoding`。链路元数据和编解码能力 由各段传输层独立管理;其余业务扩展头继续透传,不使用固定业务头白名单。 此规则同时适用于上游 HTTP 和 WebSocket,不影响上游响应的 `cf-ray` 等诊断信息。 -API Key 上游还会移除 Cookie、ChatGPT 账号身份、`x-codex-*`、`x-openai-internal-*`、会话/线程身份头及 -`X-OpenAI-Actor-Authorization`,避免把 OAuth 或网关托管身份传给第三方 API。 +API Key 与 OAuth 共用模拟客户端画像(`User-Agent`、`originator`、`version`)和业务头透传规则, +包括会话、线程、Lite 和其他业务扩展头。 +上游认证只来自选中的账号;API Key 不携带 OAuth Cookie、ChatGPT 账号身份或下游的 +`X-OpenAI-Actor-Authorization` 托管认证声明。 Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和 `sec-fetch-*` 携带的下游 SDK/浏览器环境或页面来源。过滤规则适用于所有下游客户端,与 User-Agent 无关; @@ -547,18 +549,21 @@ API Key 账号使用以下独立凭据形态: } ``` -`base_url` 是 API 前缀,追加 `responses` 和 `models`,不会自动补 `/v1`;支持根路径和自定义前缀。 +`base_url` 是 API 前缀,追加 `responses`、`models`、`images/generations`、`images/edits` 或 `alpha/search`, +不会自动补 `/v1`;支持根路径和自定义前缀。 地址仅允许 HTTPS,HTTP 只允许本机回环;拒绝 URL 中的认证信息、查询串和 fragment。 `transport` 可省略(默认 `http`)或设为 `prefer_websocket`。API Key 使用 Bearer 认证与普通 JSON, -不携带 OAuth Cookie 或 ChatGPT 身份。上游须提供标准 `/models` 列表,模型目录按账号和凭据版本隔离。 +不携带 OAuth Cookie 或 ChatGPT 身份。上游模型列表使用标准 `/models` 格式,按账号和凭据版本隔离; +目录用于模型发现,不作为能力白名单,未列出的模型仍交由上游判断。 API Key 每次导入创建独立账号;更新已有账号使用 `rotate`。导出会显式包含密钥,沿用敏感导出的确认合同。 sub2api 的 `platform=openai`、`type=apikey` 使用 `credentials.base_url` / `credentials.api_key`; 导入时按其端点规则将服务根、版本前缀或完整 `/responses` 地址转换为 API 前缀,缺省地址为官方 `/v1`。 非空模型映射、请求头覆盖、其他协议和启用的 `extra.openai_*` 设置尚未适配,返回输入错误,需先移除并在本项目重新配置。 -API Key 账号支持 Responses、模型目录、连接测试和使用统计中的本地用量。OAuth 刷新/重新授权、ChatGPT 额度、个人资料、订阅、重置卡 -及 Images、standalone Search、Responses Lite / compact 专用入口不对这类账号开放。 +API Key 账号与 OAuth 共用现有 Responses、Images 和 standalone Search 请求与响应链路, +包括 Responses Lite 和原生压缩协议的透传,实际支持情况由上游决定;不提供独立 compact 路由。 +模型目录、连接测试和本地用量统计可用;OAuth 刷新/重新授权、ChatGPT 额度、个人资料、订阅和重置卡不适用。 客户端的 `image_generation` 和 `X-OpenAI-Actor-Authorization` 声明不改变账号的实际能力。 账号列表和详情的 API Key `usage` 汇总该账号创建后仍保留的本地请求记录,`windowLabelDisplay` 为 `通用额度`; 日志清理会影响累计范围,不代表上游余额。OAuth 账号仍按实际周/月额度窗口统计。 diff --git a/docs/architecture.md b/docs/architecture.md index 955abb322..c531f483f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -195,6 +195,8 @@ OpenAI 的 OAuth 与 API Key 共用现有账号和事务。API Key 的 Base URL 通过通用画像输出客户端目录,OAuth 原生对象保留。通用账号层按 Provider 提交的 credential state 调度,不以是否存在 上游用户 ID 推断可用性;OAuth 未完成身份投影时由 Provider 保持 `unknown`。状态恢复和未补齐身份的凭据轮换保留 `unknown`。 API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。 +OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证与传输配置。 +OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配置的模型权限仍由 Core 与选号链路执行。 - OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone Search 的业务正文按原始字节转发。canonical facts 从同一数据旁路提取,只用于路由、观测和计费。 diff --git a/release/notes.md b/release/notes.md index 7c2c9d215..f58305edb 100644 --- a/release/notes.md +++ b/release/notes.md @@ -2,7 +2,7 @@ ## 新增功能 -- 支持上游 API Key 账号,可配置服务地址与传输方式,使用 Responses、模型列表、连接测试和本地用量统计;支持手动添加及兼容格式的批量导入。这类账号不提供 ChatGPT 订阅额度、OAuth 刷新、Images、独立 Search 和 Lite/compact 能力。 +- 支持上游 API Key 账号,可配置服务地址与传输方式,使用模型列表、连接测试和本地用量统计;支持手动添加及兼容格式的批量导入。请求与响应复用 OAuth 的现有透传链路,Responses、Images、独立 Search、Lite 和原生压缩的实际支持情况由上游决定; - 批量导入改为后台任务,提供逐项进度、结果和停止待处理任务的入口;关闭页面后任务继续执行。任务队列保存在内存中,重启服务前请等待导入结束,已成功导入的账号不受影响。 - 新增可选的账号容量冷却:容量类错误达到阈值后暂时停止调度,支持恢复探测与自适应并发下调;管理端展示冻结原因和恢复时间。自动冻结默认关闭,按需在运行设置中启用。 - 支持在管理端设置全局请求位置与时区,也可为出站代理单独配置。代理自定义值优先于全局设置,用于 OpenAI/Codex Responses 的环境日期、时区和 Web Search 位置,不改变实际出口 IP。