diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index 537638978..933b8a223 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -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::>(); 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() diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index 97134e17e..0020351c6 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -656,7 +656,7 @@ impl CodexCredentialSelector { }) { continue; } - // 受管适配器拥有其上游模型合同,不套用官方 OAuth 目录与拒绝反馈 + // 受管适配器拥有其上游模型合同,不套用原生目录与拒绝反馈 if adapter.is_none() { if catalog_excluded.contains(account.id()) { catalog_rejected += 1; diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 4edfcbcc4..8009166d7 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -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!( 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 fc5d20b14..ccac63c98 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,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::>() + }))) + .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::>() + }))) + .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] diff --git a/backend/crates/providers/xai/src/credential/selector.rs b/backend/crates/providers/xai/src/credential/selector.rs index a7f8f5436..1a043709d 100644 --- a/backend/crates/providers/xai/src/credential/selector.rs +++ b/backend/crates/providers/xai/src/credential/selector.rs @@ -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::>(); let unique_scopes = account_scopes .iter() diff --git a/backend/crates/providers/xai/tests/credential/selector.rs b/backend/crates/providers/xai/tests/credential/selector.rs index 6529d5f77..4a5ae2154 100644 --- a/backend/crates/providers/xai/tests/credential/selector.rs +++ b/backend/crates/providers/xai/tests/credential/selector.rs @@ -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; diff --git a/docs/api.md b/docs/api.md index 7622ff1ff..90774de75 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象 diff --git a/docs/architecture.md b/docs/architecture.md index 1d2956eb7..62b398043 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 仍独占凭据解析。未附带设置的导入、重新授权和后台刷新保留已有分组、