fix(providers): align catalog-based account eligibility

Apply valid catalog membership alongside account model access for native OAuth, API key upstreams, and xAI. Preserve upstream validation for missing catalogs and unknown plans.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-02 00:02:08 +08:00
co-authored by Codex
parent 6f138c8256
commit 96a292dd0f
8 changed files with 374 additions and 126 deletions
@@ -539,7 +539,7 @@ impl CodexCredentialCatalogService {
.map(|models| models.map(<[String]>::to_vec))
}
/// 新鲜 OAuth 套餐目录只约束已发现的模型,未知目录与模型继续交由上游判断
/// 有效目录约束账号模型资格,缺失目录或未知套餐继续交由上游判断
pub(crate) fn scheduling_exclusions(
&self,
accounts: &[ProviderAccount],
@@ -555,26 +555,20 @@ impl CodexCredentialCatalogService {
}) else {
return BTreeSet::new();
};
// API Key 的自定义目录不能为 OAuth 套餐生成排除证据
let plans = snapshot.scope_catalogs.iter().filter(|(scope, _)| {
scope.as_str().starts_with("plan:") && scope.as_str() != "plan:unknown"
});
if !plans
.clone()
.any(|(_, catalog)| catalog.models.iter().any(|id| id == model))
{
return BTreeSet::new();
}
let excluded_scopes = plans
.filter(|(_, catalog)| !catalog.models.iter().any(|id| id == model))
// 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| {
account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_OAUTH
&& CodexCatalogScope::for_account(account)
.is_ok_and(|scope| excluded_scopes.contains(&scope))
CodexCatalogScope::for_account(account)
.is_ok_and(|scope| excluded_scopes.contains(&scope))
})
.map(|account| account.id().clone())
.collect()
@@ -656,7 +656,7 @@ impl CodexCredentialSelector {
}) {
continue;
}
// 受管适配器拥有其上游模型合同,不套用官方 OAuth 目录与拒绝反馈
// 受管适配器拥有其上游模型合同,不套用原生目录与拒绝反馈
if adapter.is_none() {
if catalog_excluded.contains(account.id()) {
catalog_rejected += 1;
@@ -11071,7 +11071,7 @@ async fn api_key_native_endpoints_preserve_bodies_headers_and_own_base_url() {
}
#[tokio::test]
async fn api_key_responses_forward_lite_memgen_and_native_compaction_without_catalog_gates() {
async fn api_key_responses_forward_lite_memgen_and_native_compaction_with_catalog_eligibility() {
let upstream = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
@@ -11083,9 +11083,7 @@ async fn api_key_responses_forward_lite_memgen_and_native_compaction_without_cat
.await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data":[{"id":"different-model"}]})),
)
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[{"id":"gpt-5.4"}]})))
.expect(1)
.mount(&upstream)
.await;
@@ -12671,7 +12669,7 @@ async fn capture_client_request(
}
#[tokio::test]
async fn public_catalog_filters_each_api_account_before_union_without_gating_inference() {
async fn public_catalog_filters_each_api_account_before_union_and_preserves_route_planning() {
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
use gateway_core::engine::provider::{Provider, ProviderRegistry};
use gateway_core::routing::snapshot::{
@@ -12796,7 +12794,7 @@ async fn public_catalog_filters_each_api_account_before_union_without_gating_inf
let model = PublicModelId::new(id).unwrap();
assert!(!snapshot.contains_public_model_for_scope(&model, &scope));
}
// 目录来源只决定展示,发现型目录仍允许把请求交给政策合规的上游判断
// Core 按冻结政策编排路由,具体账号的目录资格由 Provider 在选号时检查
for id in ["kimi-k2.5", "kimi-public", "not-yet-discovered"] {
let model = PublicModelId::new(id).unwrap();
assert_eq!(
@@ -141,61 +141,96 @@ async fn unlisted_models_remain_available_without_catalog_discovery() {
}
#[tokio::test]
async fn known_oauth_models_filter_plan_candidates_before_weight_and_keep_unknown_models() {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let mut free = profile("chatgpt-acct_scope_new");
free.plan_type = Some("free".to_owned());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_scope_new".to_owned(),
name: "free".to_owned(),
secret: secret("test-free"),
verified_account: free,
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
store.set_scheduling("acct_scope_new", None, AccountWeight::new(100).unwrap());
for (id, models) in [
(
"acct_scope_new",
json!([{"slug":"gpt-6-luna","display_name":"Luna"}]),
),
(
"acct_provider_contract",
json!([{"slug":"gpt-6-luna","display_name":"Luna"},{"slug":"gpt-6-astra","display_name":"Astra"}]),
),
] {
Mock::given(method("GET"))
.and(path("/codex/models"))
.and(header("chatgpt-account-id", format!("chatgpt-{id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"models":models})))
.expect(1)
.mount(&server)
.await;
}
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
)
.expect(3)
.mount(&server)
.await;
let affinity = Arc::new(MemorySessionAffinity::default());
let provider = provider_with_affinity_and_base_url(&store, affinity.clone(), server.uri());
provider.query_model_capabilities().await.unwrap();
for (model, expected) in [
("gpt-6-luna", "acct_scope_new"),
("gpt-6-astra", "acct_provider_contract"),
("custom-unlisted", "acct_scope_new"),
] {
let request_operation = if model == "custom-unlisted" {
operation(model)
} else {
Operation::Generate(GenerateRequest::from_protocol_payload(
async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_accounts() {
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
use provider_openai::credential::ResponsesTransport;
for (primary_api, secondary_api) in [(false, false), (true, true), (true, false), (false, true)]
{
let native = MockServer::start().await;
let api_primary = MockServer::start().await;
let api_secondary = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
for (id, api, server, plan, models) in [
(
"acct_provider_contract",
primary_api,
&api_primary,
"pro",
vec!["gpt-6-luna", "gpt-6-astra"],
),
(
"acct_scope_new",
secondary_api,
&api_secondary,
"free",
vec!["gpt-6-luna"],
),
] {
if api {
store
.seed_api_key(id, server.uri(), ResponsesTransport::Http)
.await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": models.iter().map(|id| json!({"id": id})).collect::<Vec<_>>()
})))
.expect(1)
.mount(server)
.await;
} else {
let mut identity = profile(&format!("chatgpt-{id}"));
identity.plan_type = Some(plan.to_owned());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: id.to_owned(),
name: plan.to_owned(),
secret: secret("test-access"),
verified_account: identity,
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.and(header("chatgpt-account-id", format!("chatgpt-{id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"models": models.iter().map(|id| json!({"slug": id, "display_name": id})).collect::<Vec<_>>()
})))
.expect(1)
.mount(&native).await;
}
}
for (server, endpoint) in [
(&native, "/codex/responses"),
(&api_primary, "/responses"),
(&api_secondary, "/responses"),
] {
Mock::given(method("POST"))
.and(path(endpoint))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
)
.mount(server)
.await;
}
store.set_scheduling("acct_scope_new", None, AccountWeight::new(100).unwrap());
let affinity = Arc::new(MemorySessionAffinity::default());
let leases = Arc::new(TestLeaseCoordinator::default());
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
affinity.clone(),
native.uri(),
leases.clone(),
);
provider.query_model_capabilities().await.unwrap();
for (model, expected) in [
("gpt-6-luna", "acct_scope_new"),
("gpt-6-astra", "acct_provider_contract"),
] {
let request_operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
json!({
@@ -210,52 +245,212 @@ async fn known_oauth_models_filter_plan_candidates_before_weight_and_keep_unknow
)
.unwrap()
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])),
))
));
let mut stream = provider
.clone()
.execute(
planned_request_for_model("openai", request_operation, model),
context("req_catalog_model", CancellationToken::new()),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
expected,
"{model}: primary_api={primary_api}, secondary_api={secondary_api}"
);
while let Some(event) = stream.next().await {
event.unwrap();
}
}
assert_eq!(affinity.binding_count(), 1);
// 目录资格不能被固定账号、模型允许列表或另一个账号的目录放宽
for (model, required, disable_supported) in [
("gpt-6-astra", Some("acct_scope_new"), false),
("custom-unlisted", None, false),
("gpt-6-astra", None, true),
] {
let scope = if disable_supported {
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
(
ProviderAccountId::new("acct_provider_contract").unwrap(),
RuntimeAccount::new(
ProviderKind::new("openai").unwrap(),
BTreeSet::new(),
)
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Denylist,
vec![model.to_owned()],
)
.unwrap(),
),
),
(
ProviderAccountId::new("acct_scope_new").unwrap(),
RuntimeAccount::new(
ProviderKind::new("openai").unwrap(),
BTreeSet::new(),
)
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Allowlist,
vec![model.to_owned()],
)
.unwrap(),
),
),
]))),
ClientRoutingScope::all_accounts(),
))
} else {
contract_account_scope()
};
let attempt = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_catalog_rejected").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::MIN,
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(
BTreeSet::new(),
required.map(|id| ProviderAccountId::new(id).unwrap()),
None,
)
.with_account_scope(scope),
None,
CancellationToken::new(),
);
let acquisitions = leases.requests.lock().unwrap().len();
let error = provider
.clone()
.execute(
planned_request_for_model("openai", operation(model), model),
attempt,
)
.await
.err()
.expect("catalog and model access must both permit the account");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(leases.requests.lock().unwrap().len(), acquisitions);
}
let sent = native
.received_requests()
.await
.unwrap()
.into_iter()
.chain(api_primary.received_requests().await.unwrap())
.chain(api_secondary.received_requests().await.unwrap())
.filter(|request| request.method == "POST")
.count();
assert_eq!(sent, 2);
}
}
#[tokio::test]
async fn missing_catalog_unknown_plan_and_rotated_api_credentials_preserve_upstream_validation() {
use gateway_core::account::{CredentialCasOutcome, CredentialCasUpdate, ProviderAccountUpdate};
use provider_openai::credential::ResponsesTransport;
for scenario in ["missing", "unknown_plan", "rotated"] {
let native = MockServer::start().await;
let api = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"models": [{"slug": "gpt-6-luna", "display_name": "Luna"}]
})))
.mount(&native)
.await;
let endpoint = if scenario == "unknown_plan" {
let mut identity = profile("chatgpt-acct_scope_new");
identity.plan_type = None;
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_scope_new".to_owned(),
name: "unknown".to_owned(),
secret: secret("test-access"),
verified_account: identity,
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
"/codex/responses"
} else {
store
.seed_api_key("acct_scope_new", api.uri(), ResponsesTransport::Http)
.await;
let response = if scenario == "missing" {
ResponseTemplate::new(503)
} else {
ResponseTemplate::new(200).set_body_json(json!({"data": [{"id": "gpt-6-luna"}]}))
};
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(response)
.mount(&api)
.await;
"/responses"
};
let upstream = if scenario == "unknown_plan" {
&native
} else {
&api
};
Mock::given(method("POST"))
.and(path(endpoint))
.respond_with(
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
)
.expect(1)
.mount(upstream)
.await;
let provider = provider_with_base_url(&store, native.uri());
provider.query_model_capabilities().await.unwrap();
if scenario == "rotated" {
let account = store.account("acct_scope_new").unwrap();
let loaded = store.load_current_credential(account.id()).await.unwrap();
let update = CredentialCasUpdate::new(
account.id().clone(),
account.revision(),
ProviderAccountUpdate {
account_id: account.id().clone(),
name: account.name().to_owned(),
email: None,
plan_type: None,
},
loaded.credential,
false,
None,
None,
)
.unwrap();
assert!(matches!(
store.compare_and_swap_credential(update).await.unwrap(),
CredentialCasOutcome::Updated(_)
));
}
let mut stream = provider
.clone()
.execute(
planned_request_for_model("openai", request_operation, model),
context("req_plan_model", CancellationToken::new()),
planned_request_for_model("openai", operation("gpt-6-astra"), "gpt-6-astra"),
context("req_unknown_catalog", CancellationToken::new()),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
expected,
"{model}"
"acct_scope_new",
"{scenario}"
);
while let Some(event) = stream.next().await {
event.unwrap();
}
}
assert_eq!(affinity.binding_count(), 1);
let pinned = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_catalog_pin").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::MIN,
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(
BTreeSet::new(),
Some(ProviderAccountId::new("acct_scope_new").unwrap()),
None,
)
.with_account_scope(contract_account_scope()),
None,
CancellationToken::new(),
);
let error = provider
.execute(
planned_request_for_model("openai", operation("gpt-6-astra"), "gpt-6-astra"),
pinned,
)
.await
.err()
.expect("a fixed account cannot escape model eligibility");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
}
#[tokio::test]
@@ -165,7 +165,11 @@ impl GrokAccountSessionSelector {
// 目录支持度按套餐 scope 去重后并发批量读取,避免逐账号串行往返
let account_scopes = accounts
.iter()
.map(|account| GrokCatalogScope::for_account(account).ok())
.map(|account| {
GrokCatalogScope::for_account(account)
.ok()
.filter(|scope| scope.as_str() != "plan:unknown")
})
.collect::<Vec<_>>();
let unique_scopes = account_scopes
.iter()
@@ -947,6 +947,58 @@ async fn explicit_catalog_non_membership_excludes_only_unsupported_account() {
assert_eq!(session.account_id(), &account_id("zzz-supported"));
}
#[tokio::test]
async fn catalog_rejects_unlisted_models_but_does_not_share_unknown_plan_entitlements() {
let fixture = SelectorFixture::new(&["catalog-account"]).await;
let id = account_id("catalog-account");
let account = fixture.store.account(&id).unwrap();
let seed = GrokCredentialCatalogSeed::new(["grok-4.5"], None).unwrap();
fixture
.cache
.replace(GrokPlanCatalog::new(
GrokCatalogScope::for_account(&account).unwrap(),
Utc::now(),
seed.clone(),
))
.await
.unwrap();
let request = || {
fixture.request_for_model_with_policy(
"grok-unlisted",
BTreeSet::new(),
None,
RotationStrategy::Smart,
)
};
assert!(matches!(
fixture.selector.select(request()).await,
Err(GrokSessionSelectorError::NoEligibleSession)
));
assert!(fixture.coordinator.requests.lock().unwrap().is_empty());
fixture
.store
.update_account(ProviderAccountUpdate {
account_id: id.clone(),
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
plan_type: None,
})
.await
.unwrap();
let account = fixture.store.account(&id).unwrap();
fixture
.cache
.replace(GrokPlanCatalog::new(
GrokCatalogScope::for_account(&account).unwrap(),
Utc::now(),
seed,
))
.await
.unwrap();
let session = fixture.selector.select(request()).await.unwrap();
assert_eq!(session.account_id(), &id);
}
#[tokio::test]
async fn smart_strategy_uses_fresh_provider_quota_after_load_ties() {
let fixture = SelectorFixture::new(&["aaa-low-quota", "zzz-high-quota"]).await;
+10 -7
View File
@@ -550,8 +550,8 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用
已知模型只路由到明确提供该模型的 Provider,账号不可用时不转发给其他平台的未知模型通道。
未知模型只在当前范围与路由约束下存在唯一 Provider 透传候选时发送;无法确定归属时返回不可用错误,
可通过 Client Key 分组限定目标平台。一个 Provider 内的多个 API Key 账号仍按账号模型政策与调度规则选择,
不要求模型已被上游目录收录;ChatGPT OAuth 账号不接受 Grok 模型,已知模型按[账号模型限制](#账号模型限制)中的套餐资格筛选
可通过 Client Key 分组限定目标平台。OpenAI OAuth、API Key 上游和 xAI 账号均叠加有效目录资格与账号模型政策,
具体边界见[账号模型限制](#账号模型限制);ChatGPT OAuth 账号不接受 Grok 模型
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的
合格账号,最多尝试三个账号;API Key 目录按账号模型权限过滤后聚合。同名模型优先采用 OAuth 原生对象,
@@ -1172,10 +1172,13 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota`
例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流;
Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则
原生 ChatGPT OAuth Responses 会在评分和亲和前检查最近 30 分钟内同步的套餐目录:模型已在已知套餐中出现时,
不会选中成功读取目录但未包含该模型的套餐账号。未知套餐、目录读取失败或过期、未收录模型仍交由上游验证。
原生 Responses 上游明确拒绝某个账号使用该模型后,该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。
冷却按凭据版本隔离,不修改 `modelAccess`,也不解除原生续写的固定账号约束。受管上游适配器不应用这两项原生限制
原生 Responses 选号同时检查有效模型目录与 `modelAccess`,OAuth、API Key 上游和 xAI 使用相同资格规则:
有效目录未包含请求模型时排除该账号,目录包含模型也不能绕过禁用或黑白名单;权重、亲和和固定账号均不能放宽资格。
OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。
OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess`
原生 OpenAI Responses 上游明确拒绝某个账号使用该模型后,该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。
冷却按凭据版本隔离,不修改 `modelAccess`,也不解除原生续写的固定账号约束。OpenAI 的受管上游适配器不应用原生目录限制和模型拒绝冷却
单账号更新或批量更新省略 `modelAccess` 时保留原值,显式提交 `{ "mode": "all", "models": [] }` 清除限制。
批量接口的调度、分组、模型与代理字段均可省略;省略的字段保持各账号原值。
@@ -1186,7 +1189,7 @@ Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修
Images 和 Search 按原始请求正文的模型 ID 检查,不依赖模型目录或全局模型映射;可手动添加目录未列出的模型 ID。
模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。
Search 的选号限制不约束插件在选号后主动改写正文。
管理员连接测试不受账号模型政策、套餐目录筛选与模型拒绝冷却限制;连接测试成功只证明指定账号的上游能力。
管理员连接测试不受账号模型政策、目录资格筛选与模型拒绝冷却限制;连接测试成功只证明指定账号的上游能力。
普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示;
同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
+9 -7
View File
@@ -507,8 +507,8 @@ credential state 调度,不以是否存在上游用户 ID 推断可用性;OA
状态恢复和未补齐身份的凭据轮换保留 `unknown`。
API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。
OpenAI 模型目录用于发现;原生 OAuth Responses 还使用新鲜套餐目录筛选已知模型的候选账号。
未知目录、未知套餐或未收录模型保留上游验证;管理员配置的模型权限仍由 Core 与选号链路执行
OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth 按套餐、API Key 按账号与凭据版本匹配目录。
未知目录或未知套餐保留上游验证;管理员配置的模型权限仍由 Core 与选号链路执行
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
@@ -734,12 +734,14 @@ OpenAI Provider 在选号时排除 Grok 模型的 ChatGPT OAuth 账号,API Key
OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐限制,Images 和 Live 共用 OAuth 套餐检查。
资格检查复用账号持久事实,在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一检查
原生 OAuth Responses 在评分、亲和和租约前按映射后的模型筛选套餐候选:目录快照未满 30 分钟,
且模型已在已知 OAuth 套餐中出现时,排除成功读取目录但未包含该模型的套餐。未知或读取失败的套餐、
过期快照与未收录模型继续交由上游判断;热路径只读进程缓存,不请求上游目录。
原生 Responses 明确返回模型不支持时,通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。
原生 Responses 在评分、亲和和租约前将目录资格与冻结的模型政策取交集:有效目录未包含请求模型时排除账号,
目录包含模型不能解除管理员限制。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。
OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。
未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格
原生 OpenAI Responses 明确返回模型不支持时,通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。
该反馈不冻结整个账号,不改写管理员模型政策,也不占用 WS 传输恢复预算;重试仍遵守交付边界与原生续写固定账号约束。
管理员诊断旁路这两项限制,受管上游适配器不继承原生目录资格或原生模型拒绝冷却
管理员诊断旁路目录资格与模型拒绝冷却,OpenAI 的受管上游适配器不继承这两项原生限制
账号编辑在一个事务中更新调度事实,批量更新只应用显式提供的字段。导入与首次 OAuth 可携带统一账号设置,由 Admin 传递给 Store,
与凭据在同一事务中提交;Provider 仍独占凭据解析。未附带设置的导入、重新授权和后台刷新保留已有分组、