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:
Zyy
2026-10-12 13:04:32 +08:00
co-authored by Codex
parent 0f575e279a
commit 2f96c02ea0
9 changed files with 430 additions and 74 deletions
@@ -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"
);
}
}
+5
View File
@@ -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 确定来源,不跨账号
+9 -4
View File
@@ -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. 状态所有权