fix(scheduler): confine model mappings to endpoint and api-format scope

映射限定了端点或 API 格式范围时,默认上游模型名只在该范围内可用;
范围之外整行不再参与候选,避免把上游不认识的默认名发到未开启的端点
(例如仅将 gpt-6-luna 映射到 openai:chat 的行,不再经 claude:messages 回退默认名)。

- 调度核心:可用性解析调整 + 2 条回归单测
- 数据层镜像同步:memory 仓储过滤、Postgres 请求模型过滤 SQL(含断言测试)
This commit is contained in:
hkxiaoyao
2026-10-08 15:12:54 +08:00
parent 911c7f8875
commit 58ec544aac
3 changed files with 219 additions and 39 deletions
@@ -1050,17 +1050,6 @@ fn requested_model_selection_sql() -> String {
)
)
)
OR NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(m.provider_model_mappings) = 'array'
THEN m.provider_model_mappings
ELSE '[]'::jsonb
END
) AS mapping(value)
WHERE mapping.value ->> 'name' = m.provider_model_name
)
)
)
OR (
@@ -1068,17 +1057,6 @@ fn requested_model_selection_sql() -> String {
AND (
m.provider_model_mappings IS NULL
OR jsonb_typeof(m.provider_model_mappings) <> 'array'
OR NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(m.provider_model_mappings) = 'array'
THEN m.provider_model_mappings
ELSE '[]'::jsonb
END
) AS mapping(value)
WHERE mapping.value ->> 'name' = m.provider_model_name
)
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(
@@ -1108,6 +1086,47 @@ fn requested_model_selection_sql() -> String {
)
)
)
OR (
NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(m.provider_model_mappings) = 'array'
THEN m.provider_model_mappings
ELSE '[]'::jsonb
END
) AS mapping(value)
WHERE mapping.value ->> 'name' = m.provider_model_name
)
AND EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(m.provider_model_mappings) = 'array'
THEN m.provider_model_mappings
ELSE '[]'::jsonb
END
) AS mapping(value)
WHERE (
mapping.value -> 'api_formats' IS NULL
OR jsonb_typeof(mapping.value -> 'api_formats') <> 'array'
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text(mapping.value -> 'api_formats') AS fmt(value)
WHERE __AETHER_PROVIDER_MODEL_MAPPING_API_FORMAT_MATCH__
)
)
AND (
mapping.value -> 'endpoint_ids' IS NULL
OR jsonb_typeof(mapping.value -> 'endpoint_ids') <> 'array'
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text(mapping.value -> 'endpoint_ids') AS endpoint(value)
WHERE endpoint.value = pe.id
)
)
)
)
)
)
OR (
@@ -1706,7 +1725,7 @@ mod tests {
let sql = requested_model_selection_sql();
let compatibility = PROVIDER_MODEL_MAPPING_API_FORMAT_MATCH_SQL;
assert_eq!(sql.matches(compatibility).count(), 3);
assert_eq!(sql.matches(compatibility).count(), 4);
assert!(!sql.contains(PROVIDER_MODEL_MAPPING_API_FORMAT_MATCH_MARKER));
assert!(compatibility.contains("LOWER(BTRIM(p.provider_type)) = 'codex'"));
assert!(compatibility.contains("LOWER($4) = 'codex:live'"));
@@ -1730,6 +1749,21 @@ mod tests {
}
}
#[test]
fn requested_model_sql_confines_default_name_to_mapping_scope() {
let sql = requested_model_selection_sql();
// 默认名(provider_model_name)的可用性由映射的端点/格式范围决定:
// 分支 A(全局名匹配)不再单独放行“无默认名映射”的行;
// 分支 B(provider 名匹配)要求默认名映射命中,或映射范围内至少有一条映射命中。
assert_eq!(sql.matches("->> 'name' = m.provider_model_name").count(), 2);
assert_eq!(
sql.matches(PROVIDER_MODEL_MAPPING_API_FORMAT_MATCH_SQL)
.count(),
4
);
}
#[test]
fn candidate_selection_sql_allows_grok_oauth_chat_auth() {
let requested_model_sql = requested_model_selection_sql();
@@ -284,7 +284,12 @@ fn row_default_provider_model_name_available(
return true;
}
}
!has_explicit_default_mapping
if has_explicit_default_mapping {
return false;
}
// 与调度核心保持一致:映射一旦限定了端点或 API 格式范围,
// 默认模型名就只在范围内可用;范围之外整行不再匹配。
row_mapping_matches_scope(row, api_format)
}
fn row_mapping_matches_scope(row: &StoredMinimalCandidateSelectionRow, api_format: &str) -> bool {
@@ -691,6 +696,45 @@ mod tests {
assert_eq!(rows[0].endpoint_id, "endpoint-openai");
}
#[tokio::test]
async fn requested_model_filter_confines_rows_to_mapping_endpoint_scope() {
// 场景:映射只把上游名绑定到 openai:chat 端点;
// 其他端点(claude:messages)不应回退到默认名,整行不可匹配。
let mut selected = sample_row("provider-1", "openai:chat", "deepseek-flash", 10);
selected.endpoint_id = "endpoint-chat".to_string();
selected.model_provider_model_name = "deepseek-flash".to_string();
selected.model_provider_model_mappings = Some(vec![StoredProviderModelMapping {
name: "gpt-6-luna".to_string(),
priority: 1,
api_formats: None,
endpoint_ids: Some(vec!["endpoint-chat".to_string()]),
operations: None,
}]);
let mut scoped_out = selected.clone();
scoped_out.provider_id = "provider-2".to_string();
scoped_out.endpoint_id = "endpoint-claude".to_string();
scoped_out.endpoint_api_format = "claude:messages".to_string();
scoped_out.key_id = "key-provider-2".to_string();
scoped_out.key_api_formats = Some(vec!["claude:messages".to_string()]);
let repository =
InMemoryMinimalCandidateSelectionReadRepository::seed(vec![scoped_out, selected]);
let rows = repository
.list_for_exact_api_format_and_requested_model("claude:messages", "deepseek-flash")
.await
.expect("list should succeed");
assert!(rows.is_empty());
let rows = repository
.list_for_exact_api_format_and_requested_model("openai:chat", "deepseek-flash")
.await
.expect("list should succeed");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].endpoint_id, "endpoint-chat");
}
#[tokio::test]
async fn requested_model_page_returns_requested_slice_only() {
let mut rows = Vec::new();