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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-17 05:15:31 +08:00
co-authored by Codex
parent 42c2b249c2
commit 223021630e
17 changed files with 390 additions and 204 deletions
@@ -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<ProviderKind, ProviderCatalogGeneration> {
self.providers
.iter()
@@ -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<ProviderKind, ProviderCatalogGeneration>;
/// 查询指定 Provider 的模型事实;成功的空列表表示已知无模型,失败表示未知。
/// 查询模型发现事实;只有完整目录的空列表能证明无模型,失败表示未知。
fn query_model_capabilities(
&self,
provider: &ProviderKind,
@@ -310,14 +310,16 @@ async fn compile_runtime_snapshot(
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
let registered_providers = provider_kinds.iter().cloned().collect::<BTreeSet<_>>();
// 目录查询失败表示未知;查询成功后,即使为空,也必须与“已知缺少模型”区分。
// 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证。
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<BTreeMap<ProviderKind, BTreeMap<UpstreamModelId, super::ModelPresentation>>>,
model_mappings: Arc<BTreeMap<String, String>>,
provider_catalog_generations: Arc<BTreeMap<ProviderKind, ProviderCatalogGeneration>>,
known_provider_catalogs: Arc<BTreeSet<ProviderKind>>,
exhaustive_provider_catalogs: Arc<BTreeSet<ProviderKind>>,
account_directory: Arc<RuntimeAccountDirectory>,
client_policies: Arc<BTreeMap<ClientApiKeyId, ClientPolicy>>,
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::<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>::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<ProviderKind>) -> Self {
self.known_provider_catalogs = Arc::new(providers);
fn with_exhaustive_provider_catalogs(mut self, providers: BTreeSet<ProviderKind>) -> 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)
})
{
@@ -53,13 +53,14 @@ enum TestCatalog {
NoProviders,
Unavailable,
Empty,
Discovery,
}
impl ProviderCatalogPort for TestCatalog {
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
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<ProviderKind, ProviderCatalogGeneration> {
@@ -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(
@@ -438,19 +438,6 @@ impl CodexCredentialCatalogService {
.map(|models| models.map(<[String]>::to_vec))
}
pub fn observed_model_support(
&self,
account: &ProviderAccount,
model: &str,
) -> Result<Option<bool>, 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,
@@ -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<dyn ProviderLeasePort>,
session_affinity: Arc<dyn ProviderSessionAffinityPort>,
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
cookie_policy: CodexCookiePolicy,
risk_recovery: Mutex<HashMap<String, RiskRecoveryState>>,
@@ -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<dyn ProviderLeasePort>,
session_affinity: Arc<dyn ProviderSessionAffinityPort>,
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
account_feedback: Arc<AccountFeedbackStats>,
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<CodexCredentialLease, CredentialSelectionError> {
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<CodexCredentialLease, CredentialSelectionError> {
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<CodexCredentialLease, CredentialSelectionError> {
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<CodexCredentialLease, CredentialSelectionError> {
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::<Vec<_>>();
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()
@@ -127,7 +127,6 @@ pub async fn initialize(
Arc::clone(&leases),
session_affinity,
session_exclusions,
Arc::clone(&catalog),
Arc::clone(&quota),
Arc::clone(&account_feedback),
CodexCookiePolicy::official().map_err(|_| OpenAiInitializeError::CookiePolicy)?,
@@ -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,
@@ -279,6 +279,10 @@ impl Provider for CodexProvider {
}
}
fn model_catalog_is_exhaustive(&self) -> bool {
false
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, 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)?;
@@ -27,6 +27,15 @@ impl CodexBackendClient {
image_turn_id: Option<&str>,
context: CodexRequestContext<'_>,
) -> CodexClientResult<CodexBackendJsonResponse> {
// 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"));
@@ -105,14 +105,13 @@ impl CodexBackendClient {
profile: &CodexWireProfile,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
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<HeaderMap> {
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);
@@ -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;
@@ -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<MemoryAccountStore>, 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"),
@@ -332,7 +332,6 @@ fn provider_and_quota_with_runtime_ports(
leases,
session_affinity,
Arc::new(MemorySessionExclusions::default()),
Arc::clone(&catalog),
Arc::clone(&quota),
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();
+11 -6
View File
@@ -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 账号仍按实际周/月额度窗口统计。
+2
View File
@@ -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 从同一数据旁路提取,只用于路由、观测和计费。
+1 -1
View File
@@ -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。