mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix(routing): bind known models to their providers
Preserve known model ownership when provider catalog refreshes fail. Reject ambiguous unknown-provider routes and exclude ChatGPT OAuth accounts from Grok requests while preserving API key passthrough. Refs #391 Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -276,7 +276,7 @@ impl RuntimeSnapshotCompiler {
|
||||
|
||||
/// 读取一个 revision,并为已注册 Provider 查询实时模型目录
|
||||
pub async fn compile(&self) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
|
||||
self.compile_inner(None).await
|
||||
self.compile_inner(None, true).await
|
||||
}
|
||||
|
||||
/// 配置提交复用同一扩展集合的已发布目录,目录留待对账刷新
|
||||
@@ -284,12 +284,21 @@ impl RuntimeSnapshotCompiler {
|
||||
&self,
|
||||
previous: &RuntimeSnapshot,
|
||||
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
|
||||
self.compile_inner(Some(previous)).await
|
||||
self.compile_inner(Some(previous), false).await
|
||||
}
|
||||
|
||||
/// 刷新目录,单个 Provider 读取失败时保留同一扩展集合的已知模型归属
|
||||
pub(crate) async fn compile_with_catalog_fallback(
|
||||
&self,
|
||||
previous: &RuntimeSnapshot,
|
||||
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
|
||||
self.compile_inner(Some(previous), true).await
|
||||
}
|
||||
|
||||
async fn compile_inner(
|
||||
&self,
|
||||
previous: Option<&RuntimeSnapshot>,
|
||||
refresh_catalog: bool,
|
||||
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
|
||||
// 同一配置 revision 的重试复用扩展候选,保留插件策略的发布一致性
|
||||
let mut prepared_extensions: Option<(
|
||||
@@ -338,6 +347,7 @@ impl RuntimeSnapshotCompiler {
|
||||
catalogs.as_ref(),
|
||||
provider_kinds,
|
||||
cached,
|
||||
refresh_catalog,
|
||||
self.privacy_compiler.clone(),
|
||||
)
|
||||
.await?;
|
||||
@@ -354,7 +364,7 @@ impl RuntimeSnapshotCompiler {
|
||||
return Err(RuntimeSnapshotCompileError::RevisionChanged);
|
||||
}
|
||||
let snapshot = snapshot
|
||||
.with_provider_catalog_generations(if cached.is_some() {
|
||||
.with_provider_catalog_generations(if cached.is_some() && !refresh_catalog {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
observed_generations
|
||||
@@ -373,14 +383,20 @@ async fn compile_runtime_snapshot(
|
||||
catalogs: &dyn ProviderCatalogPort,
|
||||
provider_kinds: Vec<ProviderKind>,
|
||||
previous: Option<&RuntimeSnapshot>,
|
||||
refresh_catalog: bool,
|
||||
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
|
||||
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
|
||||
// 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证
|
||||
// 目录读取失败不能抹掉已知归属;成功返回空目录则仍是权威的模型撤回
|
||||
let mut provider_models = Vec::new();
|
||||
let mut catalog_accounts = BTreeMap::new();
|
||||
let mut exhaustive_provider_catalogs = BTreeSet::new();
|
||||
for provider in &provider_kinds {
|
||||
if let Some(previous) = previous {
|
||||
let models = if refresh_catalog || previous.is_none() {
|
||||
catalogs.query_model_capabilities(provider).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(previous) = previous.filter(|_| models.is_none()) {
|
||||
if let Some(accounts) = previous.model_catalog_accounts.get(provider) {
|
||||
catalog_accounts.insert(provider.clone(), accounts.clone());
|
||||
}
|
||||
@@ -403,7 +419,7 @@ async fn compile_runtime_snapshot(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Ok(models) = catalogs.query_model_capabilities(provider).await else {
|
||||
let Some(models) = models else {
|
||||
continue;
|
||||
};
|
||||
if catalogs.model_catalog_is_exhaustive(provider) {
|
||||
@@ -946,7 +962,7 @@ impl RuntimeSnapshot {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 完整目录按映射后的模型判定;发现型或不可用目录不作为能力白名单
|
||||
/// 已知模型只属于明确声明它的 Provider;未知模型仍可由非完整目录透传
|
||||
#[must_use]
|
||||
pub fn contains_public_model_for_provider(
|
||||
&self,
|
||||
@@ -962,13 +978,22 @@ impl RuntimeSnapshot {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.exhaustive_provider_catalogs.contains(provider) {
|
||||
return true;
|
||||
}
|
||||
let upstream_model = self.mapped_model(public_model.as_str());
|
||||
let Ok(upstream_model) =
|
||||
UpstreamModelId::from_client_wire(self.mapped_model(public_model.as_str()))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
self.provider_models
|
||||
.get(provider)
|
||||
.is_some_and(|models| models.keys().any(|model| model.as_str() == upstream_model))
|
||||
.is_some_and(|models| models.contains_key(&upstream_model))
|
||||
|| (!self.exhaustive_provider_catalogs.contains(provider)
|
||||
&& !self.is_known_upstream_model(&upstream_model))
|
||||
}
|
||||
|
||||
fn is_known_upstream_model(&self, upstream_model: &UpstreamModelId) -> bool {
|
||||
self.provider_models
|
||||
.values()
|
||||
.any(|models| models.contains_key(upstream_model))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -1101,12 +1126,28 @@ impl RuntimeSnapshot {
|
||||
) -> Result<RoutingPlan, RoutingError> {
|
||||
let requirements = operation.capability_requirements();
|
||||
let mut candidates = Vec::new();
|
||||
let mut unlisted_candidates = Vec::new();
|
||||
|
||||
if context.required_provider.is_none() && account_scope.provider_kinds().is_empty() {
|
||||
return Err(RoutingError::EmptyAccountScope);
|
||||
}
|
||||
|
||||
let requested_model = public_model.as_str();
|
||||
let mapped_model = self.mapped_model(requested_model);
|
||||
let upstream_model = if self
|
||||
.settings
|
||||
.values
|
||||
.model_mappings
|
||||
.contains_key(requested_model)
|
||||
|| self.model_alias(requested_model).is_some()
|
||||
{
|
||||
UpstreamModelId::new(mapped_model)
|
||||
} else {
|
||||
UpstreamModelId::from_client_wire(mapped_model)
|
||||
}
|
||||
.map_err(|_| RoutingError::InvalidIdentifier)?;
|
||||
// 归属先于账号范围、能力与路由约束判定,原 Provider 被排除不能变成跨平台透传
|
||||
let known_model = self.is_known_upstream_model(&upstream_model);
|
||||
|
||||
let providers = context.required_provider.as_ref().map_or_else(
|
||||
|| account_scope.provider_kinds().clone(),
|
||||
|provider| BTreeSet::from([provider.clone()]),
|
||||
@@ -1129,20 +1170,6 @@ impl RuntimeSnapshot {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let requested_model = public_model.as_str();
|
||||
let mapped_model = self.mapped_model(requested_model);
|
||||
let upstream_model = if self
|
||||
.settings
|
||||
.values
|
||||
.model_mappings
|
||||
.contains_key(requested_model)
|
||||
|| self.model_alias(requested_model).is_some()
|
||||
{
|
||||
UpstreamModelId::new(mapped_model)
|
||||
} else {
|
||||
UpstreamModelId::from_client_wire(mapped_model)
|
||||
}
|
||||
.map_err(|_| RoutingError::InvalidIdentifier)?;
|
||||
// 强制 Provider 路由也不能扩大 Key 授权;管理诊断的目标账号由探测入口固定
|
||||
if enforce_account_model_policy
|
||||
&& !account_scope.allows_provider_model(provider, upstream_model.as_str())
|
||||
@@ -1160,28 +1187,30 @@ impl RuntimeSnapshot {
|
||||
};
|
||||
emulated
|
||||
}
|
||||
None if self.exhaustive_provider_catalogs.contains(provider) => continue,
|
||||
None if known_model || self.exhaustive_provider_catalogs.contains(provider) => {
|
||||
continue;
|
||||
}
|
||||
None => BTreeSet::new(),
|
||||
};
|
||||
let candidate = ProviderCandidate {
|
||||
candidates.push(ProviderCandidate {
|
||||
provider: provider.clone(),
|
||||
model_presentation: self
|
||||
.provider_model_presentations
|
||||
.get(provider)
|
||||
.and_then(|models| models.get(&upstream_model))
|
||||
.cloned(),
|
||||
upstream_model: Some(upstream_model),
|
||||
upstream_model: Some(upstream_model.clone()),
|
||||
emulated_features,
|
||||
account_scope: Arc::clone(&account_scope),
|
||||
};
|
||||
if capabilities.is_some() {
|
||||
candidates.push(candidate);
|
||||
} else {
|
||||
unlisted_candidates.push(candidate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 冷启动或目录不可读时不能按 Provider 名称猜测模型归属
|
||||
if !known_model && candidates.len() > 1 {
|
||||
return Err(RoutingError::NoCapableProvider {
|
||||
model: public_model.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
// 已知模型优先;发现型目录的未知模型仍可透传,但不能抢在明确匹配的 Provider 前面
|
||||
candidates.extend(unlisted_candidates);
|
||||
|
||||
if candidates.is_empty() {
|
||||
// 模型存在性必须包含被请求路由限制排除的 Provider;目录未知时不能断言模型不存在
|
||||
|
||||
@@ -234,7 +234,14 @@ impl RuntimeSnapshotPublisher {
|
||||
None => self.compiler.compile().await,
|
||||
}
|
||||
} else {
|
||||
preempt_for_committed_refresh(cancellation, self.compiler.compile()).await?
|
||||
let previous = self.snapshots.snapshot_for_diagnostics();
|
||||
preempt_for_committed_refresh(cancellation, async {
|
||||
match previous {
|
||||
Some(previous) => self.compiler.compile_with_catalog_fallback(&previous).await,
|
||||
None => self.compiler.compile().await,
|
||||
}
|
||||
})
|
||||
.await?
|
||||
};
|
||||
let snapshot = match compiled {
|
||||
Ok(snapshot) => snapshot,
|
||||
|
||||
@@ -282,7 +282,9 @@ impl ProviderCatalogPort for TestCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
struct MixedCatalog;
|
||||
struct MixedCatalog {
|
||||
xai_available: bool,
|
||||
}
|
||||
|
||||
impl ProviderCatalogPort for MixedCatalog {
|
||||
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
|
||||
@@ -307,7 +309,7 @@ impl ProviderCatalogPort for MixedCatalog {
|
||||
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>> {
|
||||
let model = match provider.as_str() {
|
||||
"openai" => "gpt-5.5",
|
||||
"xai" => "grok-4.7",
|
||||
"xai" if self.xai_available => "grok-4.7",
|
||||
_ => return Box::pin(async { Err(ProviderCatalogUnavailable) }),
|
||||
};
|
||||
Box::pin(async move {
|
||||
@@ -325,6 +327,10 @@ impl ProviderCatalogPort for MixedCatalog {
|
||||
}
|
||||
|
||||
fn mixed_catalog_snapshot() -> gateway_core::routing::RuntimeSnapshot {
|
||||
mixed_catalog_snapshot_with_xai(true)
|
||||
}
|
||||
|
||||
fn mixed_catalog_snapshot_with_xai(xai_available: bool) -> gateway_core::routing::RuntimeSnapshot {
|
||||
let facts = SnapshotFacts::new(
|
||||
revision(1),
|
||||
revision(1),
|
||||
@@ -346,7 +352,7 @@ fn mixed_catalog_snapshot() -> gateway_core::routing::RuntimeSnapshot {
|
||||
block_on(
|
||||
RuntimeSnapshotCompiler::new(
|
||||
Arc::new(TestSnapshotStore::new(Ok(facts))),
|
||||
Arc::new(MixedCatalog),
|
||||
Arc::new(MixedCatalog { xai_available }),
|
||||
)
|
||||
.compile(),
|
||||
)
|
||||
@@ -678,7 +684,7 @@ fn discovery_catalog_should_not_reject_an_unlisted_model() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_catalog_should_prefer_known_model_over_discovery_fallback() {
|
||||
fn mixed_catalog_should_bind_known_model_to_its_declared_provider() {
|
||||
let snapshot = mixed_catalog_snapshot();
|
||||
let model = PublicModelId::new("grok-4.7").unwrap();
|
||||
assert!(
|
||||
@@ -701,12 +707,12 @@ fn mixed_catalog_should_prefer_known_model_over_discovery_fallback() {
|
||||
.iter()
|
||||
.map(|candidate| candidate.provider().as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["xai", "openai"]
|
||||
["xai"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_catalog_should_prioritize_the_mapped_upstream_model() {
|
||||
fn mixed_catalog_should_bind_the_mapped_upstream_model() {
|
||||
let snapshot = mixed_catalog_snapshot();
|
||||
let settings = snapshot
|
||||
.settings()
|
||||
@@ -726,6 +732,7 @@ fn mixed_catalog_should_prioritize_the_mapped_upstream_model() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.candidates().len(), 1);
|
||||
assert_eq!(plan.candidates()[0].provider().as_str(), "xai");
|
||||
assert_eq!(
|
||||
plan.candidates()[0].upstream_model().unwrap().as_str(),
|
||||
@@ -771,41 +778,106 @@ fn mixed_catalog_should_preserve_provider_order_for_shared_models() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_catalog_should_respect_required_provider_before_model_priority() {
|
||||
fn mixed_catalog_should_reject_forcing_a_model_to_another_provider() {
|
||||
let snapshot = mixed_catalog_snapshot();
|
||||
let plan = snapshot
|
||||
.plan(
|
||||
&PublicModelId::new("grok-4.7").unwrap(),
|
||||
&super::operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&gateway_core::routing::RoutingContext {
|
||||
required_provider: Some(ProviderKind::new("openai").unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let plan = snapshot.plan(
|
||||
&PublicModelId::new("grok-4.7").unwrap(),
|
||||
&super::operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&gateway_core::routing::RoutingContext {
|
||||
required_provider: Some(ProviderKind::new("openai").unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(plan.candidates().len(), 1);
|
||||
assert_eq!(plan.candidates()[0].provider().as_str(), "openai");
|
||||
assert!(matches!(
|
||||
plan,
|
||||
Err(gateway_core::validation::RoutingError::NoCapableProvider { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_catalog_should_respect_blocked_provider_before_model_priority() {
|
||||
fn mixed_catalog_should_not_escape_a_blocked_model_provider() {
|
||||
let snapshot = mixed_catalog_snapshot();
|
||||
let plan = snapshot.plan(
|
||||
&PublicModelId::new("grok-4.7").unwrap(),
|
||||
&super::operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&gateway_core::routing::RoutingContext {
|
||||
blocked_providers: std::collections::BTreeSet::from(
|
||||
[ProviderKind::new("xai").unwrap()],
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
plan,
|
||||
Err(gateway_core::validation::RoutingError::NoCapableProvider { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_catalog_should_not_escape_model_capability_requirements() {
|
||||
let snapshot = mixed_catalog_snapshot();
|
||||
let plan = snapshot.plan(
|
||||
&PublicModelId::new("grok-4.7").unwrap(),
|
||||
&super::image_operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&Default::default(),
|
||||
);
|
||||
assert!(matches!(
|
||||
plan,
|
||||
Err(gateway_core::validation::RoutingError::NoCapableProvider { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_catalog_should_not_escape_the_authorized_account_scope() {
|
||||
let snapshot = mixed_catalog_snapshot();
|
||||
let scope = Arc::new(gateway_core::routing::FrozenAccountScope::new(
|
||||
Arc::new(gateway_core::routing::RuntimeAccountDirectory::new(
|
||||
BTreeMap::from([(
|
||||
gateway_core::account::ProviderAccountId::new("acct_openai").unwrap(),
|
||||
gateway_core::routing::RuntimeAccount::new(
|
||||
ProviderKind::new("openai").unwrap(),
|
||||
Default::default(),
|
||||
),
|
||||
)]),
|
||||
)),
|
||||
gateway_core::routing::ClientRoutingScope::all_accounts(),
|
||||
));
|
||||
let model = PublicModelId::new("grok-4.7").unwrap();
|
||||
assert!(!snapshot.contains_public_model_for_scope(&model, &scope));
|
||||
let plan = snapshot.plan(&model, &super::operation(), scope, &Default::default());
|
||||
assert!(matches!(
|
||||
plan,
|
||||
Err(gateway_core::validation::RoutingError::NoCapableProvider { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_catalog_should_reject_ambiguous_models_when_xai_catalog_is_unavailable() {
|
||||
let snapshot = mixed_catalog_snapshot_with_xai(false);
|
||||
let plan = snapshot.plan(
|
||||
&PublicModelId::new("grok-4.7").unwrap(),
|
||||
&super::operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&Default::default(),
|
||||
);
|
||||
assert!(matches!(
|
||||
plan,
|
||||
Err(gateway_core::validation::RoutingError::NoCapableProvider { .. })
|
||||
));
|
||||
|
||||
let plan = snapshot
|
||||
.plan(
|
||||
&PublicModelId::new("grok-4.7").unwrap(),
|
||||
&PublicModelId::new("gpt-5.5").unwrap(),
|
||||
&super::operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&gateway_core::routing::RoutingContext {
|
||||
blocked_providers: std::collections::BTreeSet::from([
|
||||
ProviderKind::new("xai").unwrap()
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.candidates().len(), 1);
|
||||
assert_eq!(plan.candidates()[0].provider().as_str(), "openai");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
mod publications;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use futures::channel::{mpsc, oneshot};
|
||||
@@ -612,6 +612,85 @@ fn catalog_only_reconciliation_should_keep_snapshot_on_failure_and_retry_generat
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_failure_should_preserve_model_facts_without_restoring_revoked_accounts() {
|
||||
block_on(async {
|
||||
let catalog = Arc::new(TestCatalog::default());
|
||||
let provider = ProviderKind::new("audit").unwrap();
|
||||
let model = gateway_core::routing::PublicModelId::new("audit-model").unwrap();
|
||||
catalog.models.lock().unwrap().push(
|
||||
ProviderModelCapabilities::new(
|
||||
UpstreamModelId::new("audit-model").unwrap(),
|
||||
ModelCapabilities::new(
|
||||
std::collections::BTreeSet::from([
|
||||
gateway_core::operation::OperationKind::Generate,
|
||||
]),
|
||||
Some(16_000),
|
||||
),
|
||||
)
|
||||
.with_presentation(gateway_core::routing::ModelPresentation::new(
|
||||
Some("Audit model".to_owned()),
|
||||
None,
|
||||
))
|
||||
.with_catalog_accounts(std::collections::BTreeSet::from([ProviderAccountId::new(
|
||||
"acct_audit_removed",
|
||||
)
|
||||
.unwrap()])),
|
||||
);
|
||||
let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(10, true))));
|
||||
let handle = RuntimeSnapshotHandle::default();
|
||||
let publisher = RuntimeSnapshotPublisher::new(
|
||||
Arc::new(catalog_compiler(store.clone(), catalog.clone())),
|
||||
handle.clone(),
|
||||
Arc::new(TestSnapshotSubscriptions::default()),
|
||||
);
|
||||
publisher.refresh().await.unwrap();
|
||||
let initial = handle.acquire().unwrap();
|
||||
let initial_profiles = initial.public_model_profiles_for_provider(&provider);
|
||||
|
||||
catalog.unavailable.store(true, Ordering::SeqCst);
|
||||
catalog.generation.store(1, Ordering::SeqCst);
|
||||
*store.facts.lock().unwrap() = Ok(scoped_facts(11, false));
|
||||
*store.current_revision.lock().unwrap() = Ok(revision(11));
|
||||
publisher.refresh().await.unwrap();
|
||||
|
||||
let retained = handle.acquire().unwrap();
|
||||
assert_eq!(retained.revision(), revision(11));
|
||||
assert!(!removed_account_allowed(&retained));
|
||||
assert_eq!(
|
||||
retained.public_model_profiles_for_provider(&provider),
|
||||
initial_profiles
|
||||
);
|
||||
assert!(retained.contains_public_model_for_provider(&model, &provider));
|
||||
assert!(!retained.contains_public_model_for_provider(
|
||||
&gateway_core::routing::PublicModelId::new("unlisted-model").unwrap(),
|
||||
&provider,
|
||||
));
|
||||
assert!(retained.public_models_for_scope(
|
||||
retained.client_policies().next().unwrap().account_scope(),
|
||||
).is_empty());
|
||||
assert!(removed_account_allowed(&initial));
|
||||
assert_eq!(
|
||||
retained.provider_catalog_generations(),
|
||||
&catalog.catalog_generations()
|
||||
);
|
||||
|
||||
// 上游恢复并明确撤回模型后,旧目录不能继续作为已知归属
|
||||
catalog.unavailable.store(false, Ordering::SeqCst);
|
||||
catalog.models.lock().unwrap().clear();
|
||||
catalog.generation.store(2, Ordering::SeqCst);
|
||||
let (task, context) = reconciliation_task(&publisher);
|
||||
task.run_cycle(context).await.unwrap();
|
||||
let refreshed = handle.acquire().unwrap();
|
||||
assert!(refreshed.public_models_for_provider(&provider).is_empty());
|
||||
assert!(!refreshed.contains_public_model_for_provider(&model, &provider));
|
||||
assert_eq!(
|
||||
refreshed.provider_catalog_generations(),
|
||||
&catalog.catalog_generations()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_inflight_refresh_should_release_publication_for_next_refresh() {
|
||||
block_on(async {
|
||||
@@ -688,6 +767,7 @@ fn reconciliation_task(
|
||||
#[derive(Default)]
|
||||
struct TestCatalog {
|
||||
generation: AtomicU64,
|
||||
unavailable: AtomicBool,
|
||||
queries: AtomicUsize,
|
||||
next_query: Mutex<Option<oneshot::Receiver<()>>>,
|
||||
models: Mutex<Vec<ProviderModelCapabilities>>,
|
||||
@@ -711,6 +791,9 @@ impl ProviderCatalogPort for TestCatalog {
|
||||
if let Some(gate) = gate {
|
||||
gate.await.expect("release catalog query");
|
||||
}
|
||||
if self.unavailable.load(Ordering::SeqCst) {
|
||||
return Err(ProviderCatalogUnavailable);
|
||||
}
|
||||
Ok(self.models.lock().expect("models lock").clone())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,10 +126,23 @@ impl<'a> CodexSelectionModel<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn allows_account(self, account: &ProviderAccount) -> bool {
|
||||
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH {
|
||||
return true;
|
||||
}
|
||||
// API Key 兼容上游可以提供 Grok;ChatGPT OAuth 不能因共用 Provider 被选中
|
||||
// 使用路由映射后的模型,不限制未知别名或尚未被目录收录的 OpenAI 模型
|
||||
if self.model().is_some_and(|model| {
|
||||
let model = model.rsplit('/').next().unwrap_or(model);
|
||||
model.eq_ignore_ascii_case("grok")
|
||||
|| model
|
||||
.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("grok-"))
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
// 官方 Codex 的 image_generation_available 排除 Free,API Key 不套用 ChatGPT 套餐
|
||||
// https://github.com/openai/codex/commit/0a0a9b6c8f
|
||||
!(matches!(self, Self::Image(_))
|
||||
&& account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH
|
||||
&& account
|
||||
.plan_type()
|
||||
.is_some_and(|plan| plan.trim().eq_ignore_ascii_case("free")))
|
||||
|
||||
@@ -5,6 +5,7 @@ mod capacity;
|
||||
mod error_details;
|
||||
mod image_account_eligibility;
|
||||
mod image_model_access;
|
||||
mod model_account_eligibility;
|
||||
mod passthrough;
|
||||
mod precommit;
|
||||
mod privacy;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Grok 的 ChatGPT OAuth 选号边界与 API Key 未知模型透传合同
|
||||
|
||||
use super::*;
|
||||
|
||||
fn operation(model: &str) -> Operation {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!(model)),
|
||||
("input".to_owned(), json!("hello")),
|
||||
]),
|
||||
)
|
||||
.unwrap()
|
||||
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grok_models_never_acquire_chatgpt_oauth_accounts() {
|
||||
let oauth = MockServer::start().await;
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let leases = Arc::new(TestLeaseCoordinator::default());
|
||||
let provider = provider_with_affinity_and_base_url_and_leases(
|
||||
&store,
|
||||
Arc::default(),
|
||||
oauth.uri(),
|
||||
leases.clone(),
|
||||
);
|
||||
for model in ["grok-4.7", "xai/grok-4.7", "GROK-NEW", "grok"] {
|
||||
let error = provider
|
||||
.clone()
|
||||
.execute(
|
||||
planned_request_for_model("openai", operation(model), model),
|
||||
context("req_grok_oauth_rejected", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("Grok cannot select a ChatGPT account");
|
||||
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
|
||||
}
|
||||
assert!(leases.requests.lock().unwrap().is_empty());
|
||||
assert!(oauth.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_pool_routes_grok_to_api_key_despite_oauth_weight() {
|
||||
let api = MockServer::start().await;
|
||||
let oauth = MockServer::start().await;
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_scope_new").await;
|
||||
store.set_scheduling("acct_scope_new", None, AccountWeight::new(100).unwrap());
|
||||
store
|
||||
.seed_api_key(
|
||||
"acct_provider_contract",
|
||||
api.uri(),
|
||||
provider_openai::credential::ResponsesTransport::Http,
|
||||
)
|
||||
.await;
|
||||
store.set_scheduling(
|
||||
"acct_provider_contract",
|
||||
None,
|
||||
AccountWeight::new(1).unwrap(),
|
||||
);
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&api)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, oauth.uri())
|
||||
.execute(
|
||||
planned_request_for_model("openai", operation("grok-4.7"), "grok-4.7"),
|
||||
context("req_grok_api_key", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stream.metadata().provider_account_id().as_str(),
|
||||
"acct_provider_contract"
|
||||
);
|
||||
while let Some(event) = stream.next().await {
|
||||
event.unwrap();
|
||||
}
|
||||
let requests = api.received_requests().await.unwrap();
|
||||
assert_eq!(captured_request_body(&requests[0])["model"], "grok-4.7");
|
||||
assert!(oauth.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlisted_models_remain_available_without_catalog_discovery() {
|
||||
for api_key in [false, true] {
|
||||
let server = MockServer::start().await;
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
if api_key {
|
||||
store
|
||||
.seed_api_key(
|
||||
"acct_provider_contract",
|
||||
server.uri(),
|
||||
provider_openai::credential::ResponsesTransport::Http,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
}
|
||||
Mock::given(method("POST"))
|
||||
.and(path(if api_key {
|
||||
"/responses"
|
||||
} else {
|
||||
"/codex/responses"
|
||||
}))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request_for_model(
|
||||
"openai",
|
||||
operation("custom-unlisted"),
|
||||
"custom-unlisted",
|
||||
),
|
||||
context("req_unlisted", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
while let Some(event) = stream.next().await {
|
||||
event.unwrap();
|
||||
}
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
captured_request_body(&requests[0])["model"],
|
||||
"custom-unlisted"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,11 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用
|
||||
`reasoning_efforts` 对象列表,默认值只取菜单中的显式标记;缺失时保持未知,不以首项代替,
|
||||
也不回退旧字段或其他端点的目录形状
|
||||
|
||||
已知模型只路由到明确提供该模型的 Provider,账号不可用时不转发给其他平台的未知模型通道。
|
||||
未知模型只在当前范围与路由约束下存在唯一 Provider 透传候选时发送;无法确定归属时返回不可用错误,
|
||||
可通过 Client Key 分组限定目标平台。一个 Provider 内的多个 API Key 账号仍按账号模型政策与调度规则选择,
|
||||
不要求模型已被上游目录收录;ChatGPT OAuth 账号不接受 Grok 模型
|
||||
|
||||
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的
|
||||
合格账号,最多尝试三个账号;API Key 目录按账号模型权限过滤后聚合。同名模型优先采用 OAuth 原生对象,
|
||||
其次采用 API Key 的完整 Codex 对象,再使用普通模型 ID;同类 API Key 目录按账号 ID 确定来源,不跨账号
|
||||
|
||||
@@ -613,9 +613,11 @@ Client Key 与账号分组形成授权范围:
|
||||
- 已绑定分组为空或全部禁用时得到空池,绝不回退为全部账号
|
||||
- 分组可以包含多个 Provider,账号也可以属于多个分组
|
||||
|
||||
Core 在账号授权、模型政策与请求路由约束内,优先选择目录明确包含映射后模型且能力匹配的 Provider,
|
||||
再保留发现型或暂不可读目录的未知模型候选;两类候选各自保持 Provider 标识顺序。
|
||||
目录缺项不阻止 OpenAI 模型透传,重试和跨 Provider fallback 仍受原有发送与重放安全边界约束
|
||||
Core 按映射后的模型冻结 Provider 归属:已知模型只在明确声明该模型且能力匹配的 Provider 中路由,
|
||||
再应用账号授权、模型政策和请求约束;账号不可用或所属 Provider 被排除不能扩大归属范围。
|
||||
同名模型的多个所属 Provider 保持标识顺序,重试仍受发送与重放安全边界约束。
|
||||
未被任何目录识别的模型只有一个 Provider 透传候选时才交由上游验证,多个未知候选不按名称顺序猜选平台。
|
||||
OpenAI Provider 在选号时排除 Grok 模型的 ChatGPT OAuth 账号,API Key 兼容上游仍可透传
|
||||
|
||||
账号选择综合启停状态、credential/quota 事实、Redis cooldown、并发上限、权重、请求间隔和会话亲和。
|
||||
默认账号并发上限为 `0` 时表示无限;账号独立正数上限仍优先,未设置则继承默认值。
|
||||
@@ -799,7 +801,10 @@ PostgreSQL 周期对账才是正确性基础
|
||||
|
||||
同一 publisher 的管理提交、订阅通知与周期对账共享编译到发布/暂停的临界区,避免旧结果覆盖新授权或
|
||||
晚到的失败暂停新快照;请求读取不等待刷新锁。对账仍允许持久 revision 回退,不能只比较版本大小。
|
||||
配置不可确认时暂停新请求;仅目录 generation 变化且编译失败时继续使用旧快照,留待下次对账
|
||||
配置不可确认时暂停新请求;仅目录 generation 变化且编译失败时继续使用旧快照,留待下次对账。
|
||||
同一扩展集合内,单个 Provider 目录读取失败保留上次成功的模型、能力和来源账号事实,
|
||||
新账号授权仍按当前配置发布,后续目录 generation 变化或刷新成功后更新模型事实。
|
||||
成功返回空目录表示模型撤回,移除 Provider 或切换扩展集合时不复用旧归属
|
||||
|
||||
## 8. 状态所有权
|
||||
|
||||
|
||||
Reference in New Issue
Block a user