From 758bd24b325dc8750f21f445a0bac845819fbb8c Mon Sep 17 00:00:00 2001 From: Zyy <0x@ainz.cc> Date: Tue, 10 Nov 2026 22:40:14 +0800 Subject: [PATCH] fix(openai): merge model catalogs across scoped OAuth accounts (#667) Discover models per account so the Codex picker includes models unique to an eligible group member. Use account-specific catalog evidence when selecting a credential for native Responses requests. Co-authored-by: Zyy Co-authored-by: Codex --- .../openai/src/credential/catalog.rs | 478 ++++++++---------- .../providers/openai/src/credential/mod.rs | 4 +- .../providers/openai/tests/admin/mod.rs | 8 +- .../openai/tests/credential/catalog.rs | 189 ++++--- .../contract/model_account_eligibility.rs | 20 +- deploy/README.md | 2 +- docs/api.md | 19 +- docs/architecture.md | 8 +- 8 files changed, 365 insertions(+), 363 deletions(-) diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index f102f4ffe..d610d3a43 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -1,4 +1,4 @@ -//! Codex 账号 entitlement 的进程内快照,以及套餐级 Redis 模型目录 cache +//! OpenAI 账号模型目录的聚合、缓存与调度资格 use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; use std::fmt; @@ -29,65 +29,68 @@ 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 ACCOUNT_CATALOG_CACHE_TTL: Duration = Duration::from_secs(5 * 60); // 调度证据覆盖默认 15 分钟刷新周期及抖动,长期未刷新时恢复上游验证 const SCHEDULING_CATALOG_MAX_AGE: Duration = Duration::from_secs(30 * 60); -const MAX_CATALOG_FETCH_ATTEMPTS: usize = 3; -const MAX_PLAN_CATALOG_MODELS: usize = 2_048; +const MAX_ACCOUNT_CATALOG_MODELS: usize = 2_048; const MAX_CLIENT_CATALOGS: usize = 32; // 保持原先 32 份、每份 1 MiB 的原生正文缓存预算,不把缓存容量变成上游目录合同 const MAX_CLIENT_CATALOG_CACHE_BYTES: usize = 32 * 1024 * 1024; const MODEL_CATALOG_TIMEOUT: Duration = Duration::from_secs(15); -const MAX_CONCURRENT_API_CATALOGS: usize = 4; +const MAX_CONCURRENT_CATALOGS: usize = 4; const CAPABILITY_LOOKUP_BUDGET: Duration = Duration::from_millis(500); const CLIENT_CATALOG_ERROR_TTL: Duration = Duration::from_secs(5); -/// OpenAI 以套餐划分的模型目录作用域 +/// 同一套餐也可能按账号开放模型,换凭据或套餐后必须重新取证 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct CodexCatalogScope(ProviderCatalogScope); +pub struct CodexCatalogScope { + account_id: ProviderAccountId, + revision: CredentialRevision, + plan: Option, +} impl CodexCatalogScope { - /// 从账号已验证的套餐事实构造目录作用域 - pub fn for_account(account: &ProviderAccount) -> Result { - if account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY { - return ProviderCatalogScope::new(format!( - "account:{}:{}", - account.id(), - account.revision().get() - )) - .map(Self) - .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData); + #[must_use] + pub fn for_account(account: &ProviderAccount) -> Self { + Self { + account_id: account.id().clone(), + revision: account.revision(), + plan: account + .plan_type() + .map(str::trim) + .filter(|plan| !plan.is_empty()) + .map(str::to_ascii_lowercase), } - let plan = account - .plan_type() - .map(str::trim) - .filter(|plan| !plan.is_empty()) - .map(str::to_ascii_lowercase) - .unwrap_or_else(|| "unknown".to_owned()); - ProviderCatalogScope::new(format!("plan:{plan}")) - .map(Self) - .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData) } - /// 返回稳定的 Provider-owned 作用域 - #[must_use] - pub fn as_str(&self) -> &str { - self.0.as_str() + /// Redis 作用域长度有上限,不直接拼接可变长度的账号与套餐文本 + pub fn cache_scope(&self) -> Result { + use sha2::{Digest, Sha256}; + let mut digest = Sha256::new(); + digest.update(self.account_id.as_str()); + digest.update([0]); + digest.update(self.plan.as_deref().unwrap_or_default()); + ProviderCatalogScope::new(format!( + "account:{}:{}", + hex::encode(digest.finalize()), + self.revision.get() + )) + .map_err(|_| CodexCredentialCatalogError::Cache) } } -/// Redis TTL cache 中一条可重建的套餐模型目录 +/// Redis TTL cache 中一条可重建的账号模型目录 #[derive(Clone, Debug, PartialEq, Eq)] -pub struct CodexPlanCatalog { - scope: CodexCatalogScope, +pub struct CodexAccountCatalog { + scope: ProviderCatalogScope, observed_at: DateTime, models: Vec, } -impl CodexPlanCatalog { +impl CodexAccountCatalog { #[must_use] pub const fn new( - scope: CodexCatalogScope, + scope: ProviderCatalogScope, observed_at: DateTime, models: Vec, ) -> Self { @@ -99,7 +102,7 @@ impl CodexPlanCatalog { } #[must_use] - pub const fn scope(&self) -> &CodexCatalogScope { + pub const fn scope(&self) -> &ProviderCatalogScope { &self.scope } @@ -118,13 +121,7 @@ impl CodexPlanCatalog { pub struct CodexCredentialCatalogSnapshot { observed_at: SystemTime, models: Vec, - scope_catalogs: BTreeMap, -} - -#[derive(Clone, PartialEq, Eq)] -struct CatalogScopeSnapshot { - models: Vec, - accounts: BTreeSet, + scope_catalogs: BTreeMap>, } impl CodexCredentialCatalogSnapshot { @@ -138,27 +135,22 @@ impl CodexCredentialCatalogSnapshot { &self.models } - pub fn account_models( - &self, - account: &ProviderAccount, - ) -> Result, CodexCredentialCatalogError> { - let scope = CodexCatalogScope::for_account(account)?; - Ok(self - .scope_catalogs - .get(&scope) - .map(|catalog| catalog.models.as_slice())) + pub fn account_models(&self, account: &ProviderAccount) -> Option<&[String]> { + self.scope_catalogs + .get(&CodexCatalogScope::for_account(account)) + .map(Vec::as_slice) } /// 将 Provider 私有目录作用域投影为模型来源账号,供 Core 结合冻结政策过滤 #[must_use] pub fn model_catalog_accounts(&self) -> BTreeMap<&str, BTreeSet> { let mut accounts = BTreeMap::<&str, BTreeSet>::new(); - for catalog in self.scope_catalogs.values() { - for model in &catalog.models { + for (scope, models) in &self.scope_catalogs { + for model in models { accounts .entry(model.as_str()) .or_default() - .extend(catalog.accounts.iter().cloned()); + .insert(scope.account_id.clone()); } } accounts @@ -246,12 +238,14 @@ struct ClientCatalogEntry { impl ClientCatalogEntry { fn is_fresh(&self) -> bool { let Some(value) = self.value.get() else { - return self.created_at.elapsed() < PLAN_CATALOG_CACHE_TTL; + // 最后一位读者取消后 OnceCell 不再初始化,不能继续占用缓存名额 + return Arc::strong_count(&self.value) > 1 + && self.created_at.elapsed() < ACCOUNT_CATALOG_CACHE_TTL; }; let ttl = if value.result.is_err() { CLIENT_CATALOG_ERROR_TTL } else { - PLAN_CATALOG_CACHE_TTL + ACCOUNT_CATALOG_CACHE_TTL }; value.completed_at.elapsed() < ttl } @@ -279,7 +273,7 @@ pub struct CodexCredentialCatalogService { profile: CodexWireProfileState, http: reqwest::Client, base_url: String, - plan_catalog_cache: Arc, + account_catalog_cache: Arc, cache: Arc>, client_catalogs: Arc>>, background_capabilities: Arc, @@ -293,26 +287,23 @@ impl CodexCredentialCatalogService { profile: CodexWireProfileState, http: reqwest::Client, base_url: String, - plan_catalog_cache: Arc, + account_catalog_cache: Arc, ) -> Self { Self { repository, profile, http, base_url, - plan_catalog_cache, + account_catalog_cache, cache: Arc::new(RwLock::new(CatalogCacheState::default())), client_catalogs: Arc::new(Mutex::new(Vec::new())), - background_capabilities: Arc::new(tokio::sync::Semaphore::new( - MAX_CONCURRENT_API_CATALOGS, - )), + background_capabilities: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CATALOGS)), etags: Arc::new(Mutex::new(CatalogEtagState::default())), etag_notification: Arc::new(Notify::new()), } } - /// OAuth 目录使用 Key 范围内排序稳定的首个成功账号;API Key 聚合按完整对象优先稳定选源 - /// 客户端原生对象不复用套餐级画像 + /// 聚合 Key 范围内各账号允许的模型,保留同名模型的首选完整对象 /// 同一账号/凭据版本/客户端版本的并发读取合并;原生正文仅保留在有界进程缓存 pub async fn client_model_catalog( &self, @@ -331,90 +322,97 @@ impl CodexCredentialCatalogService { } None => self.profile.snapshot(), }; - 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 api_catalogs = BTreeMap::new(); - let (api_accounts, mut accounts): (Vec<_>, Vec<_>) = - accounts.into_iter().partition(|account| { - account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY - }); - let mut catalogs = stream::iter(api_accounts) - .map(|account| { - let profile = request_profile.clone(); - async move { - let result = self - .cached_client_account_catalog( - client_key_id, - &account, - client_version, - profile, - ) - .await; - (account, result) - } - }) - .buffer_unordered(MAX_CONCURRENT_API_CATALOGS); - // 所有 API 账号共享等待预算;慢上游不能逐个耗尽超时或阻塞 OAuth 目录 - let deadline = Instant::now() + MODEL_CATALOG_TIMEOUT; - while let Ok(Some((account, result))) = - tokio::time::timeout_at(deadline, catalogs.next()).await - { - if let Ok(mut models) = result { - models.retain(|model| account.model_access().allows(model.model.as_str())); - api_catalogs.insert(account.id().clone(), models); - } - } - drop(catalogs); - let api_catalog_available = !api_catalogs.is_empty(); - // 完整对象优先于普通 ID;同类目录按账号顺序选来源,并保留上游条目顺序 - let (native, adapted): (Vec<_>, Vec<_>) = api_catalogs - .into_values() - .flatten() - .partition(|model| matches!(model.content, ProviderModelContent::Native(_))); - let mut seen = BTreeSet::new(); - let api_models = native - .into_iter() - .chain(adapted) - .filter(|model| seen.insert(model.model.clone())) + let account_ids = scope + .allowed_account_ids() + .filter(|id| scope.account_provider(id) == Some(&provider)) + .cloned() .collect::>(); + let mut accounts = self.repository.list_for_accounts(&account_ids).await?; accounts.retain(|account| { - account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_OAUTH + scope.allows_current_account(account) && eligible_catalog_account(account, now) }); - let mut last_error = CodexCredentialCatalogError::NoEligibleCredential; - for account in accounts.iter().take(MAX_CATALOG_FETCH_ATTEMPTS) { - match self - .cached_client_account_catalog( - client_key_id, - account, - client_version, - request_profile.clone(), + accounts.sort_by(|left, right| left.id().cmp(right.id())); + let (oauth, api): (Vec<_>, Vec<_>) = accounts + .into_iter() + .enumerate() + .filter(|(_, account)| { + matches!( + account.authentication_kind(), + super::CODEX_AUTHENTICATION_KIND_OAUTH + | super::CODEX_AUTHENTICATION_KIND_API_KEY ) - .await - { - Ok(models) => { - let native_ids = models - .iter() - .map(|model| model.model.clone()) - .collect::>(); - return Ok(models - .into_iter() - .chain( - api_models - .into_iter() - .filter(|model| !native_ids.contains(&model.model)), - ) - .collect()); + }) + .partition(|(_, account)| { + account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_OAUTH + }); + let read = |(index, account): (usize, ProviderAccount)| { + let profile = request_profile.clone(); + async move { + let result = self + .cached_client_account_catalog(client_key_id, &account, client_version, profile) + .await; + (index, account, result) + } + }; + // 两类上游独立限并发,共用一个期限,慢上游不占用另一类账号的读取名额 + let mut catalogs = stream::select( + stream::iter(oauth) + .map(read) + .buffer_unordered(MAX_CONCURRENT_CATALOGS), + stream::iter(api) + .map(read) + .buffer_unordered(MAX_CONCURRENT_CATALOGS), + ); + let deadline = Instant::now() + MODEL_CATALOG_TIMEOUT; + let mut selected = BTreeMap::new(); + let mut available = false; + let mut last_error = CodexCredentialCatalogError::NoEligibleCredential; + loop { + let (index, account, models) = + match tokio::time::timeout_at(deadline, catalogs.next()).await { + Ok(Some((index, account, Ok(models)))) => (index, account, models), + Ok(Some((_, _, Err(error)))) => { + last_error = error; + continue; + } + Ok(None) => break, + Err(_) => { + last_error = CodexCredentialCatalogError::Upstream { + detail: "model catalog timed out".to_owned(), + }; + break; + } + }; + available = true; + let api = account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY; + for (position, model) in models.into_iter().enumerate() { + if !scope.allows_model(account.id(), model.model.as_str()) { + continue; + } + // OAuth、API 原生、普通 ID 依次优先;响应快慢不改变来源或条目顺序 + let order = ( + api, + matches!(model.content, ProviderModelContent::Adapted(_)), + index, + position, + ); + match selected.entry(model.model.clone()) { + Entry::Vacant(entry) => { + entry.insert((order, model)); + } + Entry::Occupied(mut entry) if order < entry.get().0 => { + entry.insert((order, model)); + } + Entry::Occupied(_) => {} } - Err(error) => last_error = error, } } - if !api_catalog_available { - Err(last_error) - } else { - Ok(api_models) + if !available { + return Err(last_error); } + let mut models: Vec<_> = selected.into_values().collect(); + models.sort_unstable_by_key(|(order, _)| *order); + Ok(models.into_iter().map(|(_, model)| model).collect()) } async fn cached_client_account_catalog( @@ -593,7 +591,7 @@ impl CodexCredentialCatalogService { .cloned()) } - /// 读取账号当前已验证套餐的模型 entitlement;不触发网络 + /// 读取账号当前凭据对应的模型目录;不触发网络 pub fn cached_account_models( &self, account: &ProviderAccount, @@ -601,12 +599,10 @@ impl CodexCredentialCatalogService { let Some(snapshot) = self.cached()? else { return Ok(None); }; - snapshot - .account_models(account) - .map(|models| models.map(<[String]>::to_vec)) + Ok(snapshot.account_models(account).map(<[String]>::to_vec)) } - /// 有效目录约束账号模型资格,缺失目录或未知套餐继续交由上游判断 + /// 有效目录约束账号模型资格,没有该账号的有效目录时交由上游判断 pub(crate) fn scheduling_exclusions( &self, accounts: &[ProviderAccount], @@ -622,42 +618,34 @@ impl CodexCredentialCatalogService { }) else { return BTreeSet::new(); }; - // OAuth 按套餐共享,API Key 按账号与凭据版本隔离,不能借用其他来源的模型资格 - let excluded_scopes = snapshot - .scope_catalogs - .iter() - .filter(|(scope, catalog)| { - scope.as_str() != "plan:unknown" && !catalog.models.iter().any(|id| id == model) - }) - .map(|(scope, _)| scope) - .collect::>(); accounts .iter() .filter(|account| { - CodexCatalogScope::for_account(account) - .is_ok_and(|scope| excluded_scopes.contains(&scope)) + snapshot + .account_models(account) + .is_some_and(|models| !models.iter().any(|id| id == model)) }) .map(|account| account.id().clone()) .collect() } - /// 优先读取套餐目录 cache;缺失时才用当前账号所属套餐的有限候选集实时填充 + /// 优先读取账号目录缓存;缺失时只查询该账号 pub async fn cached_or_refresh_account_catalog( &self, account: &ProviderAccount, - ) -> Result { - let scope = CodexCatalogScope::for_account(account)?; - if let Some(catalog) = self.read_plan_catalog(&scope).await? { + ) -> Result { + let scope = CodexCatalogScope::for_account(account).cache_scope()?; + if let Some(catalog) = self.read_cached_catalog(&scope).await? { return Ok(catalog); } self.refresh_account_catalog(account.id()).await } - /// 实时刷新指定账号所属套餐的模型集合,并覆盖可重建 Redis cache + /// 实时刷新指定账号的模型集合,包括管理员显式查询的停用账号 pub async fn refresh_account_catalog( &self, account_id: &ProviderAccountId, - ) -> Result { + ) -> Result { let account = self .repository .store() @@ -666,46 +654,21 @@ impl CodexCredentialCatalogService { .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)? .filter(|account| account.provider().as_str() == "openai") .ok_or(CodexCredentialCatalogError::NoEligibleCredential)?; - let scope = CodexCatalogScope::for_account(&account)?; - let mut candidates = - match catalog_candidates_by_scope(self.repository.list_for_provider().await?) { - Ok(mut groups) => groups.remove(&scope).unwrap_or_default(), - // 全部账号都被调度列表过滤时按空候选处理,让下方目标账号补回继续生效 - Err(CodexCredentialCatalogError::NoEligibleCredential) => Vec::new(), - Err(error) => return Err(error), - }; - // 常规调度列表不含停用账号;管理端按账号查询模型要对停用账号返回真实上游 - // 结果,这里把不在候选里的目标账号本身补回,仍按优先顺序先试目标账号 - if !candidates - .iter() - .any(|candidate| candidate.id() == account_id) - { - candidates.push(account.clone()); - } - if candidates.is_empty() { - return Err(CodexCredentialCatalogError::NoEligibleCredential); - } - candidates.sort_by(|left, right| { - let left_preferred = left.id() == account_id; - let right_preferred = right.id() == account_id; - right_preferred - .cmp(&left_preferred) - .then_with(|| left.id().cmp(right.id())) - }); + let scope = CodexCatalogScope::for_account(&account).cache_scope()?; let client = CodexBackendClient::new( self.http.clone(), self.base_url.clone(), self.profile.clone(), ); - let fetched = self.fetch_scope_models(&client, candidates).await?; - let catalog = CodexPlanCatalog::new(scope, Utc::now(), model_ids(&fetched.models)); - self.replace_plan_catalog(&catalog).await?; + let fetched = self.fetch_account_models(&client, &account, None).await?; + let catalog = CodexAccountCatalog::new(scope, Utc::now(), model_ids(&fetched.models)); + self.replace_account_catalog(&catalog).await?; Ok(catalog) } /// 读取目标账号的原生目录条目;供管理端按账号导出目录文件使用 /// - /// 按账号直接请求上游,避免进程快照的跨套餐同名模型合并替换目标账号的原生对象; + /// 按账号直接请求上游,避免其他账号的同名模型替换目标账号的原生对象; /// 停用账号也不在常规快照候选中,仍允许管理员显式导出 /// 返回顺序沿用上游目录顺序,条目内容保持上游原样,不做别名改写或字段裁剪 pub async fn account_catalog_documents( @@ -724,12 +687,12 @@ impl CodexCredentialCatalogService { Ok((fetched.models, SystemTime::now())) } - /// 读取当前账号所属套餐的目录 cache,不触发上游请求 + /// 读取当前账号的目录缓存,不触发上游请求 pub async fn read_account_catalog( &self, account: &ProviderAccount, - ) -> Result, CodexCredentialCatalogError> { - self.read_plan_catalog(&CodexCatalogScope::for_account(account)?) + ) -> Result, CodexCredentialCatalogError> { + self.read_cached_catalog(&CodexCatalogScope::for_account(account).cache_scope()?) .await } @@ -745,8 +708,13 @@ impl CodexCredentialCatalogService { } async fn fetch_catalog(&self) -> Result { - let accounts = self.repository.list_for_provider().await?; - let groups = catalog_candidates_by_scope(accounts)?; + let mut accounts = self.repository.list_for_provider().await?; + let now = SystemTime::now(); + accounts.retain(|account| eligible_catalog_account(account, now)); + if accounts.is_empty() { + return Err(CodexCredentialCatalogError::NoEligibleCredential); + } + accounts.sort_by(|a, b| a.id().cmp(b.id())); let client = CodexBackendClient::new( self.http.clone(), self.base_url.clone(), @@ -757,49 +725,47 @@ impl CodexCredentialCatalogService { let mut scope_catalogs = BTreeMap::new(); let mut etags = Vec::new(); let mut last_error = None; - for (scope, candidates) in groups { - let accounts = candidates - .iter() - .map(|account| account.id().clone()) - .collect(); - let fetched = match self.fetch_scope_models(&client, candidates).await { + let client = &client; + let mut catalogs = stream::iter(accounts) + .map(|account| async move { + let result = self.fetch_account_models(client, &account, None).await; + (account, result) + }) + .buffered(MAX_CONCURRENT_CATALOGS); + while let Some((account, result)) = catalogs.next().await { + let fetched = match result { Ok(fetched) => fetched, Err(error) => { last_error = Some(error); continue; } }; + let scope = CodexCatalogScope::for_account(&account); let entitlement = model_ids(&fetched.models); - self.replace_plan_catalog(&CodexPlanCatalog::new( - scope.clone(), + self.replace_account_catalog(&CodexAccountCatalog::new( + scope.cache_scope()?, Utc::now(), entitlement.clone(), )) .await?; - for model in &fetched.models { + for model in fetched.models { let id = model.request_model().as_str().to_owned(); match union.entry(id) { Entry::Vacant(entry) => { union_order.push(entry.key().clone()); - entry.insert(model.clone()); + entry.insert(model); } Entry::Occupied(mut entry) => { // 原生目录含完整能力证据,同名 API ID 不能覆盖它,也不能跨来源拼字段 if model.document().protocol() == "codex" && entry.get().document().protocol() != "codex" { - entry.insert(model.clone()); + entry.insert(model); } } } } - scope_catalogs.insert( - scope, - CatalogScopeSnapshot { - models: entitlement, - accounts, - }, - ); + scope_catalogs.insert(scope, entitlement); etags.extend(fetched.etag); } if union.is_empty() @@ -819,53 +785,38 @@ impl CodexCredentialCatalogService { Ok(FetchedCatalog { snapshot, etags }) } - async fn fetch_scope_models( + async fn replace_account_catalog( &self, - client: &CodexBackendClient, - candidates: Vec, - ) -> Result { - let mut last_error = None; - for account in candidates.into_iter().take(MAX_CATALOG_FETCH_ATTEMPTS) { - match self.fetch_account_models(client, &account, None).await { - Ok(fetched) => return Ok(fetched), - Err(error) => last_error = Some(error), - } - } - Err(last_error.unwrap_or(CodexCredentialCatalogError::NoEligibleCredential)) - } - - async fn replace_plan_catalog( - &self, - catalog: &CodexPlanCatalog, + catalog: &CodexAccountCatalog, ) -> Result<(), CodexCredentialCatalogError> { - let key = self.plan_catalog_key(catalog.scope())?; - let document = encode_plan_catalog(catalog); - self.plan_catalog_cache - .replace(&key, &document, PLAN_CATALOG_CACHE_TTL) + let key = self.account_catalog_key(catalog.scope())?; + let document = encode_account_catalog(catalog); + self.account_catalog_cache + .replace(&key, &document, ACCOUNT_CATALOG_CACHE_TTL) .await .map_err(|_| CodexCredentialCatalogError::Cache) } - async fn read_plan_catalog( + async fn read_cached_catalog( &self, - scope: &CodexCatalogScope, - ) -> Result, CodexCredentialCatalogError> { - let key = self.plan_catalog_key(scope)?; - self.plan_catalog_cache + scope: &ProviderCatalogScope, + ) -> Result, CodexCredentialCatalogError> { + let key = self.account_catalog_key(scope)?; + self.account_catalog_cache .read(&key) .await .map_err(|_| CodexCredentialCatalogError::Cache)? - .map(|document| decode_plan_catalog(scope, document)) + .map(|document| decode_account_catalog(scope, document)) .transpose() } - fn plan_catalog_key( + fn account_catalog_key( &self, - scope: &CodexCatalogScope, + scope: &ProviderCatalogScope, ) -> Result { let provider_kind = ProviderKind::new("openai").map_err(|_| CodexCredentialCatalogError::Cache)?; - Ok(ProviderCatalogCacheKey::new(provider_kind, scope.0.clone())) + Ok(ProviderCatalogCacheKey::new(provider_kind, scope.clone())) } async fn fetch_account_models( @@ -986,7 +937,7 @@ impl CodexCredentialCatalogService { } } - /// 立即刷新所有套餐目录,但不接管 ETag daemon 已认领的刷新状态 + /// 立即刷新所有账号目录,但不接管 ETag daemon 已认领的刷新状态 /// /// 周期 worker 与 ETag daemon 可以同时请求目录;只有后者可以完成 /// `inflight` ETag 的状态转换,避免周期刷新错误地确认另一个请求的版本 @@ -1161,28 +1112,7 @@ fn model_ids(models: &[CodexCatalogModel]) -> Vec { .collect() } -fn catalog_candidates_by_scope( - accounts: Vec, -) -> Result>, CodexCredentialCatalogError> { - let now = SystemTime::now(); - let mut groups = BTreeMap::>::new(); - for account in accounts - .into_iter() - .filter(|account| eligible_catalog_account(account, now)) - { - let scope = CodexCatalogScope::for_account(&account)?; - groups.entry(scope).or_default().push(account); - } - for candidates in groups.values_mut() { - candidates.sort_by(|left, right| left.id().cmp(right.id())); - } - if groups.is_empty() { - return Err(CodexCredentialCatalogError::NoEligibleCredential); - } - Ok(groups) -} - -fn encode_plan_catalog(catalog: &CodexPlanCatalog) -> OpaqueProviderData { +fn encode_account_catalog(catalog: &CodexAccountCatalog) -> OpaqueProviderData { let mut document = serde_json::Map::new(); document.insert("version".to_owned(), serde_json::Value::from(1)); document.insert( @@ -1207,10 +1137,10 @@ fn encode_plan_catalog(catalog: &CodexPlanCatalog) -> OpaqueProviderData { OpaqueProviderData::new(document) } -fn decode_plan_catalog( - scope: &CodexCatalogScope, +fn decode_account_catalog( + scope: &ProviderCatalogScope, document: OpaqueProviderData, -) -> Result { +) -> Result { let mut fields = document.into_inner(); if fields.remove("version").and_then(|value| value.as_u64()) != Some(1) { return Err(CodexCredentialCatalogError::Cache); @@ -1232,7 +1162,7 @@ fn decode_plan_catalog( let values = fields .remove("models") .and_then(|value| value.as_array().cloned()) - .filter(|models| !models.is_empty() && models.len() <= MAX_PLAN_CATALOG_MODELS) + .filter(|models| !models.is_empty() && models.len() <= MAX_ACCOUNT_CATALOG_MODELS) .ok_or(CodexCredentialCatalogError::Cache)?; if !fields.is_empty() { return Err(CodexCredentialCatalogError::Cache); @@ -1250,7 +1180,7 @@ fn decode_plan_catalog( } models.push(model.as_str().to_owned()); } - Ok(CodexPlanCatalog::new(scope.clone(), observed_at, models)) + Ok(CodexAccountCatalog::new(scope.clone(), observed_at, models)) } fn same_catalog( diff --git a/backend/crates/providers/openai/src/credential/mod.rs b/backend/crates/providers/openai/src/credential/mod.rs index ec1fcbf8f..0bfe7adab 100644 --- a/backend/crates/providers/openai/src/credential/mod.rs +++ b/backend/crates/providers/openai/src/credential/mod.rs @@ -41,8 +41,8 @@ pub use admin::{ PreparedCodexCredentialRotationGuard, }; pub use catalog::{ - CodexCatalogScope, CodexCredentialCatalogError, CodexCredentialCatalogService, - CodexCredentialCatalogSnapshot, CodexPlanCatalog, + CodexAccountCatalog, CodexCatalogScope, CodexCredentialCatalogError, + CodexCredentialCatalogService, CodexCredentialCatalogSnapshot, }; pub use codec::{ CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeAuthentication, diff --git a/backend/crates/providers/openai/tests/admin/mod.rs b/backend/crates/providers/openai/tests/admin/mod.rs index b718217ed..c7e355c95 100644 --- a/backend/crates/providers/openai/tests/admin/mod.rs +++ b/backend/crates/providers/openai/tests/admin/mod.rs @@ -1212,7 +1212,13 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export let record = account_record(&account); let config = valid_config(); let catalog_cache = Arc::new(TestCatalogCache::default()); - catalog_cache.seed("plan:pro", ["gpt-5.4"]); + catalog_cache.seed( + provider_openai::credential::CodexCatalogScope::for_account(&account) + .cache_scope() + .unwrap() + .as_str(), + ["gpt-5.4"], + ); let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with_catalog( diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index ba825ee29..b1135f431 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -45,7 +45,7 @@ impl Respond for ReplacingCatalogResponder { } } -/// 按调用顺序为不同套餐 scope 返回不同的目录正文 +/// 按调用顺序为连续读取 返回不同的目录正文 struct SequencedCatalogResponder { calls: Arc, bodies: [&'static [u8]; 2], @@ -138,7 +138,8 @@ fn client_scope(accounts: &[ProviderAccount]) -> FrozenAccountScope { RuntimeAccount::new( ProviderKind::new("openai").expect("provider"), Default::default(), - ), + ) + .with_model_access(account.model_access().clone()), ) }) .collect(), @@ -152,6 +153,13 @@ 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 denied = second.clone().with_model_access( + gateway_core::account::AccountModelAccess::new( + gateway_core::account::AccountModelAccessMode::Denylist, + vec!["gpt-extra".into()], + ) + .unwrap(), + ); let server = MockServer::start().await; for (account, version, text) in [ ("acct_client_a", "0.154.0", "a-original"), @@ -165,19 +173,49 @@ async fn client_catalog_is_account_and_version_scoped_and_keeps_original_objects .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; + }, {"slug": if account == "acct_client_b" {"gpt-extra"} else {"gpt-native-a"}, "display_name":"Additional"}]}))).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"), + for (accounts, version, text, expected) in [ + ( + vec![second.clone(), first.clone()], + "0.154.0", + "a-original", + vec!["gpt-native", "gpt-native-a", "gpt-extra"], + ), + ( + vec![second], + "0.154.0", + "b-original", + vec!["gpt-native", "gpt-extra"], + ), + ( + vec![first.clone(), denied], + "0.154.0", + "a-original", + vec!["gpt-native", "gpt-native-a"], + ), + ( + vec![first.clone()], + "0.155.0", + "a-new-client", + vec!["gpt-native", "gpt-native-a"], + ), + ( + vec![first], + "0.154.0", + "a-original", + vec!["gpt-native", "gpt-native-a"], + ), ] { let models = service .client_model_catalog(&CLIENT_KEY, &client_scope(&accounts), version) .await .expect("catalog"); + assert_eq!( + models.iter().map(|m| m.model.as_str()).collect::>(), + expected + ); let document: serde_json::Value = serde_json::from_slice(match &models[0].content { gateway_core::routing::ProviderModelContent::Native(payload) => payload.body(), _ => panic!("expected native model"), @@ -294,7 +332,7 @@ async fn client_catalog_cache_does_not_survive_credential_rotation() { } #[tokio::test] -async fn failed_client_catalog_never_falls_back_to_an_out_of_scope_account_or_plan_cache() { +async fn failed_client_catalog_never_falls_back_to_an_out_of_scope_account_or_cached_profile() { let store = Arc::new(MemoryAccountStore::default()); let allowed = seed_account(&store, "acct_client_allowed").await; seed_account(&store, "acct_client_outside").await; @@ -484,7 +522,7 @@ async fn disabled_account_refresh_discovers_models_with_the_pinned_account() { } #[tokio::test] -async fn plan_catalog_cache_is_shared_and_manual_refresh_replaces_it() { +async fn refreshing_one_account_does_not_replace_a_peers_cached_catalog() { let store = Arc::new(MemoryAccountStore::default()); let first = seed_account(&store, "acct_catalog_a").await; let second = seed_account(&store, "acct_catalog_b").await; @@ -505,9 +543,9 @@ async fn plan_catalog_cache_is_shared_and_manual_refresh_replaces_it() { .await .expect("cold cache fetch"); let cached = service - .cached_or_refresh_account_catalog(&second) + .cached_or_refresh_account_catalog(&first) .await - .expect("shared cache hit"); + .expect("account cache hit"); let refreshed = service .refresh_account_catalog(second.id()) .await @@ -515,12 +553,12 @@ async fn plan_catalog_cache_is_shared_and_manual_refresh_replaces_it() { let replaced = service .cached_or_refresh_account_catalog(&first) .await - .expect("replaced cache hit"); + .expect("peer cache hit"); assert_eq!(initial.models(), ["gpt-5.4"]); assert_eq!(cached.models(), ["gpt-5.4"]); assert_eq!(refreshed.models(), ["gpt-5.5"]); - assert_eq!(replaced.models(), ["gpt-5.5"]); + assert_eq!(replaced.models(), ["gpt-5.4"]); assert_eq!(calls.load(Ordering::SeqCst), 2); server.verify().await; } @@ -572,27 +610,40 @@ async fn service_tier_changes_should_replace_cached_metadata_and_advance_generat } #[tokio::test] -async fn plan_catalog_refresh_stops_after_three_failed_accounts() { +async fn failed_oauth_catalog_does_not_hide_healthy_peers_or_borrow_their_models() { let store = Arc::new(MemoryAccountStore::default()); - let accounts = [ - seed_account(&store, "acct_catalog_limit_a").await, - seed_account(&store, "acct_catalog_limit_b").await, - seed_account(&store, "acct_catalog_limit_c").await, - seed_account(&store, "acct_catalog_limit_d").await, - ]; + let failed = seed_account(&store, "acct_failed").await; + let healthy = seed_account(&store, "acct_healthy").await; let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/codex/models")) - .respond_with(ResponseTemplate::new(503)) - .expect(3) - .mount(&server) - .await; + for (account, status) in [(&failed, 503), (&healthy, 200)] { + Mock::given(method("GET")) + .and(path("/codex/models")) + .and(header( + "chatgpt-account-id", + format!("chatgpt-{}", account.id()), + )) + .respond_with( + ResponseTemplate::new(status).set_body_raw(OFFICIAL_FIXTURE, "application/json"), + ) + .expect(if status == 503 { 2 } else { 1 }) + .mount(&server) + .await; + } let service = service_with_catalog_cache(&store, server.uri(), catalog_cache()); - - let error = service - .refresh_account_catalog(accounts[0].id()) + let models = service + .client_model_catalog( + &CLIENT_KEY, + &client_scope(&[failed.clone(), healthy]), + "0.154.0", + ) .await - .expect_err("three failed accounts stop the refresh"); + .unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].model.as_str(), "gpt-5.4"); + let error = service + .refresh_account_catalog(failed.id()) + .await + .expect_err("account refresh must not use a peer"); assert!(matches!( error, @@ -607,18 +658,20 @@ async fn cross_plan_presentation_drift_unions_on_the_first_seen_model() { seed_account_with_plan(&store, "acct_union_plus", "plus").await; seed_account_with_plan(&store, "acct_union_pro", "pro").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 Plus","description":"plus copy","supported_in_api":true}]}"#, - br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4 Pro","description":"pro copy","supported_in_api":true,"context_window":272000}]}"#, - ], - }) - .expect(2) - .mount(&server) - .await; + for (account, name) in [ + ("acct_union_plus", "GPT-5.4 Plus"), + ("acct_union_pro", "GPT-5.4 Pro"), + ] { + Mock::given(method("GET")) + .and(path("/codex/models")) + .and(header("chatgpt-account-id", format!("chatgpt-{account}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "models":[{"slug":"gpt-5.4","display_name":name,"supported_in_api":true}] + }))) + .expect(1) + .mount(&server) + .await; + } let service = service_with_catalog_cache(&store, server.uri(), catalog_cache()); let snapshot = service @@ -661,24 +714,30 @@ async fn cross_plan_supported_in_api_drift_unions_on_the_first_seen_model() { } #[tokio::test] -async fn free_and_k12_catalog_entitlements_are_isolated_by_plan_scope() { +async fn account_entitlements_keep_the_discovering_account_across_plans() { let store = Arc::new(MemoryAccountStore::default()); let first_free = seed_account_with_plan(&store, "acct_free_a", "free").await; let second_free = seed_account_with_plan(&store, "acct_free_b", "free").await; let k12 = seed_account_with_plan(&store, "acct_k12", "k12").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","supported_in_api":true}]}"#, - br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4","supported_in_api":true},{"slug":"gpt-5.6-sol","display_name":"GPT-5.6 Sol","supported_in_api":true}]}"#, - ], - }) - .expect(2) - .mount(&server) - .await; + for (account, extra) in [(&first_free, false), (&second_free, true), (&k12, true)] { + let mut models = vec![serde_json::json!({"slug":"gpt-5.4","display_name":"GPT-5.4"})]; + if extra { + models.push(serde_json::json!({"slug":"gpt-5.6-sol","display_name":"Sol"})); + } + Mock::given(method("GET")) + .and(path("/codex/models")) + .and(header( + "chatgpt-account-id", + format!("chatgpt-{}", account.id()), + )) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"models":models})), + ) + .expect(1) + .mount(&server) + .await; + } let service = service_with_catalog_cache(&store, server.uri(), catalog_cache()); let snapshot = service.synchronize().await.expect("mixed-plan catalog"); @@ -694,12 +753,16 @@ async fn free_and_k12_catalog_entitlements_are_isolated_by_plan_scope() { service .cached_account_models(&second_free) .expect("second free entitlement"), - Some(vec!["gpt-5.4".to_owned()]) + Some(vec!["gpt-5.4".to_owned(), "gpt-5.6-sol".to_owned()]) ); assert_eq!( service.cached_account_models(&k12).expect("K12 catalog"), Some(vec!["gpt-5.4".to_owned(), "gpt-5.6-sol".to_owned()]) ); + assert_eq!( + snapshot.model_catalog_accounts()["gpt-5.6-sol"], + std::collections::BTreeSet::from([second_free.id().clone(), k12.id().clone()]) + ); let synchronized_generation = service.catalog_generation().get(); service.invalidate().expect("invalidate mixed-plan catalog"); assert!( @@ -792,7 +855,7 @@ fn invalid_response_etag_is_rejected_without_touching_the_catalog() { #[tokio::test] async fn api_key_catalogs_are_isolated_and_join_oauth_without_claiming_native_metadata() { - use provider_openai::credential::{CodexCatalogScope, ResponsesTransport}; + use provider_openai::credential::ResponsesTransport; let oauth = MockServer::start().await; let first = MockServer::start().await; let second = MockServer::start().await; @@ -806,10 +869,6 @@ async fn api_key_catalogs_are_isolated_and_join_oauth_without_claiming_native_me .await; let a = store.account("acct_first").unwrap(); let b = store.account("acct_second").unwrap(); - assert_ne!( - CodexCatalogScope::for_account(&a).unwrap(), - CodexCatalogScope::for_account(&b).unwrap() - ); Mock::given(method("GET")) .and(path("/codex/models")) .respond_with(ResponseTemplate::new(200).set_body_json( @@ -894,7 +953,7 @@ async fn api_key_client_catalog_negotiates_and_preserves_versioned_native_object .seed_api_key("acct_api", upstream.uri(), ResponsesTransport::Http) .await; let account = store.account("acct_api").expect("API account"); - // 先填满普通 ID 目录,确保客户端不会把已有套餐缓存误当成原生元数据 + // 先填满普通 ID 目录,确保客户端不会把账号 ID 列表缓存误当成原生元数据 Mock::given(method("GET")) .and(path("/models")) .and(|request: &Request| request.url.query().is_none()) @@ -1453,7 +1512,7 @@ async fn slow_api_key_catalogs_share_a_deadline_and_preserve_healthy_catalogs() .await .expect("healthy API catalog completed and released a concurrency slot"); // 四个并发读取中,健康 API 目录完成后才会发出第四个慢请求,另有一次独立刷新 - // 只快进慢目录的共享期限,随后恢复实时时钟让 OAuth 冷请求完整通过真实 HTTP + // 快进共享期限,慢目录不影响已读取的账号 tokio::time::pause(); tokio::time::advance(Duration::from_secs(15)).await; tokio::time::resume(); @@ -1574,7 +1633,7 @@ async fn catalog_account_membership_changes_advance_generation_without_model_cha .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "models": [{"slug": "gpt-5.4", "display_name": "GPT-5.4"}] }))) - .expect(2) + .expect(3) .mount(&server) .await; let service = service_with_catalog_cache(&store, server.uri(), catalog_cache()); diff --git a/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs b/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs index 592b53a1a..1426642c9 100644 --- a/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs +++ b/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs @@ -166,7 +166,7 @@ async fn account_allowlists_override_catalog_omissions_but_weight_affinity_and_p "acct_scope_new", secondary_api, &api_secondary, - "free", + "pro", vec!["gpt-6-luna"], ), ] { @@ -362,7 +362,7 @@ async fn account_allowlists_override_catalog_omissions_but_weight_affinity_and_p } #[tokio::test] -async fn missing_catalog_unknown_plan_and_rotated_api_credentials_preserve_upstream_validation() { +async fn only_a_current_account_catalog_can_exclude_a_model() { use gateway_core::account::{CredentialCasOutcome, CredentialCasUpdate, ProviderAccountUpdate}; use provider_openai::credential::ResponsesTransport; @@ -418,7 +418,7 @@ async fn missing_catalog_unknown_plan_and_rotated_api_credentials_preserve_upstr .respond_with( ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"), ) - .expect(1) + .expect(if scenario == "unknown_plan" { 0 } else { 1 }) .mount(upstream) .await; let provider = provider_with_base_url(&store, native.uri()); @@ -446,13 +446,21 @@ async fn missing_catalog_unknown_plan_and_rotated_api_credentials_preserve_upstr CredentialCasOutcome::Updated(_) )); } - let mut stream = provider + let result = provider .execute( planned_request_for_model("openai", operation("gpt-6-astra"), "gpt-6-astra"), context("req_unknown_catalog", CancellationToken::new()), ) - .await - .unwrap(); + .await; + if scenario == "unknown_plan" { + // 未知套餐也有该账号亲自返回的目录,不能借套餐缺失绕过资格 + assert_eq!( + result.err().unwrap().kind(), + ProviderErrorKind::NoEligibleAccount + ); + continue; + } + let mut stream = result.unwrap(); assert_eq!( stream.metadata().provider_account_id().as_str(), "acct_scope_new", diff --git a/deploy/README.md b/deploy/README.md index 4e2afea3a..9d9faef55 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -704,7 +704,7 @@ Compose 使用以下绑定目录: PostgreSQL 是账号、Client Key、运行设置、请求记录与审计的权威存储;账号 credential 按 Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建、可过期的协调状态,例如 -会话亲和、lease、cooldown、OAuth pending flow 与套餐模型目录 cache +会话亲和、lease、cooldown、OAuth pending flow 与模型目录缓存 备份应覆盖哪些目录、如何生成一致性数据库归档,见 [备份与恢复](#备份与恢复) diff --git a/docs/api.md b/docs/api.md index 5c50533ac..98601c998 100644 --- a/docs/api.md +++ b/docs/api.md @@ -695,13 +695,12 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用 可通过 Client Key 分组限定目标平台。Provider 在选号时检查账号模型政策与目录资格, 具体边界见[账号模型限制](#账号模型限制);ChatGPT OAuth 账号不接受 Grok 模型 -目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的 -合格账号,最多尝试三个账号;API Key 目录按账号模型权限过滤后聚合。同名模型优先采用 OAuth 原生对象, -其次采用 API Key 的完整 Codex 对象,再使用普通模型 ID;同类 API Key 目录按账号 ID 确定来源,不跨账号 -或套餐混拼字段。因此单账号、无别名时可保持该账号的模型对象一致,多账号/多 Provider 聚合不代表 -“与某个官方账号的整个目录完全一致”,也不会固定后续推理账号。 -读取失败返回 `503 model_catalog_unavailable`,不以简化模板或空成功响应覆盖客户端缓存。 -成功获取的 API Key 目录被账号模型政策全部过滤时返回空列表,区别于读取失败。 +OpenAI 聚合当前 Key 范围内合格 OAuth 和 API Key 账号的目录。每个账号只贡献其模型政策允许的条目; +同名模型优先采用 OAuth 原生对象,其次采用 API Key 的完整 Codex 对象,再使用普通模型 ID。 +同类来源按账号 ID 排序,保留各来源的上游条目顺序,不跨账号拼接字段 + +部分账号失败或超时时,返回已成功读取的目录并集;没有成功读取任何目录时返回 `503 model_catalog_unavailable`。 +成功读取但全部条目被账号政策过滤时返回空列表。 目录保留完整的原生 Codex 模型对象。对于 OpenAI API Key 普通模型列表和 xAI 目录生成的通用画像, 当前 Key 可访问的适配器若声明了同名模型,其画像在原位置替换通用画像。仅由插件提供的可见模型按声明顺序追加到所属 Provider 的条目之后 @@ -1355,8 +1354,8 @@ Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修 原生 Responses 默认排除有效目录未列出目标模型的账号。OpenAI OAuth 或 API Key 账号的白名单精确包含映射后的上游 ID 时, 允许通过该账号请求目录外模型,由上游确认是否可用。此设置只对配置它的账号生效,已有白名单同样适用。 账号禁用、Key 分组、认证类型、黑名单和模型冷却仍参与选号。xAI 账号使用有效套餐目录与账号模型政策的交集。 -OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。 -OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess` +OpenAI 只使用该账号当前凭据和套餐对应、最近 30 分钟内同步的目录,不借用同套餐其他账号的模型。 +没有有效账号目录时交由上游验证,仍执行 `modelAccess`。xAI 使用已知套餐的有效目录缓存 原生 OpenAI Responses 上游返回 `error.code=model_not_found`,且状态为 400/403/404 或流内错误未提供状态时, 该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。仅消息提及模型不支持不触发冷却; @@ -1750,7 +1749,7 @@ OpenAI 授权码交换与令牌刷新请求的连接超时为 30 秒,单次上 预付余额保留上游账本符号,非零余额及剩余按量额度可独立证明可用;窗口已过期或缺少可用证据时, 不凭旧快照恢复账号 - 正常 Responses 请求会解析上游响应的 rate-limit headers,合并进同一 quota 快照并同步状态。Free、 - K12 等套餐共用该状态机;套餐只参与账号展示和按套餐隔离的模型目录 cache,不存在 K12 专属额度路径 + K12 等套餐共用该状态机,不存在 K12 专属额度路径 - OAuth 账号详情的 Token 统计、模型排行和列表 Token 汇总优先使用账号级周额度窗口,无可统计的周窗口时 使用月额度窗口;`usage.windowKind` 随选中的窗口返回 `weekly` 或 `monthly`。查询边界 严格为 `[resetAt - windowSeconds, resetAt)`,不是自然周/月或最近 7/30 天;额度刷新若返回了更早的 diff --git a/docs/architecture.md b/docs/architecture.md index ce2870003..3ec681b4f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -334,7 +334,7 @@ Provider 可为模型提供来源账号集合;Core 在公开目录查询时结 不解析上游字段;API 负责协议输出,不直接依赖具体 Provider。原生目录失败不会退回通用画像,只有不提供 该协议原生目录的 Provider(如 xAI 的 Codex 适配)才走画像转换。目录读取不创建计量请求或推理 attempt。 OpenAI 的账号/凭据 revision/客户端版本隔离、并发合并、TTL、容量与失效均归属 credential catalog -service;客户端原生对象保存在有界进程缓存中,与套餐 Redis 模型 ID 缓存分开管理。 +service;客户端原生对象保存在有界进程缓存中,与账号级 Redis 模型 ID 缓存分开管理。 该缓存最多保留 32 项,原生正文总预算为 32 MiB;单次结果超过预算时仍正常交付,但不驻留缓存 全局请求位置及开关由运行设置持久化;快照仅在开关开启时生成全局覆盖,并随 @@ -525,7 +525,7 @@ API Key 默认使用 Responses 协议和 HTTP/SSE,Responses 可选 WS 优先 协议和传输资格在选号阶段验证,WS pool 与 continuation 按凭据版本隔离。 Responses API Key 与 OAuth 共用原生请求与响应链路;Messages API Key 使用独立 HTTP transport, 在同一 Provider 的账号租约内执行生成和输入计数,不进入 Images、Search、Live 或 Responses 原生续接路径。 -OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth 按套餐、API Key 按账号与凭据版本匹配目录。 +OpenAI 模型目录用于发现与原生 Responses 账号资格筛选。 目录与账号白名单的优先级见[路由与账号范围](#6-路由账号范围与-continuation) - Responses 同协议执行是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义; @@ -763,9 +763,9 @@ OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐 资格检查复用账号持久事实,在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一检查 原生 Responses 在评分、亲和和租约前检查冻结的账号模型政策。OpenAI 账号白名单精确命中目标 ID 时允许上游验证, -其余 OpenAI 账号及 xAI 账号按有效目录排除未列出的模型。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。 +其余 OpenAI 账号及 xAI 账号按有效目录排除未列出的模型。OpenAI 目录按来源账号、凭据版本和套餐隔离;同套餐账号可以有不同的模型。 OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。 -未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格 +没有有效目录时保留上游验证,不从其他账号借用 OpenAI 模型资格,也不从其他套餐借用 xAI 模型资格 原生 OpenAI Responses 按[官方 Codex 0.166.0](https://github.com/openai/codex/blob/6ff1e1ca3b30fc8b914720681fcf5fe62e95f856/codex-rs/codex-api/src/error.rs#L73)的结构化 `model_not_found` 错误码与状态合同识别模型拒绝, 不从消息或任意正文推断模型权限;通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。