Tighten local auth allow-list matching

This commit is contained in:
fawney19
2026-04-30 00:26:33 +08:00
parent 37392d8774
commit 9e2faa7e5b
10 changed files with 758 additions and 53 deletions

View File

@@ -3,6 +3,7 @@ use axum::http::Uri;
use super::super::GatewayControlDecision;
use super::credentials::{contains_string, extract_requested_model};
use crate::{AppState, GatewayError};
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum GatewayLocalAuthRejection {
@@ -42,24 +43,297 @@ pub(crate) fn should_buffer_request_for_local_auth(
&& crate::headers::is_json_request(headers)
}
pub(crate) fn request_model_local_rejection(
pub(crate) async fn request_model_local_rejection(
state: &AppState,
decision: Option<&GatewayControlDecision>,
uri: &Uri,
headers: &http::HeaderMap,
body: &Bytes,
) -> Option<GatewayLocalAuthRejection> {
let decision = decision?;
) -> Result<Option<GatewayLocalAuthRejection>, GatewayError> {
let Some(decision) = decision else {
return Ok(None);
};
if decision.route_class.as_deref() != Some("ai_public") {
return None;
return Ok(None);
}
let auth_context = decision.auth_context.as_ref()?;
let allowed_models = auth_context.allowed_models.as_deref()?;
let requested_model = extract_requested_model(decision, uri, headers, body)?;
let Some(auth_context) = decision.auth_context.as_ref() else {
return Ok(None);
};
let Some(allowed_models) = auth_context.allowed_models.as_deref() else {
return Ok(None);
};
let Some(requested_model) = extract_requested_model(decision, uri, headers, body) else {
return Ok(None);
};
if contains_string(allowed_models, &requested_model) {
return None;
return Ok(None);
}
if request_model_resolves_to_allowed_model(state, decision, &requested_model, allowed_models)
.await?
{
return Ok(None);
}
Some(GatewayLocalAuthRejection::ModelNotAllowed {
Ok(Some(GatewayLocalAuthRejection::ModelNotAllowed {
model: requested_model,
})
}))
}
async fn request_model_resolves_to_allowed_model(
state: &AppState,
decision: &GatewayControlDecision,
requested_model: &str,
allowed_models: &[String],
) -> Result<bool, GatewayError> {
let Some(client_api_format) = decision
.auth_endpoint_signature
.as_deref()
.map(crate::ai_pipeline::normalize_api_format_alias)
.filter(|value| !value.trim().is_empty())
else {
return Ok(false);
};
for api_format in candidate_api_formats_for_model_resolution(&client_api_format) {
let rows = state
.list_minimal_candidate_selection_rows_for_api_format(&api_format)
.await?;
let matching_rows = rows
.into_iter()
.filter(|row| {
aether_scheduler_core::row_supports_requested_model(
row,
requested_model,
&api_format,
)
})
.collect::<Vec<_>>();
let Some(resolved_global_model) =
aether_scheduler_core::resolve_requested_global_model_name(
&matching_rows,
requested_model,
&api_format,
)
else {
continue;
};
if contains_string(allowed_models, &resolved_global_model) {
return Ok(true);
}
}
Ok(false)
}
fn candidate_api_formats_for_model_resolution(client_api_format: &str) -> Vec<String> {
let mut api_formats = Vec::new();
push_unique_api_format(&mut api_formats, client_api_format);
for api_format in crate::ai_pipeline::request_candidate_api_formats(client_api_format, false) {
push_unique_api_format(&mut api_formats, api_format);
}
api_formats
}
fn push_unique_api_format(api_formats: &mut Vec<String>, api_format: &str) {
let api_format = crate::ai_pipeline::normalize_api_format_alias(api_format);
if api_format.is_empty() || api_formats.iter().any(|value| value == &api_format) {
return;
}
api_formats.push(api_format);
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
};
use axum::body::Bytes;
use axum::http::{HeaderMap, Uri};
use super::{request_model_local_rejection, GatewayLocalAuthRejection};
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
use crate::data::GatewayDataState;
use crate::AppState;
fn sample_row() -> StoredMinimalCandidateSelectionRow {
StoredMinimalCandidateSelectionRow {
provider_id: "provider-1".to_string(),
provider_name: "Provider 1".to_string(),
provider_type: "openai".to_string(),
provider_priority: 0,
provider_is_active: true,
endpoint_id: "endpoint-1".to_string(),
endpoint_api_format: "openai:chat".to_string(),
endpoint_api_family: Some("openai".to_string()),
endpoint_kind: Some("chat".to_string()),
endpoint_is_active: true,
key_id: "key-1".to_string(),
key_name: "key".to_string(),
key_auth_type: "api_key".to_string(),
key_is_active: true,
key_api_formats: Some(vec!["openai:chat".to_string()]),
key_allowed_models: None,
key_capabilities: None,
key_internal_priority: 0,
key_global_priority_by_format: None,
model_id: "model-1".to_string(),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5".to_string(),
global_model_mappings: Some(vec!["gpt-5(?:\\.\\d+)?".to_string()]),
global_model_supports_streaming: Some(true),
model_provider_model_name: "gpt-5-upstream".to_string(),
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
name: "gpt-5-upstream".to_string(),
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
}]),
model_supports_streaming: Some(true),
model_is_active: true,
model_is_available: true,
}
}
fn sample_row_for_api_format(api_format: &str) -> StoredMinimalCandidateSelectionRow {
let mut row = sample_row();
let api_family = api_format
.split_once(':')
.map(|(family, _)| family)
.unwrap_or(api_format);
row.provider_id = format!("provider-{api_family}");
row.provider_name = format!("Provider {api_family}");
row.provider_type = api_family.to_string();
row.endpoint_id = format!("endpoint-{api_family}");
row.endpoint_api_format = api_format.to_string();
row.endpoint_api_family = Some(api_family.to_string());
row.key_id = format!("key-{api_family}");
row.key_api_formats = Some(vec![api_format.to_string()]);
if let Some(mappings) = row.model_provider_model_mappings.as_mut() {
for mapping in mappings {
mapping.api_formats = Some(vec![api_format.to_string()]);
}
}
row
}
fn decision_with_allowed_models(allowed_models: Vec<String>) -> GatewayControlDecision {
let mut decision = GatewayControlDecision::synthetic(
"/v1/chat/completions",
Some("ai_public".to_string()),
Some("openai".to_string()),
Some("chat".to_string()),
Some("openai:chat".to_string()),
);
decision.auth_context = Some(GatewayControlAuthContext {
user_id: "user-1".to_string(),
api_key_id: "api-key-1".to_string(),
username: None,
api_key_name: None,
balance_remaining: None,
access_allowed: true,
user_rate_limit: None,
api_key_rate_limit: None,
api_key_is_standalone: false,
local_rejection: None,
allowed_models: Some(allowed_models),
});
decision
}
fn state_with_rows(rows: Vec<StoredMinimalCandidateSelectionRow>) -> AppState {
let repository = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(rows));
let data = GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository);
AppState::new()
.expect("state should build")
.with_data_state_for_tests(data)
}
fn state_with_model_mapping() -> AppState {
state_with_rows(vec![sample_row()])
}
fn json_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
"application/json"
.parse()
.expect("content type should parse"),
);
headers
}
#[tokio::test]
async fn model_rejection_allows_requested_model_that_resolves_to_allowed_global_model() {
let state = state_with_model_mapping();
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
let body = Bytes::from_static(br#"{"model":"gpt-5.2","messages":[]}"#);
let rejection =
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
.await
.expect("model rejection should resolve");
assert_eq!(rejection, None);
}
#[tokio::test]
async fn model_rejection_allows_cross_format_provider_mapping_to_allowed_global_model() {
let mut row = sample_row_for_api_format("gemini:generate_content");
row.model_provider_model_name = "gemini-2.5-pro-upstream".to_string();
row.model_provider_model_mappings = Some(vec![StoredProviderModelMapping {
name: "gemini-2.5-pro-alias".to_string(),
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
}]);
let state = state_with_rows(vec![row]);
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
let body = Bytes::from_static(br#"{"model":"gemini-2.5-pro-alias","messages":[]}"#);
let rejection =
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
.await
.expect("model rejection should resolve");
assert_eq!(rejection, None);
}
#[tokio::test]
async fn model_rejection_allows_cross_format_regex_mapping_to_allowed_global_model() {
let state = state_with_rows(vec![sample_row_for_api_format("claude:messages")]);
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
let body = Bytes::from_static(br#"{"model":"gpt-5.2","messages":[]}"#);
let rejection =
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
.await
.expect("model rejection should resolve");
assert_eq!(rejection, None);
}
#[tokio::test]
async fn model_rejection_denies_requested_model_outside_allowed_global_models() {
let state = state_with_model_mapping();
let decision = decision_with_allowed_models(vec!["gpt-4.1".to_string()]);
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
let body = Bytes::from_static(br#"{"model":"gpt-5.2","messages":[]}"#);
let rejection =
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
.await
.expect("model rejection should resolve");
assert_eq!(
rejection,
Some(GatewayLocalAuthRejection::ModelNotAllowed {
model: "gpt-5.2".to_string(),
})
);
}
}

View File

@@ -1,5 +1,8 @@
use std::time::Duration;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
};
use axum::http::Uri;
use base64::Engine as _;
use hmac::Mac;
@@ -611,7 +614,7 @@ async fn build_data_backed_auth_context(
.unwrap_or(auth_endpoint_signature)
.trim();
let requested_provider_allowed =
auth_snapshot_allows_requested_provider(state, &snapshot, requested_provider).await;
auth_snapshot_allows_requested_provider(state, &snapshot, auth_endpoint_signature).await;
let local_rejection = if invalid_api_key {
Some(GatewayLocalAuthRejection::InvalidApiKey)
} else if locked_api_key {
@@ -664,18 +667,23 @@ fn normalize_api_format_alias(value: &str) -> String {
}
fn api_format_matches(left: &str, right: &str) -> bool {
normalize_api_format_alias(left) == normalize_api_format_alias(right)
aether_scheduler_core::api_format_matches_allowed_value(left, right)
}
async fn auth_snapshot_allows_requested_provider(
state: &AppState,
snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
requested_provider: &str,
auth_endpoint_signature: &str,
) -> bool {
let Some(allowed_providers) = snapshot.effective_allowed_providers() else {
return true;
};
let requested_provider = requested_provider.trim();
let requested_api_format = normalize_api_format_alias(auth_endpoint_signature);
let requested_provider = requested_api_format
.split_once(':')
.map(|(provider, _)| provider)
.unwrap_or(requested_api_format.as_str())
.trim();
if requested_provider.is_empty() {
return true;
}
@@ -684,7 +692,7 @@ async fn auth_snapshot_allows_requested_provider(
}
if allowed_providers
.iter()
.any(|value| value.trim().eq_ignore_ascii_case(requested_provider))
.any(|value| allowed_provider_value_matches_requested_provider(value, requested_provider))
{
return true;
}
@@ -704,12 +712,10 @@ async fn auth_snapshot_allows_requested_provider(
}
};
providers.into_iter().any(|provider| {
provider
.provider_type
.trim()
.eq_ignore_ascii_case(requested_provider)
&& allowed_providers.iter().any(|value| {
let allowed_catalog_providers = providers
.into_iter()
.filter(|provider| {
allowed_providers.iter().any(|value| {
aether_scheduler_core::provider_matches_allowed_value(
value,
&provider.id,
@@ -717,9 +723,95 @@ async fn auth_snapshot_allows_requested_provider(
&provider.provider_type,
)
})
})
.collect::<Vec<_>>();
if allowed_catalog_providers
.iter()
.any(|provider| provider_matches_requested_provider(provider, requested_provider))
{
return true;
}
let allowed_provider_ids = allowed_catalog_providers
.iter()
.map(|provider| provider.id.clone())
.collect::<Vec<_>>();
if allowed_provider_ids.is_empty() {
return false;
}
let endpoints = match state
.list_provider_catalog_endpoints_by_provider_ids(&allowed_provider_ids)
.await
{
Ok(value) => value,
Err(err) => {
debug!(
"skip local provider auth gate for requested provider {}: provider endpoint lookup failed: {:?}",
requested_provider, err
);
return true;
}
};
endpoints.iter().any(|endpoint| {
endpoint_matches_requested_provider(endpoint, &requested_api_format, requested_provider)
})
}
fn allowed_provider_value_matches_requested_provider(
allowed_value: &str,
requested_provider: &str,
) -> bool {
aether_scheduler_core::provider_matches_allowed_value(
allowed_value,
requested_provider,
requested_provider,
requested_provider,
)
}
fn provider_matches_requested_provider(
provider: &StoredProviderCatalogProvider,
requested_provider: &str,
) -> bool {
aether_scheduler_core::provider_matches_allowed_value(
requested_provider,
&provider.id,
&provider.name,
&provider.provider_type,
)
}
fn endpoint_matches_requested_provider(
endpoint: &StoredProviderCatalogEndpoint,
requested_api_format: &str,
requested_provider: &str,
) -> bool {
if !endpoint.is_active {
return false;
}
if api_format_matches(&endpoint.api_format, requested_api_format) {
return true;
}
let endpoint_api_format = normalize_api_format_alias(&endpoint.api_format);
if crate::ai_pipeline::request_conversion_kind(requested_api_format, &endpoint_api_format)
.is_some()
{
return true;
}
if endpoint.api_family.as_deref().is_some_and(|family| {
allowed_provider_value_matches_requested_provider(family, requested_provider)
}) {
return true;
}
let endpoint_provider = endpoint_api_format
.split_once(':')
.map(|(provider, _)| provider)
.unwrap_or(endpoint_api_format.as_str());
allowed_provider_value_matches_requested_provider(endpoint_provider, requested_provider)
}
fn get_cached_auth_context(state: &AppState, cache_key: &str) -> Option<GatewayControlAuthContext> {
state
.auth_context_cache
@@ -734,7 +826,9 @@ mod tests {
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
};
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
};
use axum::http::{HeaderMap, Uri};
use super::{resolve_data_backed_auth_context, GatewayLocalAuthRejection};
@@ -783,6 +877,22 @@ mod tests {
.expect("provider should build")
}
fn sample_endpoint(
id: &str,
provider_id: &str,
api_format: &str,
) -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
id.to_string(),
provider_id.to_string(),
api_format.to_string(),
None,
None,
true,
)
.expect("endpoint should build")
}
#[tokio::test]
async fn data_backed_api_key_auth_touches_last_used_once_per_throttle_window() {
let api_key = "sk-test-touch";
@@ -868,6 +978,191 @@ mod tests {
assert_eq!(auth_context.local_rejection, None);
}
#[tokio::test]
async fn data_backed_auth_context_allows_provider_id_for_matching_endpoint_format() {
let api_key = "sk-test-provider-endpoint";
let mut snapshot = sample_snapshot("key-4", "user-4");
snapshot.user_allowed_providers = Some(vec!["provider-custom-claude".to_string()]);
snapshot.api_key_allowed_providers = Some(vec!["provider-custom-claude".to_string()]);
snapshot.user_allowed_api_formats = None;
snapshot.api_key_allowed_api_formats = None;
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key(api_key)),
snapshot,
)]));
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider(
"provider-custom-claude",
"Custom Claude Gateway",
"custom",
)],
vec![sample_endpoint(
"endpoint-custom-claude",
"provider-custom-claude",
"claude:messages",
)],
Vec::new(),
));
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
.with_provider_catalog_reader(provider_catalog);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data);
let mut headers = HeaderMap::new();
headers.insert("x-api-key", api_key.parse().unwrap());
let auth_context = resolve_data_backed_auth_context(
&state,
&headers,
&uri("/v1/messages"),
Some("claude:messages"),
)
.await
.expect("resolution should succeed")
.expect("auth context should exist");
assert_eq!(auth_context.local_rejection, None);
}
#[tokio::test]
async fn data_backed_auth_context_allows_provider_id_for_convertible_endpoint_format() {
let api_key = "sk-test-provider-convertible-endpoint";
let mut snapshot = sample_snapshot("key-9", "user-9");
snapshot.api_key_is_standalone = true;
snapshot.user_allowed_providers = None;
snapshot.api_key_allowed_providers = Some(vec!["provider-custom-openai".to_string()]);
snapshot.user_allowed_api_formats = None;
snapshot.api_key_allowed_api_formats = None;
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key(api_key)),
snapshot,
)]));
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider(
"provider-custom-openai",
"Custom OpenAI Responses Gateway",
"custom",
)],
vec![sample_endpoint(
"endpoint-custom-openai-responses",
"provider-custom-openai",
"openai:responses",
)],
Vec::new(),
));
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
.with_provider_catalog_reader(provider_catalog);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data);
let mut headers = HeaderMap::new();
headers.insert("x-api-key", api_key.parse().unwrap());
let auth_context = resolve_data_backed_auth_context(
&state,
&headers,
&uri("/v1/messages?beta=true"),
Some("claude:messages"),
)
.await
.expect("resolution should succeed")
.expect("auth context should exist");
assert_eq!(auth_context.local_rejection, None);
}
#[tokio::test]
async fn data_backed_auth_context_denies_retired_anthropic_provider_alias_for_claude_route() {
let api_key = "sk-test-provider-retired-anthropic-alias";
let mut snapshot = sample_snapshot("key-5", "user-5");
snapshot.user_allowed_providers = Some(vec!["anthropic".to_string()]);
snapshot.api_key_allowed_providers = Some(vec!["anthropic".to_string()]);
snapshot.user_allowed_api_formats = None;
snapshot.api_key_allowed_api_formats = None;
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key(api_key)),
snapshot,
)]));
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-claude", "Claude", "custom")],
Vec::new(),
Vec::new(),
));
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
.with_provider_catalog_reader(provider_catalog);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data);
let mut headers = HeaderMap::new();
headers.insert("x-api-key", api_key.parse().unwrap());
let auth_context = resolve_data_backed_auth_context(
&state,
&headers,
&uri("/v1/messages"),
Some("claude:messages"),
)
.await
.expect("resolution should succeed")
.expect("auth context should exist");
assert_eq!(
auth_context.local_rejection,
Some(GatewayLocalAuthRejection::ProviderNotAllowed {
provider: "claude".to_string(),
})
);
}
#[tokio::test]
async fn data_backed_auth_context_treats_empty_allowed_lists_as_unrestricted() {
let api_key = "sk-test-empty-restrictions";
let mut snapshot = sample_snapshot("key-6", "user-6");
snapshot.api_key_is_standalone = true;
snapshot.user_allowed_providers = Some(vec!["openai".to_string()]);
snapshot.user_allowed_api_formats = Some(vec!["openai:chat".to_string()]);
snapshot.user_allowed_models = Some(vec!["gpt-4.1".to_string()]);
snapshot.api_key_allowed_providers = Some(Vec::new());
snapshot.api_key_allowed_api_formats = Some(Vec::new());
snapshot.api_key_allowed_models = Some(Vec::new());
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key(api_key)),
snapshot,
)]));
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-claude", "Claude", "custom")],
vec![sample_endpoint(
"endpoint-claude",
"provider-claude",
"claude:messages",
)],
Vec::new(),
));
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
.with_provider_catalog_reader(provider_catalog);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data);
let mut headers = HeaderMap::new();
headers.insert("x-api-key", api_key.parse().unwrap());
let auth_context = resolve_data_backed_auth_context(
&state,
&headers,
&uri("/v1/messages"),
Some("claude:messages"),
)
.await
.expect("resolution should succeed")
.expect("auth context should exist");
assert_eq!(auth_context.local_rejection, None);
}
#[tokio::test]
async fn data_backed_auth_context_denies_provider_type_without_matching_allowed_provider() {
let api_key = "sk-test-provider-miss";

View File

@@ -247,17 +247,15 @@ pub(crate) fn normalize_admin_user_api_formats(
};
let mut normalized = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let pattern =
Regex::new(r"^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+$").expect("api format regex should compile");
for item in values {
let item = item.trim();
if item.is_empty() {
return Err("allowed_api_formats 不能为空".to_string());
}
if !pattern.is_match(item) {
let Some(normalized_item) = crate::api::ai::normalize_admin_endpoint_signature(item) else {
return Err(format!("allowed_api_formats 格式无效: {item}"));
}
let normalized_item = item.to_ascii_lowercase();
};
let normalized_item = normalized_item.to_string();
if seen.insert(normalized_item.clone()) {
normalized.push(normalized_item);
}
@@ -278,3 +276,44 @@ pub(super) fn format_optional_datetime_iso8601(
) -> Option<String> {
value.map(|value| value.to_rfc3339())
}
#[cfg(test)]
mod tests {
use super::normalize_admin_user_api_formats;
#[test]
fn admin_user_api_formats_accept_current_canonical_signatures() {
assert_eq!(
normalize_admin_user_api_formats(Some(vec![
" OPENAI:RESPONSES ".to_string(),
"claude:messages".to_string(),
"gemini:generate_content".to_string(),
"openai:responses".to_string(),
]))
.expect("formats should normalize"),
Some(vec![
"openai:responses".to_string(),
"claude:messages".to_string(),
"gemini:generate_content".to_string(),
])
);
}
#[test]
fn admin_user_api_formats_reject_retired_signatures() {
for retired in [
"anthropic:messages",
"claude:chat",
"claude:cli",
"openai:cli",
"openai:compact",
"gemini:chat",
"gemini:cli",
] {
assert!(
normalize_admin_user_api_formats(Some(vec![retired.to_string()])).is_err(),
"{retired} should be rejected"
);
}
}
}

View File

@@ -1002,11 +1002,14 @@ pub(crate) async fn proxy_request(
if let Some(buffered_body) = buffered_body.as_ref() {
if let Some(rejection) = request_model_local_rejection(
&state,
control_decision,
&parts.uri,
&parts.headers,
buffered_body,
) {
)
.await?
{
let response =
build_local_auth_rejection_response(&trace_id, control_decision, &rejection)?;
return Ok(finalize_gateway_response_with_context(

View File

@@ -7241,7 +7241,7 @@ async fn gateway_handles_users_me_providers_locally_without_proxying_upstream()
sample_endpoint(
"endpoint-claude-1",
"provider-claude",
"anthropic:messages",
"claude:messages",
"https://api.claude.example",
),
],

View File

@@ -294,7 +294,7 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
"user".to_string(),
"local".to_string(),
Some(json!(["anthropic"])),
Some(json!(["anthropic:messages"])),
Some(json!(["claude:messages"])),
Some(json!(["claude-3-7"])),
Some(30),
None,
@@ -340,7 +340,7 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
None,
Some("secondary".to_string()),
Some(json!(["anthropic"])),
Some(json!(["anthropic:messages"])),
Some(json!(["claude:messages"])),
Some(json!(["claude-3-7"])),
Some(60),
Some(5),