mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(®ex_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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user