diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index 4417652e5..2544e9083 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -9,10 +9,10 @@ use gateway_core::error::ErrorSource; use gateway_core::account::{ AccountAffinity, AccountCandidate, AccountCapacitySnapshot, AccountErrorReason, - AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals, AccountSchedulingBlocker, - AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus, - CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, QuotaEvidence, - RotationStrategy, + AccountFeedbackStats, AccountModelAccessMode, AccountQuotaSignals, AccountRuntimeSignals, + AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector, + AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, + QuotaEvidence, RotationStrategy, }; use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection, WaitPriority}; use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError}; @@ -635,10 +635,19 @@ impl CodexCredentialSelector { }) .collect::>(); let mut eligible = Vec::with_capacity(accounts.len()); - let catalog_excluded = match model { - CodexSelectionModel::Requested(model) if !diagnostic => self - .catalog - .scheduling_exclusions(&accounts, model, SystemTime::now()), + let catalog_excluded = match (model, request.attempt.account_scope()) { + (CodexSelectionModel::Requested(model), Some(scope)) if !diagnostic => { + // 候选已通过冻结的模型政策,白名单精确命中的 ID 交给上游验证 + let mut excluded = + self.catalog + .scheduling_exclusions(&accounts, model, SystemTime::now()); + excluded.retain(|id| { + scope.directory().account(id).is_none_or(|account| { + account.model_access().mode() != AccountModelAccessMode::Allowlist + }) + }); + excluded + } _ => Default::default(), }; let mut catalog_rejected = 0_usize; 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 cfff837c2..592b53a1a 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 @@ -141,8 +141,11 @@ async fn unlisted_models_remain_available_without_catalog_discovery() { } #[tokio::test] -async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_accounts() { - use gateway_core::account::{AccountModelAccess, AccountModelAccessMode}; +async fn account_allowlists_override_catalog_omissions_but_weight_affinity_and_pinning_do_not() { + use gateway_core::account::{ + AccountModelAccess, + AccountModelAccessMode::{All, Allowlist, Denylist}, + }; use provider_openai::credential::ResponsesTransport; for (primary_api, secondary_api) in [(false, false), (true, true), (true, false), (false, true)] @@ -264,52 +267,45 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a } } 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 primary = "acct_provider_contract"; + let secondary = "acct_scope_new"; + // 目录外许可只属于明确添加该模型的账号,不能借用同池账号的白名单 + for (model, required, primary_mode, secondary_mode, expected) in [ + ("gpt-6-astra", Some(secondary), All, All, None), + ("custom-unlisted", None, All, All, None), + ("gpt-6.1-sol", None, Allowlist, All, Some(primary)), + ("gpt-6-astra", None, Denylist, Allowlist, Some(secondary)), + ( + "custom-unlisted", + None, + Denylist, + Allowlist, + Some(secondary), + ), + ("gpt-6-luna", Some(primary), Denylist, Allowlist, None), ] { - 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 accounts = [(primary, primary_mode), (secondary, secondary_mode)] + .into_iter() + .map(|(id, mode)| { + let access = if mode == All { + AccountModelAccess::all() + } else { + AccountModelAccess::new(mode, vec![model.to_owned()]).unwrap() + }; + ( + ProviderAccountId::new(id).unwrap(), + RuntimeAccount::new(ProviderKind::new("openai").unwrap(), BTreeSet::new()) + .with_model_access(access), + ) + }) + .collect(); + let scope = Arc::new(FrozenAccountScope::new( + Arc::new(RuntimeAccountDirectory::new(accounts)), + ClientRoutingScope::all_accounts(), + )); let attempt = AttemptContext::new( RequestAttemptContext::new( - ModelRequestId::new("req_catalog_rejected").unwrap(), + ModelRequestId::new("req_account_catalog_policy").unwrap(), ClientApiKeyId::new("key_openai_contract").unwrap(), ), NonZeroU32::MIN, @@ -325,19 +321,26 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a CancellationToken::new(), ); let acquisitions = leases.requests.lock().unwrap().len(); - let error = provider + let result = 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); + .await; + if let Some(expected) = expected { + let mut stream = result.unwrap(); + assert_eq!(stream.metadata().provider_account_id().as_str(), expected); + while let Some(event) = stream.next().await { + event.unwrap(); + } + } else { + let error = result.err().expect("account must be excluded"); + assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount); + assert_eq!(leases.requests.lock().unwrap().len(), acquisitions); + } } - let sent = native + let sent: Vec<_> = native .received_requests() .await .unwrap() @@ -345,8 +348,16 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a .chain(api_primary.received_requests().await.unwrap()) .chain(api_secondary.received_requests().await.unwrap()) .filter(|request| request.method == "POST") - .count(); - assert_eq!(sent, 2); + .map(|request| { + captured_request_body(&request)["model"] + .as_str() + .unwrap() + .to_owned() + }) + .collect(); + assert_eq!(sent.len(), 5); + assert!(sent.iter().any(|model| model == "gpt-6.1-sol")); + assert!(sent.iter().any(|model| model == "custom-unlisted")); } } diff --git a/docs/api.md b/docs/api.md index e28b2db71..d58b911e5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -579,7 +579,7 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用 已知模型只路由到明确提供该模型的 Provider,账号不可用时不转发给其他平台的未知模型通道。 未知模型只在当前范围与路由约束下存在唯一 Provider 透传候选时发送;无法确定归属时返回不可用错误, -可通过 Client Key 分组限定目标平台。OpenAI OAuth、API Key 上游和 xAI 账号均叠加有效目录资格与账号模型政策, +可通过 Client Key 分组限定目标平台。Provider 在选号时检查账号模型政策与目录资格, 具体边界见[账号模型限制](#账号模型限制);ChatGPT OAuth 账号不接受 Grok 模型 目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的 @@ -1226,14 +1226,16 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota` 最多 256 项,每个 ID 最多 256 字节;重复 ID 去重,不接受空白、控制字符、`__` 前缀或 `*` 通配符。 允许保存当前上游目录尚未返回的 ID。限制按全局模型映射后的上游 ID 判断,不扩大账号本身的上游权限。 -管理端刷新目录只更新目录,不修改模型限制。白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项, +管理端刷新目录只更新目录,不修改模型限制。全选与取消全选只作用于上游目录中符合当前搜索条件的模型。 +白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项, 由管理员确认后从当前表单移除,保存账号后生效。目录未就绪、刷新失败或返回空目录时不能清理,清理也不能将白名单置空。 图像、搜索、插件适配模型和映射目标可能不在目录中,目录缺失本身不代表模型失效。 例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流; Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则 -原生 Responses 选号同时检查有效模型目录与 `modelAccess`,OAuth、API Key 上游和 xAI 使用相同资格规则: -有效目录未包含请求模型时排除该账号,目录包含模型也不能绕过禁用或黑白名单;权重、亲和和固定账号均不能放宽资格。 +原生 Responses 默认排除有效目录未列出目标模型的账号。OpenAI OAuth 或 API Key 账号的白名单精确包含映射后的上游 ID 时, +允许通过该账号请求目录外模型,由上游确认是否可用。此设置只对配置它的账号生效,已有白名单同样适用。 +账号禁用、Key 分组、认证类型、黑名单和模型冷却仍参与选号。xAI 账号使用有效套餐目录与账号模型政策的交集。 OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。 OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess` @@ -1251,7 +1253,7 @@ OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐 Images 和 Search 按原始请求正文的模型 ID 检查,不依赖模型目录或全局模型映射;可手动添加目录未列出的模型 ID。 模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。 Search 的选号限制不约束插件在选号后主动改写正文。 -管理员连接测试不受账号模型政策、目录资格筛选与模型拒绝冷却限制;连接测试成功只证明指定账号的上游能力。 +管理员连接测试的选项包含上游目录和账号白名单中的 ID。连接测试跳过普通选号的模型政策、目录资格筛选与模型拒绝冷却,成功表示指定账号可调用测试使用的端点与模型。 普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示; 同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象 diff --git a/docs/architecture.md b/docs/architecture.md index c8abe97a1..39a8e7f7e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -517,7 +517,7 @@ credential state 调度,不以是否存在上游用户 ID 推断可用性;OA API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。 OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。 OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth 按套餐、API Key 按账号与凭据版本匹配目录。 -未知目录或未知套餐保留上游验证;管理员配置的模型权限仍由 Core 与选号链路执行 +目录与账号白名单的优先级见[路由与账号范围](#6-路由账号范围与-continuation) - OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义; 业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。 @@ -747,8 +747,8 @@ OpenAI Provider 在选号时排除 Grok 模型的 ChatGPT OAuth 账号,API Key OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐限制,Images 和 Live 共用 OAuth 套餐检查。 资格检查复用账号持久事实,在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一检查 -原生 Responses 在评分、亲和和租约前将目录资格与冻结的模型政策取交集:有效目录未包含请求模型时排除账号, -目录包含模型不能解除管理员限制。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。 +原生 Responses 在评分、亲和和租约前检查冻结的账号模型政策。OpenAI 账号白名单精确命中目标 ID 时允许上游验证, +其余 OpenAI 账号及 xAI 账号按有效目录排除未列出的模型。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。 OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。 未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格 diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index 6c163e04b..cdff5f11e 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -186,19 +186,21 @@ "reserve": "Reserve", "allowlist": "Allowlist", "blocklist": "Blocklist", - "modelCatalog": "Model catalog", "refreshModel": "Refresh model", - "modelRestrictionMode": "Model restriction mode", + "modelRestrictionMode": "Model restrictions", "catalogHelpTitle": "Model catalog details", "catalogObservedAtHelp": "The time shows when the upstream catalog was last successfully fetched", "catalogDescription": "The catalog lists model IDs, verify endpoint support with an actual request — a connection test only checks the endpoint it uses", "searchOrAddModels": "Search or add models", - "searchOrEnterIdAndPressEnter": "Search or enter ID and press Enter to add", - "selectModel": "Select model", - "notInCurrentModelCatalog": "{id} (not in the current model catalog)", - "modelCatalogMissingHint": "The current upstream catalog does not list this model", + "searchOrEnterId": "Search models or enter a full ID", + "addModel": "Add model", + "customModels": "Custom", + "selectedModels": "Selected models", + "upstreamCatalog": "Upstream catalog", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "addToDenylistHint": "Saving blocks this ID on the account", "loadingModel": "Loading model", - "noMatchingModels": "No matching models", "noModels": "No models", "selectOrAddAtLeastOneModel": "Please select or add at least one model", "configureUpToModels": "Configure up to 256 models", diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index bc9f9560a..b9a79bd27 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -186,19 +186,21 @@ "reserve": "保留", "allowlist": "白名单", "blocklist": "黑名单", - "modelCatalog": "模型目录", "refreshModel": "刷新模型", - "modelRestrictionMode": "模型限制模式", + "modelRestrictionMode": "模型限制", "catalogHelpTitle": "模型目录说明", "catalogObservedAtHelp": "时间表示最近一次成功获取上游目录的时间", "catalogDescription": "目录仅提供模型 ID,端点能力请通过实际请求确认,连接测试仅验证测试所用端点", "searchOrAddModels": "搜索或添加模型", - "searchOrEnterIdAndPressEnter": "搜索或输入 ID,回车添加", - "selectModel": "选择模型", - "notInCurrentModelCatalog": "{id}(当前目录未返回)", - "modelCatalogMissingHint": "当前上游目录未返回此模型", + "searchOrEnterId": "搜索模型或输入完整 ID", + "addModel": "添加模型", + "customModels": "自定义", + "selectedModels": "已选模型", + "upstreamCatalog": "上游目录", + "selectAll": "全选", + "deselectAll": "取消全选", + "addToDenylistHint": "保存后,此账号将拒绝该模型", "loadingModel": "加载模型中", - "noMatchingModels": "无匹配模型", "noModels": "暂无模型", "selectOrAddAtLeastOneModel": "请至少选择或添加一个模型", "configureUpToModels": "最多配置 256 个模型", diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index 0dbfbd295..473990ac8 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -186,19 +186,21 @@ "reserve": "保留", "allowlist": "白名單", "blocklist": "黑名單", - "modelCatalog": "模型目錄", "refreshModel": "刷新模型", - "modelRestrictionMode": "模型限制模式", + "modelRestrictionMode": "模型限制", "catalogHelpTitle": "模型目錄說明", "catalogObservedAtHelp": "時間表示最近一次成功取得上游目錄的時間", "catalogDescription": "目錄僅提供模型 ID,端點能力請透過實際請求確認,連線測試僅驗證測試所用端點", "searchOrAddModels": "搜索或添加模型", - "searchOrEnterIdAndPressEnter": "搜索或輸入 ID,回車添加", - "selectModel": "選擇模型", - "notInCurrentModelCatalog": "{id}(當前目錄未返回)", - "modelCatalogMissingHint": "當前上游目錄未返回此模型", + "searchOrEnterId": "搜索模型或輸入完整 ID", + "addModel": "添加模型", + "customModels": "自定義", + "selectedModels": "已選模型", + "upstreamCatalog": "上游目錄", + "selectAll": "全選", + "deselectAll": "取消全選", + "addToDenylistHint": "保存後,此賬號將拒絕該模型", "loadingModel": "加載模型中", - "noMatchingModels": "無匹配模型", "noModels": "暫無模型", "selectOrAddAtLeastOneModel": "請至少選擇或添加一個模型", "configureUpToModels": "最多配置 256 個模型", diff --git a/frontend/src/views/accounts/components/AccountEditModal.vue b/frontend/src/views/accounts/components/AccountEditModal.vue index dbb5eebee..6603322cf 100644 --- a/frontend/src/views/accounts/components/AccountEditModal.vue +++ b/frontend/src/views/accounts/components/AccountEditModal.vue @@ -82,7 +82,7 @@ async function requestSave() { @@ -127,7 +127,7 @@ async function requestSave() { > diff --git a/frontend/src/views/accounts/components/AccountModelAccessField.vue b/frontend/src/views/accounts/components/AccountModelAccessField.vue index 169099fbf..dfd5ebc5f 100644 --- a/frontend/src/views/accounts/components/AccountModelAccessField.vue +++ b/frontend/src/views/accounts/components/AccountModelAccessField.vue @@ -1,9 +1,9 @@