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 <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-10 22:40:14 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 114baf669b
commit 758bd24b32
8 changed files with 365 additions and 363 deletions
@@ -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<String>,
}
impl CodexCatalogScope {
/// 从账号已验证的套餐事实构造目录作用域
pub fn for_account(account: &ProviderAccount) -> Result<Self, CodexCredentialCatalogError> {
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<ProviderCatalogScope, CodexCredentialCatalogError> {
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<Utc>,
models: Vec<String>,
}
impl CodexPlanCatalog {
impl CodexAccountCatalog {
#[must_use]
pub const fn new(
scope: CodexCatalogScope,
scope: ProviderCatalogScope,
observed_at: DateTime<Utc>,
models: Vec<String>,
) -> 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<CodexCatalogModel>,
scope_catalogs: BTreeMap<CodexCatalogScope, CatalogScopeSnapshot>,
}
#[derive(Clone, PartialEq, Eq)]
struct CatalogScopeSnapshot {
models: Vec<String>,
accounts: BTreeSet<ProviderAccountId>,
scope_catalogs: BTreeMap<CodexCatalogScope, Vec<String>>,
}
impl CodexCredentialCatalogSnapshot {
@@ -138,27 +135,22 @@ impl CodexCredentialCatalogSnapshot {
&self.models
}
pub fn account_models(
&self,
account: &ProviderAccount,
) -> Result<Option<&[String]>, 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<ProviderAccountId>> {
let mut accounts = BTreeMap::<&str, BTreeSet<ProviderAccountId>>::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<dyn ProviderCatalogCachePort>,
account_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
cache: Arc<RwLock<CatalogCacheState>>,
client_catalogs: Arc<Mutex<Vec<ClientCatalogEntry>>>,
background_capabilities: Arc<tokio::sync::Semaphore>,
@@ -293,26 +287,23 @@ impl CodexCredentialCatalogService {
profile: CodexWireProfileState,
http: reqwest::Client,
base_url: String,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
account_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
) -> 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::<Vec<_>>();
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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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<CodexPlanCatalog, CodexCredentialCatalogError> {
let scope = CodexCatalogScope::for_account(account)?;
if let Some(catalog) = self.read_plan_catalog(&scope).await? {
) -> Result<CodexAccountCatalog, CodexCredentialCatalogError> {
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<CodexPlanCatalog, CodexCredentialCatalogError> {
) -> Result<CodexAccountCatalog, CodexCredentialCatalogError> {
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<Option<CodexPlanCatalog>, CodexCredentialCatalogError> {
self.read_plan_catalog(&CodexCatalogScope::for_account(account)?)
) -> Result<Option<CodexAccountCatalog>, CodexCredentialCatalogError> {
self.read_cached_catalog(&CodexCatalogScope::for_account(account).cache_scope()?)
.await
}
@@ -745,8 +708,13 @@ impl CodexCredentialCatalogService {
}
async fn fetch_catalog(&self) -> Result<FetchedCatalog, CodexCredentialCatalogError> {
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<ProviderAccount>,
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let mut last_error = None;
for account in candidates.into_iter().take(MAX_CATALOG_FETCH_ATTEMPTS) {
match self.fetch_account_models(client, &account, 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<Option<CodexPlanCatalog>, CodexCredentialCatalogError> {
let key = self.plan_catalog_key(scope)?;
self.plan_catalog_cache
scope: &ProviderCatalogScope,
) -> Result<Option<CodexAccountCatalog>, 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<ProviderCatalogCacheKey, CodexCredentialCatalogError> {
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<String> {
.collect()
}
fn catalog_candidates_by_scope(
accounts: Vec<ProviderAccount>,
) -> Result<BTreeMap<CodexCatalogScope, Vec<ProviderAccount>>, CodexCredentialCatalogError> {
let now = SystemTime::now();
let mut groups = BTreeMap::<CodexCatalogScope, Vec<ProviderAccount>>::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<CodexPlanCatalog, CodexCredentialCatalogError> {
) -> Result<CodexAccountCatalog, CodexCredentialCatalogError> {
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(
@@ -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,
@@ -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(
@@ -45,7 +45,7 @@ impl Respond for ReplacingCatalogResponder {
}
}
/// 按调用顺序为不同套餐 scope 返回不同的目录正文
/// 按调用顺序为连续读取 返回不同的目录正文
struct SequencedCatalogResponder {
calls: Arc<AtomicUsize>,
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::<Vec<_>>(),
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());
@@ -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",
+1 -1
View File
@@ -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 与模型目录缓存
备份应覆盖哪些目录、如何生成一致性数据库归档,见 [备份与恢复](#备份与恢复)
+9 -10
View File
@@ -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 天;额度刷新若返回了更早的
+4 -4
View File
@@ -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 分钟内不再选用该组合。