mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user