fix(mapping): 对齐全局模型映射的正则匹配行为与范围 (#296)

- scheduler_core: `matches_model_mapping` 改为大小写不敏感且整串匹配,并补充单测
- global model routing 预览:
  - Key 过滤增加 `allowed_models + model_mappings` 校验
  - `all_keys_whitelist` 改为收集全站活跃 Provider 的活跃 Key 白名单
- provider mapping-preview:
  - 优先使用 admin 全量 GlobalModel(含非激活)参与映射
  - admin 数据为空时回退 public 模型,保持兼容
- public models 匹配逻辑统一复用 scheduler_core 实现,避免行为分叉
- 更新网关测试,覆盖未关联 Provider 的 Key 也进入 whitelist 的场景
This commit is contained in:
AAEE86
2026-04-14 21:58:52 +08:00
committed by GitHub
parent 47bf1d04a1
commit fb31928e44
5 changed files with 210 additions and 36 deletions

View File

@@ -7,7 +7,9 @@ use aether_data_contracts::repository::global_models::{
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
}; };
use aether_scheduler_core::{is_provider_key_circuit_open, provider_key_health_score}; use aether_scheduler_core::{
is_provider_key_circuit_open, matches_model_mapping, provider_key_health_score,
};
use serde_json::json; use serde_json::json;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use uuid::Uuid; use uuid::Uuid;
@@ -121,6 +123,13 @@ pub(crate) async fn build_admin_global_model_routing_payload(
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.filter(|key| provider_catalog_key_supports_format(key, &endpoint.api_format)) .filter(|key| provider_catalog_key_supports_format(key, &endpoint.api_format))
.filter(|key| {
key_allowed_models_match_global_model_for_routing(
key.allowed_models.as_ref(),
&global_model.name,
&global_model_mappings,
)
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
endpoint_keys.sort_by(|left, right| { endpoint_keys.sort_by(|left, right| {
left.internal_priority left.internal_priority
@@ -170,14 +179,6 @@ pub(crate) async fn build_admin_global_model_routing_payload(
"circuit_breaker_formats": circuit_breaker_formats, "circuit_breaker_formats": circuit_breaker_formats,
"next_probe_at": next_probe_at, "next_probe_at": next_probe_at,
}); });
all_keys_whitelist.push(json!({
"key_id": &key.id,
"key_name": &key.name,
"masked_key": state.masked_catalog_api_key(key),
"provider_id": &provider.id,
"provider_name": &provider.name,
"allowed_models": json_string_list(key.allowed_models.as_ref()),
}));
payload payload
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -218,6 +219,53 @@ pub(crate) async fn build_admin_global_model_routing_payload(
"active_endpoints": active_endpoints, "active_endpoints": active_endpoints,
})); }));
} }
// 与 Python 逻辑对齐:供前端实时匹配的白名单数据来自“全站活跃 Provider 的活跃 Key”
// (仅保留配置了非空 allowed_models 的 Key而不是仅当前 GlobalModel 关联 Provider。
let active_providers = state
.list_provider_catalog_providers(true)
.await
.ok()
.unwrap_or_default();
let active_provider_ids = active_providers
.iter()
.map(|provider| provider.id.clone())
.collect::<Vec<_>>();
let active_provider_name_by_id = active_providers
.into_iter()
.map(|provider| (provider.id, provider.name))
.collect::<BTreeMap<_, _>>();
let active_keys = if active_provider_ids.is_empty() {
Vec::new()
} else {
state
.list_provider_catalog_keys_by_provider_ids(&active_provider_ids)
.await
.ok()
.unwrap_or_default()
};
for key in active_keys {
if !key.is_active {
continue;
}
let allowed_models = json_string_list(key.allowed_models.as_ref());
if allowed_models.is_empty() {
continue;
}
let provider_name = active_provider_name_by_id
.get(&key.provider_id)
.cloned()
.unwrap_or_default();
all_keys_whitelist.push(json!({
"key_id": key.id,
"key_name": key.name,
"masked_key": state.masked_catalog_api_key(&key),
"provider_id": key.provider_id,
"provider_name": provider_name,
"allowed_models": allowed_models,
}));
}
providers_payload.sort_by(|left, right| { providers_payload.sort_by(|left, right| {
left.get("provider_priority") left.get("provider_priority")
.and_then(serde_json::Value::as_i64) .and_then(serde_json::Value::as_i64)
@@ -257,6 +305,35 @@ pub(crate) async fn build_admin_global_model_routing_payload(
})) }))
} }
fn key_allowed_models_match_global_model_for_routing(
raw_allowed_models: Option<&serde_json::Value>,
global_model_name: &str,
global_model_mappings: &[String],
) -> bool {
// 兼容 Python 预览逻辑None/[] 视为“不限制”,在链路预览中保留该 Key。
let allowed_models = json_string_list(raw_allowed_models);
if raw_allowed_models.is_none() || allowed_models.is_empty() {
return true;
}
if allowed_models
.iter()
.any(|value| value == global_model_name)
{
return true;
}
for allowed_model in &allowed_models {
for pattern in global_model_mappings {
if matches_model_mapping(pattern, allowed_model) {
return true;
}
}
}
false
}
pub(crate) async fn build_admin_assign_global_model_to_providers_payload( pub(crate) async fn build_admin_assign_global_model_to_providers_payload(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
global_model_id: &str, global_model_id: &str,

View File

@@ -7,12 +7,21 @@ use crate::handlers::admin::shared::{decrypt_catalog_secret_with_fallbacks, json
use crate::handlers::public::matches_model_mapping_for_models; use crate::handlers::public::matches_model_mapping_for_models;
use crate::{GatewayError, LocalProviderDeleteTaskState}; use crate::{GatewayError, LocalProviderDeleteTaskState};
use aether_data_contracts::repository::global_models::{ use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, PublicGlobalModelQuery, StoredPublicGlobalModel, AdminGlobalModelListQuery, AdminProviderModelListQuery, PublicGlobalModelQuery,
}; };
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey; use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::json; use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
struct MappingPreviewGlobalModel {
id: String,
name: String,
display_name: String,
is_active: bool,
mappings: Vec<String>,
}
pub(crate) async fn run_admin_provider_delete_task( pub(crate) async fn run_admin_provider_delete_task(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
provider_id: &str, provider_id: &str,
@@ -141,10 +150,10 @@ pub(crate) async fn run_admin_provider_delete_task(
Ok(task) Ok(task)
} }
pub(crate) fn public_global_model_mapping_patterns(model: &StoredPublicGlobalModel) -> Vec<String> { pub(crate) fn global_model_mapping_patterns_from_config(
model config: Option<&serde_json::Value>,
.config ) -> Vec<String> {
.as_ref() config
.and_then(serde_json::Value::as_object) .and_then(serde_json::Value::as_object)
.and_then(|config| config.get("model_mappings")) .and_then(|config| config.get("model_mappings"))
.and_then(serde_json::Value::as_array) .and_then(serde_json::Value::as_array)
@@ -211,8 +220,8 @@ pub(crate) async fn build_admin_provider_mapping_preview_payload(
keys.truncate(ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_KEYS); keys.truncate(ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_KEYS);
} }
let public_models = app let admin_models = state
.list_public_global_models(&PublicGlobalModelQuery { .list_admin_global_models(&AdminGlobalModelListQuery {
offset: 0, offset: 0,
limit: ADMIN_PROVIDER_MAPPING_PREVIEW_FETCH_LIMIT, limit: ADMIN_PROVIDER_MAPPING_PREVIEW_FETCH_LIMIT,
is_active: None, is_active: None,
@@ -221,19 +230,56 @@ pub(crate) async fn build_admin_provider_mapping_preview_payload(
.await .await
.ok() .ok()
.unwrap_or_else(|| { .unwrap_or_else(|| {
aether_data_contracts::repository::global_models::StoredPublicGlobalModelPage { aether_data_contracts::repository::global_models::StoredAdminGlobalModelPage {
items: Vec::new(), items: Vec::new(),
total: 0, total: 0,
} }
}) })
.items; .items;
let mut models_with_mappings = public_models let mut models_with_mappings = admin_models
.into_iter() .into_iter()
.filter_map(|model| { .filter_map(|model| {
let mappings = public_global_model_mapping_patterns(&model); let mappings = global_model_mapping_patterns_from_config(model.config.as_ref());
(!mappings.is_empty()).then_some((model, mappings)) (!mappings.is_empty()).then_some(MappingPreviewGlobalModel {
id: model.id,
name: model.name,
display_name: model.display_name,
is_active: model.is_active,
mappings,
})
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if models_with_mappings.is_empty() {
let public_models = app
.list_public_global_models(&PublicGlobalModelQuery {
offset: 0,
limit: ADMIN_PROVIDER_MAPPING_PREVIEW_FETCH_LIMIT,
is_active: None,
search: None,
})
.await
.ok()
.unwrap_or_else(|| {
aether_data_contracts::repository::global_models::StoredPublicGlobalModelPage {
items: Vec::new(),
total: 0,
}
})
.items;
models_with_mappings = public_models
.into_iter()
.filter_map(|model| {
let mappings = global_model_mapping_patterns_from_config(model.config.as_ref());
(!mappings.is_empty()).then_some(MappingPreviewGlobalModel {
id: model.id,
name: model.name.clone(),
display_name: model.display_name.unwrap_or(model.name),
is_active: model.is_active,
mappings,
})
})
.collect::<Vec<_>>();
}
let total_models_with_mappings = models_with_mappings.len(); let total_models_with_mappings = models_with_mappings.len();
let truncated_models = let truncated_models =
total_models_with_mappings.saturating_sub(ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_MODELS); total_models_with_mappings.saturating_sub(ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_MODELS);
@@ -263,10 +309,10 @@ pub(crate) async fn build_admin_provider_mapping_preview_payload(
} }
let mut matching_global_models = Vec::new(); let mut matching_global_models = Vec::new();
for (global_model, mappings) in &models_with_mappings { for global_model in &models_with_mappings {
let mut matched_models = Vec::new(); let mut matched_models = Vec::new();
for allowed_model in &allowed_models { for allowed_model in &allowed_models {
for mapping_pattern in mappings { for mapping_pattern in &global_model.mappings {
if matches_model_mapping_for_models(mapping_pattern, allowed_model) { if matches_model_mapping_for_models(mapping_pattern, allowed_model) {
matched_models.push(json!({ matched_models.push(json!({
"allowed_model": allowed_model, "allowed_model": allowed_model,
@@ -281,10 +327,7 @@ pub(crate) async fn build_admin_provider_mapping_preview_payload(
matching_global_models.push(json!({ matching_global_models.push(json!({
"global_model_id": global_model.id, "global_model_id": global_model.id,
"global_model_name": global_model.name, "global_model_name": global_model.name,
"display_name": global_model "display_name": global_model.display_name,
.display_name
.clone()
.unwrap_or_else(|| global_model.name.clone()),
"is_active": global_model.is_active, "is_active": global_model.is_active,
"matched_models": matched_models, "matched_models": matched_models,
})); }));

View File

@@ -1,7 +1,7 @@
use aether_data_contracts::repository::candidate_selection::{ use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping, StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
}; };
use regex::Regex; use aether_scheduler_core::matches_model_mapping;
use super::GatewayPublicRequestContext; use super::GatewayPublicRequestContext;
@@ -103,10 +103,7 @@ fn candidate_model_names_for_models(
} }
pub(crate) fn matches_model_mapping_for_models(pattern: &str, model_name: &str) -> bool { pub(crate) fn matches_model_mapping_for_models(pattern: &str, model_name: &str) -> bool {
let Ok(compiled) = Regex::new(&format!("^(?:{pattern})$")) else { matches_model_mapping(pattern, model_name)
return false;
};
compiled.is_match(model_name)
} }
fn row_exposes_global_model_for_models( fn row_exposes_global_model_for_models(

View File

@@ -619,8 +619,16 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
None, None,
None, None,
); );
let unlinked_provider = sample_provider("provider-unlinked", "shadow", 30).with_billing_fields(
Some("quota".to_string()),
Some(30.0),
Some(6.0),
None,
None,
None,
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![openai_provider, alt_provider], vec![openai_provider, alt_provider, unlinked_provider],
vec![ vec![
sample_endpoint( sample_endpoint(
"endpoint-openai-chat", "endpoint-openai-chat",
@@ -634,6 +642,12 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
"openai:chat", "openai:chat",
"https://api.alt.example", "https://api.alt.example",
), ),
sample_endpoint(
"endpoint-unlinked-chat",
"provider-unlinked",
"openai:chat",
"https://api.shadow.example",
),
], ],
{ {
let mut primary_key = sample_key( let mut primary_key = sample_key(
@@ -668,7 +682,17 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
mapped_key.allowed_models = Some(json!(["gpt-5-upstream"])); mapped_key.allowed_models = Some(json!(["gpt-5-upstream"]));
mapped_key.rpm_limit = Some(120); mapped_key.rpm_limit = Some(120);
vec![primary_key, mapped_key] let mut unlinked_key = sample_key(
"key-unlinked-routing",
"provider-unlinked",
"openai:chat",
"sk-unlinked-routing-9999",
);
unlinked_key.name = "unlinked".to_string();
unlinked_key.internal_priority = 40;
unlinked_key.allowed_models = Some(json!(["gpt-5-upstream-shadow"]));
vec![primary_key, mapped_key, unlinked_key]
}, },
)); ));
let global_model_repository = Arc::new( let global_model_repository = Arc::new(
@@ -774,13 +798,16 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
let whitelist = payload["all_keys_whitelist"] let whitelist = payload["all_keys_whitelist"]
.as_array() .as_array()
.expect("whitelist array"); .expect("whitelist array");
assert_eq!(whitelist.len(), 2); assert_eq!(whitelist.len(), 3);
assert!(whitelist assert!(whitelist
.iter() .iter()
.any(|item| item["key_id"] == "key-openai-routing")); .any(|item| item["key_id"] == "key-openai-routing"));
assert!(whitelist assert!(whitelist
.iter() .iter()
.any(|item| item["key_id"] == "key-alt-routing")); .any(|item| item["key_id"] == "key-alt-routing"));
assert!(whitelist
.iter()
.any(|item| item["key_id"] == "key-unlinked-routing"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort(); gateway_handle.abort();

View File

@@ -4,7 +4,7 @@ use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping, StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
}; };
use aether_data_contracts::DataLayerError; use aether_data_contracts::DataLayerError;
use regex::Regex; use regex::RegexBuilder;
pub fn resolve_requested_global_model_name( pub fn resolve_requested_global_model_name(
rows: &[StoredMinimalCandidateSelectionRow], rows: &[StoredMinimalCandidateSelectionRow],
@@ -201,7 +201,15 @@ fn capabilities_support_required_capability(
} }
pub fn matches_model_mapping(pattern: &str, model_name: &str) -> bool { pub fn matches_model_mapping(pattern: &str, model_name: &str) -> bool {
let Ok(compiled) = Regex::new(&format!("^(?:{pattern})$")) else { if pattern.eq_ignore_ascii_case(model_name) {
return true;
}
let regex_pattern = format!("^(?:{pattern})$");
let Ok(compiled) = RegexBuilder::new(&regex_pattern)
.case_insensitive(true)
.build()
else {
return false; return false;
}; };
compiled.is_match(model_name) compiled.is_match(model_name)
@@ -253,3 +261,25 @@ pub fn extract_global_priority_for_format(
pub fn normalize_api_format(value: &str) -> String { pub fn normalize_api_format(value: &str) -> String {
value.trim().to_ascii_lowercase() value.trim().to_ascii_lowercase()
} }
#[cfg(test)]
mod tests {
use super::matches_model_mapping;
#[test]
fn model_mapping_match_is_case_insensitive() {
assert!(matches_model_mapping("gpt-4o", "GPT-4O"));
assert!(matches_model_mapping("gpt-5(?:\\.\\d+)?", "GPT-5.1"));
}
#[test]
fn model_mapping_match_is_anchored_to_full_text() {
assert!(matches_model_mapping("gpt-4o", "gpt-4o"));
assert!(!matches_model_mapping("gpt-4o", "gpt-4o-mini"));
}
#[test]
fn invalid_model_mapping_pattern_returns_false() {
assert!(!matches_model_mapping("([a-z", "gpt-4o"));
}
}