mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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!(
|
||||
|
||||
+283
-88
@@ -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
@@ -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` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
|
||||
|
||||
|
||||
@@ -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 仍独占凭据解析。未附带设置的导入、重新授权和后台刷新保留已有分组、
|
||||
|
||||
Reference in New Issue
Block a user