diff --git a/backend/crates/gateway-api/tests/openai/error.rs b/backend/crates/gateway-api/tests/openai/error.rs index 1ba85001d..2b54af1a0 100644 --- a/backend/crates/gateway-api/tests/openai/error.rs +++ b/backend/crates/gateway-api/tests/openai/error.rs @@ -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> { + 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") + ), + ); + } +} diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index 0b95c360b..0325eb6a6 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -1213,6 +1213,17 @@ fn new_request_id() -> Result { 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( diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index cb2383a2a..250fc5333 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -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(), }); diff --git a/backend/crates/gateway-core/src/validation.rs b/backend/crates/gateway-core/src/validation.rs index c1b4e3f3b..04b19a5be 100644 --- a/backend/crates/gateway-core/src/validation.rs +++ b/backend/crates/gateway-core/src/validation.rs @@ -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 { diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index 9309c68a2..c31b70603 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -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] diff --git a/backend/crates/gateway-core/tests/routing/mod.rs b/backend/crates/gateway-core/tests/routing/mod.rs index 39a69b559..2e737f79e 100644 --- a/backend/crates/gateway-core/tests/routing/mod.rs +++ b/backend/crates/gateway-core/tests/routing/mod.rs @@ -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] diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index 3d5aa4e94..41f6d8060 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -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, 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]) + ProviderRegistry::new([Arc::new(TestCatalogProvider::Unavailable) as Arc]) .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]) + .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 {