fix(routing): report missing models with explicit errors

Return model_not_found with HTTP 404 when all scoped provider catalogs
confirm the requested or mapped model is absent. Explain missing mapping
targets separately and preserve unavailable-provider errors for circuit
blocks, empty account scopes, and unsupported operations.

Cover missing models, aliases, empty and unknown catalogs, provider
fallback, and streaming and buffered HTTP error responses.

Validation: gateway-core and gateway-api tests (541 passed), strict Clippy
for both crates with all targets and features, workspace rustfmt, and diff
whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-10 22:23:42 +08:00
co-authored by Codex
parent ddc10d7c1a
commit 74318af570
7 changed files with 358 additions and 21 deletions
@@ -433,3 +433,151 @@ async fn continuation_recovery_should_preserve_the_official_retry_signal() {
})
);
}
mod model_routing {
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode},
};
use futures::future::BoxFuture;
use gateway_core::engine::execution::{
DefaultExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
};
use gateway_core::engine::provider::ProviderRegistry;
use gateway_core::routing::{ProviderKind, RuntimeSnapshot};
use gateway_core::runtime::RuntimeSnapshotHandle;
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::openai::{
IgnoredClientApiKeyUsage, UnusedAdmissions, UnusedContinuation, UnusedExecutionStore,
};
struct TestCircuits {
blocked: bool,
}
impl ProviderCircuitPort for TestCircuits {
fn decision<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>> {
Box::pin(async move {
Ok(if self.blocked {
ProviderCircuitDecision::BlockedUntil(
SystemTime::now() + Duration::from_secs(30),
)
} else {
ProviderCircuitDecision::Allow
})
})
}
fn observe_failure<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("routing rejection must not execute a provider") })
}
fn observe_success<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("routing rejection must not execute a provider") })
}
}
async fn request_model(
snapshot: RuntimeSnapshot,
model: &str,
stream: bool,
blocked: bool,
) -> (StatusCode, Value) {
let execution = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(TestCircuits { blocked }),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
);
let response = crate::openai::api_router(Arc::new(execution))
.await
.oneshot(
Request::post("/v1/responses")
.header("authorization", "Bearer sk_model_routing")
.header("content-type", "application/json")
.body(Body::from(
json!({"model": model, "input": "hello", "stream": stream}).to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), 16 * 1024)
.await
.expect("error body");
(status, serde_json::from_slice(&body).expect("error JSON"))
}
#[tokio::test]
async fn missing_models_should_return_404_with_a_specific_explanation() {
for (model, mappings, message) in [
(
"missing-model",
BTreeMap::new(),
"the requested model was not found in the provider catalogs available to this API key; check the model name",
),
(
"model-a",
BTreeMap::from([("model-a".to_owned(), "missing-target".to_owned())]),
"the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping",
),
] {
for stream in [false, true] {
let snapshot = crate::openai::snapshot("sk_model_routing", "openai")
.with_model_mappings(mappings.clone());
let response = request_model(snapshot, model, stream, false).await;
assert_eq!(
response,
(
StatusCode::NOT_FOUND,
json!({"error": {
"type": "invalid_request_error",
"code": "model_not_found",
"message": message,
}}),
),
"model={model}, stream={stream}",
);
}
}
}
#[tokio::test]
async fn an_existing_model_with_a_blocked_provider_should_remain_service_unavailable() {
let response = request_model(
crate::openai::snapshot("sk_model_routing", "openai"),
"model-a",
true,
true,
)
.await;
assert_eq!(
(response.0, response.1["error"]["code"].as_str()),
(
StatusCode::SERVICE_UNAVAILABLE,
Some("no_available_provider")
),
);
}
}
@@ -1213,6 +1213,17 @@ fn new_request_id() -> Result<ModelRequestId, GatewayError> {
fn map_routing_error(error: crate::validation::RoutingError) -> GatewayError {
match error {
crate::validation::RoutingError::ModelNotFound {
model,
mapped_model,
} => GatewayError::new(
GatewayErrorKind::ModelNotFound,
if model == mapped_model {
"the requested model was not found in the provider catalogs available to this API key; check the model name"
} else {
"the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping"
},
),
crate::validation::RoutingError::NoCapableProvider { .. }
| crate::validation::RoutingError::NoCapableProviderEndpoint { .. }
| crate::validation::RoutingError::EmptyAccountScope => GatewayError::new(
@@ -778,6 +778,19 @@ impl RuntimeSnapshot {
}
if candidates.is_empty() {
// 模型存在性必须包含被熔断的 Provider;目录未知时不能断言模型不存在。
let mut scoped_providers = providers.intersection(&self.providers).peekable();
if scoped_providers.peek().is_some()
&& scoped_providers.all(|provider| {
self.known_provider_catalogs.contains(provider)
&& !self.contains_public_model_for_provider(public_model, provider)
})
{
return Err(RoutingError::ModelNotFound {
model: public_model.as_str().to_owned(),
mapped_model: self.mapped_model(public_model.as_str()),
});
}
return Err(RoutingError::NoCapableProvider {
model: public_model.as_str().to_owned(),
});
@@ -85,6 +85,16 @@ pub enum RoutingError {
/// 实体 ID。
id: String,
},
/// 账号范围内的 Provider 目录均已知,且均没有请求或映射后的模型。
#[error(
"model `{model}` (mapped to `{mapped_model}`) was not found in the scoped provider catalogs"
)]
ModelNotFound {
/// 客户端提交的模型名称。
model: String,
/// 应用模型映射后的名称。
mapped_model: String,
},
/// 固定平台内没有可执行本次请求的 Provider。
#[error("no provider can execute model `{model}`")]
NoCapableProvider {
@@ -495,7 +495,13 @@ fn known_catalog_should_reject_a_model_that_the_provider_did_not_publish() {
panic!("a known provider catalog must be authoritative for model availability");
};
assert_eq!(error.kind(), GatewayErrorKind::NoAvailableProvider);
assert_eq!(
(error.kind(), error.client_message()),
(
GatewayErrorKind::ModelNotFound,
"the requested model was not found in the provider catalogs available to this API key; check the model name",
)
);
}
#[test]
+127 -13
View File
@@ -326,12 +326,123 @@ fn known_provider_catalog_missing_mapped_model_should_be_filtered() {
)
.expect_err("known xAI catalog does not contain the mapped OpenAI model");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "gpt-5.4".to_owned(),
mapped_model: "gpt-5.5".to_owned(),
}
);
}
#[test]
fn missing_model_should_be_reported_when_all_scoped_catalogs_are_known() {
let snapshot = snapshot();
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.expect_err("neither provider publishes the requested model");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "missing-model".to_owned(),
mapped_model: "missing-model".to_owned(),
}
);
}
#[test]
fn missing_model_in_one_provider_should_still_route_to_another() {
let snapshot = snapshot();
let plan = snapshot
.plan(
&PublicModelId::new("grok-4.5").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.expect("xAI publishes the model missing from OpenAI");
assert_eq!(plan.candidates()[0].provider().as_str(), "xai");
}
#[test]
fn blocked_model_provider_should_not_be_misreported_as_model_not_found() {
let snapshot = snapshot();
let error = snapshot
.plan(
&PublicModelId::new("gpt-5.5").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
blocked_providers: BTreeSet::from([ProviderKind::new("openai").expect("provider")]),
..RoutingContext::default()
},
)
.expect_err("OpenAI is blocked and xAI does not publish this model");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
fn blocked_unknown_catalog_should_not_prove_model_absence() {
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
scheduling(),
vec![
ProviderKind::new("openai").expect("provider"),
ProviderKind::new("xai").expect("provider"),
],
vec![model("xai", "grok-4.5", capabilities())],
Vec::new(),
)
.expect("snapshot")
.with_account_directory(account_directory());
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
blocked_providers: BTreeSet::from([ProviderKind::new("openai").expect("provider")]),
..RoutingContext::default()
},
)
.expect_err("the blocked provider's catalog cannot establish model absence");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
fn empty_account_scope_should_not_be_misreported_as_model_not_found() {
let snapshot = snapshot();
let empty_scope = Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::default()),
ClientRoutingScope::all_accounts(),
));
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
empty_scope,
&RoutingContext::default(),
)
.expect_err("the API key has no accounts");
assert_eq!(error, gateway_core::error::RoutingError::EmptyAccountScope);
}
#[test]
fn provider_endpoint_plan_should_not_consult_or_publish_the_text_model_catalog() {
let snapshot = snapshot();
@@ -463,19 +574,22 @@ fn known_unsupported_operation_should_not_be_bypassed() {
)
.expect("snapshot");
assert!(
snapshot
.plan(
&PublicModelId::new("gpt-known-unsupported").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
required_provider: Some(ProviderKind::new("openai").expect("provider")),
..RoutingContext::default()
},
)
.is_err()
);
let error = snapshot
.plan(
&PublicModelId::new("gpt-known-unsupported").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
required_provider: Some(ProviderKind::new("openai").expect("provider")),
..RoutingContext::default()
},
)
.expect_err("the model exists but cannot perform this operation");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
@@ -56,10 +56,13 @@ struct PublishingCatalogProvider {
queries: AtomicUsize,
}
struct UnavailableCatalogProvider;
enum TestCatalogProvider {
Unavailable,
Empty,
}
#[async_trait]
impl Provider for UnavailableCatalogProvider {
impl Provider for TestCatalogProvider {
fn name(&self) -> &'static str {
"alpha"
}
@@ -71,10 +74,13 @@ impl Provider for UnavailableCatalogProvider {
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
))
match self {
Self::Unavailable => Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
)),
Self::Empty => Ok(Vec::new()),
}
}
async fn execute(
@@ -145,7 +151,7 @@ fn compiler_should_reject_revision_changed_during_consistent_read() {
#[test]
fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
let providers =
ProviderRegistry::new([Arc::new(UnavailableCatalogProvider) as Arc<dyn Provider>])
ProviderRegistry::new([Arc::new(TestCatalogProvider::Unavailable) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
@@ -162,6 +168,35 @@ fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
assert_eq!(snapshot.client_policies().count(), 1);
}
#[test]
fn known_empty_catalog_should_report_model_not_found() {
let providers =
ProviderRegistry::new([Arc::new(TestCatalogProvider::Empty) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
let snapshot = block_on(compiler.compile()).expect("compile empty catalog");
let error = snapshot
.plan(
&PublicModelId::new("public-model").expect("model"),
&super::operation(),
snapshot.all_account_scope(),
&gateway_core::routing::RoutingContext {
required_provider: Some(ProviderKind::new("alpha").expect("provider")),
..gateway_core::routing::RoutingContext::default()
},
)
.expect_err("a successfully published empty catalog proves model absence");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "public-model".to_owned(),
mapped_model: "upstream-model".to_owned(),
},
);
}
#[test]
fn compiler_retries_when_provider_publishes_catalog_during_compilation() {
let provider = Arc::new(PublishingCatalogProvider {