fix(model-directives): harden suffix configuration

This commit is contained in:
elky
2026-07-22 14:19:09 +08:00
parent 713010fa0a
commit 856605defa
12 changed files with 1464 additions and 105 deletions
+40 -2
View File
@@ -124,8 +124,11 @@ impl ModelDirectivePolicySnapshot {
Some(suffixes) => suffixes Some(suffixes) => suffixes
.iter() .iter()
.filter(|suffix| { .filter(|suffix| {
model_directive_suffix_has_builtin_mapping(suffix) if model_directive_suffix_has_builtin_mapping(suffix) {
|| mappings.is_some_and(|mappings| mappings.contains_key(*suffix)) model_directive_builtin_suffix_supported_for_api_format(&api_format, suffix)
} else {
mappings.is_some_and(|mappings| mappings.contains_key(*suffix))
}
}) })
.map(String::as_str) .map(String::as_str)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
@@ -159,6 +162,12 @@ impl ModelDirectivePolicySnapshot {
} }
} }
fn model_directive_builtin_suffix_supported_for_api_format(api_format: &str, suffix: &str) -> bool {
default_model_directive_suffixes(api_format)
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(suffix))
}
fn model_directive_policy_snapshot_from_config_reads<E>( fn model_directive_policy_snapshot_from_config_reads<E>(
enabled: Result<Option<serde_json::Value>, E>, enabled: Result<Option<serde_json::Value>, E>,
settings: Result<Option<serde_json::Value>, E>, settings: Result<Option<serde_json::Value>, E>,
@@ -656,6 +665,35 @@ mod tests {
); );
} }
#[test]
fn model_directives_search_does_not_enable_the_unsupported_fast_suffix() {
let default_snapshot =
ModelDirectivePolicySnapshot::from_config_values(Some(&json!(true)), None);
assert!(default_snapshot
.resolve_reasoning("openai:responses", Some("gpt-5.4-fast"))
.enabled());
assert!(!default_snapshot
.resolve_reasoning("openai:search", Some("gpt-5.4-fast"))
.enabled());
let explicitly_configured = ModelDirectivePolicySnapshot::from_config_values(
Some(&json!(true)),
Some(&json!({
"reasoning_effort": {
"api_formats": {
"openai:search": {
"suffixes": ["fast"],
"mappings": {}
}
}
}
})),
);
assert!(!explicitly_configured
.resolve_reasoning("openai:search", Some("gpt-5.4-fast"))
.enabled());
}
#[test] #[test]
fn openai_reasoning_defaults_cover_the_complete_effort_contract() { fn openai_reasoning_defaults_cover_the_complete_effort_contract() {
for effort in ["none", "minimal", "low", "medium", "high", "xhigh"] { for effort in ["none", "minimal", "low", "medium", "high", "xhigh"] {
+184
View File
@@ -2101,6 +2101,75 @@ fn normalize_optional_bounded_string(
} }
} }
fn validate_model_directives_config_value(value: &Value) -> Result<(), ()> {
let root = value.as_object().ok_or(())?;
let reasoning = root
.get("reasoning_effort")
.and_then(Value::as_object)
.ok_or(())?;
if reasoning
.get("enabled")
.is_some_and(|value| !value.is_boolean())
{
return Err(());
}
let Some(api_formats) = reasoning.get("api_formats") else {
return Ok(());
};
let api_formats = api_formats.as_object().ok_or(())?;
let mut canonical_api_formats = BTreeSet::new();
for (api_format, raw_config) in api_formats {
if api_format.trim().is_empty() {
return Err(());
}
let canonical_api_format = aether_ai_formats::normalize_api_format_alias(api_format);
if !canonical_api_formats.insert(canonical_api_format) {
return Err(());
}
if raw_config.is_boolean() {
continue;
}
let config = raw_config.as_object().ok_or(())?;
if config
.get("enabled")
.is_some_and(|value| !value.is_boolean())
{
return Err(());
}
if let Some(suffixes) = config.get("suffixes") {
let suffixes = suffixes.as_array().ok_or(())?;
let mut canonical_suffixes = BTreeSet::new();
for suffix in suffixes {
let suffix = suffix.as_str().map(str::trim).ok_or(())?;
if suffix.is_empty()
|| suffix.starts_with('-')
|| suffix.ends_with('-')
|| !canonical_suffixes.insert(suffix.to_ascii_lowercase())
{
return Err(());
}
}
}
if let Some(mappings) = config.get("mappings") {
let mappings = mappings.as_object().ok_or(())?;
let mut canonical_suffixes = BTreeSet::new();
for (suffix, mapping) in mappings {
let suffix = suffix.trim();
if suffix.is_empty()
|| suffix.starts_with('-')
|| suffix.ends_with('-')
|| !canonical_suffixes.insert(suffix.to_ascii_lowercase())
|| !mapping.is_object()
{
return Err(());
}
}
}
}
Ok(())
}
pub fn parse_admin_system_config_update( pub fn parse_admin_system_config_update(
requested_key: &str, requested_key: &str,
request_body: &[u8], request_body: &[u8],
@@ -2155,6 +2224,7 @@ pub fn parse_admin_system_config_update(
match normalized_key.as_str() { match normalized_key.as_str() {
"cyber_continue_failover" "cyber_continue_failover"
| "enable_model_directives"
| "module.important_notification.enabled" | "module.important_notification.enabled"
| "module.important_notification.email_enabled" | "module.important_notification.email_enabled"
| "module.server_chan_push.enabled" | "module.server_chan_push.enabled"
@@ -2246,6 +2316,18 @@ pub fn parse_admin_system_config_update(
} }
}; };
} }
"model_directives" => {
if value.is_null() {
value = aether_ai_formats::default_model_directives_config();
} else {
validate_model_directives_config_value(&value).map_err(|_| {
(
http::StatusCode::BAD_REQUEST,
json!({ "detail": "模型后缀参数配置格式无效" }),
)
})?;
}
}
"module.chat_pii_redaction.enabled" => match value.as_bool() { "module.chat_pii_redaction.enabled" => match value.as_bool() {
Some(enabled) => value = json!(enabled), Some(enabled) => value = json!(enabled),
None if value.is_null() => { None if value.is_null() => {
@@ -3409,6 +3491,108 @@ mod tests {
.is_err()); .is_err());
} }
#[test]
fn model_directives_update_accepts_legacy_and_current_config_shapes() {
for body in [
r#"{
"value": {
"reasoning_effort": {
"enabled": true,
"api_formats": {
"openai:responses": {
"enabled": true,
"mappings": {
"low": { "reasoning": { "effort": "low" } }
}
}
}
}
}
}"#,
r#"{
"value": {
"reasoning_effort": {
"api_formats": {
"openai:responses": {
"suffixes": ["low", "VendorFuture"],
"mappings": {
"VendorFuture": { "reasoning": { "context": "all_turns" } }
}
}
}
}
}
}"#,
r#"{
"value": {
"reasoning_effort": {
"api_formats": {
"/v1/responses": {
"suffixes": ["low"],
"mappings": {}
}
}
}
}
}"#,
] {
let update = parse_admin_system_config_update("model_directives", body.as_bytes())
.expect("valid model directive config should parse");
assert!(update.value["reasoning_effort"].is_object());
}
let reset = parse_admin_system_config_update("model_directives", br#"{"value":null}"#)
.expect("null should reset model directives to defaults");
assert_eq!(
reset.value,
aether_ai_formats::default_model_directives_config()
);
}
#[test]
fn model_directives_update_rejects_unsafe_mapping_shapes() {
for body in [
r#"{"value":[]}"#,
r#"{"value":{"reasoning_effort":true}}"#,
r#"{"value":{"reasoning_effort":{"enabled":"true"}}}"#,
r#"{"value":{"reasoning_effort":{"api_formats":[]}}}"#,
r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":{"suffixes":["low",42]}}}}}"#,
r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":{"mappings":{"low":"replace-body"}}}}}}"#,
] {
let error = parse_admin_system_config_update("model_directives", body.as_bytes())
.expect_err("invalid model directive config should fail");
assert_eq!(error.0, http::StatusCode::BAD_REQUEST);
assert_eq!(error.1["detail"], "模型后缀参数配置格式无效");
}
}
#[test]
fn model_directives_update_rejects_ambiguous_aliases_and_suffixes() {
for body in [
r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":true,"/v1/responses":false}}}}"#,
r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":{"suffixes":["low","LOW"]}}}}}"#,
r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":{"mappings":{"VendorFuture":{},"vendorfuture":{}}}}}}}"#,
] {
let error = parse_admin_system_config_update("model_directives", body.as_bytes())
.expect_err("ambiguous model directive config should fail");
assert_eq!(error.0, http::StatusCode::BAD_REQUEST);
assert_eq!(error.1["detail"], "模型后缀参数配置格式无效");
}
}
#[test]
fn enable_model_directives_update_requires_a_boolean() {
let update =
parse_admin_system_config_update("enable_model_directives", br#"{"value":true}"#)
.expect("boolean model directives flag should parse");
assert_eq!(update.value, json!(true));
assert!(parse_admin_system_config_update(
"enable_model_directives",
br#"{"value":"true"}"#,
)
.is_err());
}
#[test] #[test]
fn legacy_notification_email_config_key_normalizes_to_important_notification() { fn legacy_notification_email_config_key_normalizes_to_important_notification() {
assert_eq!( assert_eq!(
@@ -11,6 +11,9 @@ pub const MODEL_DIRECTIVE_API_FORMATS: [&str; 6] = [
pub const OPENAI_MODEL_DIRECTIVE_SUFFIXES: [&str; 9] = [ pub const OPENAI_MODEL_DIRECTIVE_SUFFIXES: [&str; 9] = [
"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "fast", "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "fast",
]; ];
const OPENAI_SEARCH_MODEL_DIRECTIVE_SUFFIXES: [&str; 8] = [
"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra",
];
pub const CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES: [&str; 5] = pub const CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES: [&str; 5] =
["low", "medium", "high", "xhigh", "max"]; ["low", "medium", "high", "xhigh", "max"];
@@ -434,9 +437,10 @@ pub fn default_model_directive_mapping_patch(
pub fn default_model_directive_suffixes(provider_api_format: &str) -> &'static [&'static str] { pub fn default_model_directive_suffixes(provider_api_format: &str) -> &'static [&'static str] {
match crate::normalize_api_format_alias(provider_api_format).as_str() { match crate::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" | "openai:responses" | "openai:responses:compact" | "openai:search" => { "openai:chat" | "openai:responses" | "openai:responses:compact" => {
&OPENAI_MODEL_DIRECTIVE_SUFFIXES &OPENAI_MODEL_DIRECTIVE_SUFFIXES
} }
"openai:search" => &OPENAI_SEARCH_MODEL_DIRECTIVE_SUFFIXES,
"claude:messages" | "gemini:generate_content" => &CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES, "claude:messages" | "gemini:generate_content" => &CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES,
_ => &[], _ => &[],
} }
@@ -551,7 +555,6 @@ fn apply_service_tier_override(
"service_tier", "service_tier",
tier.as_openai_value(), tier.as_openai_value(),
), ),
"openai:search" => Some(()),
_ => None, _ => None,
} }
} }
@@ -970,6 +973,8 @@ mod tests {
json!(default_model_directive_suffixes(api_format)) json!(default_model_directive_suffixes(api_format))
); );
} }
assert!(default_model_directive_suffixes("openai:responses").contains(&"fast"));
assert!(!default_model_directive_suffixes("openai:search").contains(&"fast"));
} }
#[test] #[test]
@@ -1266,6 +1271,17 @@ mod tests {
) )
.expect("directive should apply"); .expect("directive should apply");
assert_eq!(responses["service_tier"], "priority"); assert_eq!(responses["service_tier"], "priority");
let mut search = json!({"model": "gpt-5-upstream"});
let original = search.clone();
assert!(apply_model_directive_overrides_from_model(
&mut search,
"openai:search",
"gpt-5-upstream",
"gpt-5.4-fast",
)
.is_none());
assert_eq!(search, original);
} }
#[test] #[test]
+21
View File
@@ -2322,6 +2322,27 @@ const legacyPhraseEnglishMessages: Array<[string, string]> = [
['发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。', 'Replace sensitive information in chat messages with placeholders before sending to providers, then restore it before returning to clients.'], ['发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。', 'Replace sensitive information in chat messages with placeholders before sending to providers, then restore it before returning to clients.'],
['模型后缀参数', 'Model suffix parameters'], ['模型后缀参数', 'Model suffix parameters'],
['允许通过模型名称后缀覆盖推理参数或服务等级', 'Allow model name suffixes to override inference parameters or service tier'], ['允许通过模型名称后缀覆盖推理参数或服务等级', 'Allow model name suffixes to override inference parameters or service tier'],
['允许通过模型名后缀覆盖推理参数或服务层级', 'Allow model name suffixes to override reasoning parameters or service tier'],
['模块状态', 'Module status'],
['推理参数映射', 'Reasoning parameter mappings'],
['按 API 端点配置模型名后缀及其请求参数映射。', 'Configure model suffixes and request parameter mappings by API endpoint.'],
['模型后缀', 'Model suffix'],
['有效映射', 'Effective mapping'],
['后缀状态', 'Suffix status'],
['自定义覆盖(已合并显示)', 'Custom override (merged view)'],
['内置映射预览(运行时按目标模型调整)', 'Built-in mapping preview (adjusted for the target model at runtime)'],
['尚未配置映射', 'Mapping not configured'],
['保存非空映射后启用此后缀', 'Save a non-empty mapping to enable this suffix'],
['自定义映射', 'Custom mapping'],
['不启用推理', 'Disable reasoning'],
['模型支持时使用最低推理投入', 'Use minimal reasoning effort when supported'],
['低推理投入', 'Low reasoning effort'],
['中等推理投入', 'Medium reasoning effort'],
['高推理投入', 'High reasoning effort'],
['超高推理投入', 'Extra-high reasoning effort'],
['模型支持时使用最大推理投入', 'Use maximum reasoning effort when supported'],
['Codex Ultra 推理预设,仅支持兼容的 Codex 模型', 'Codex Ultra reasoning preset for compatible Codex models'],
['Fast 服务层级', 'Fast service tier'],
['管理 Gemini Files API 上传文件,支持文件上传 / 查看和删除', 'Manage files uploaded through Gemini Files API, including upload, view, and delete'], ['管理 Gemini Files API 上传文件,支持文件上传 / 查看和删除', 'Manage files uploaded through Gemini Files API, including upload, view, and delete'],
['至少启用一个具有「Gemini 文件 API」能力 Key', 'Enable at least one key with Gemini Files API capability'], ['至少启用一个具有「Gemini 文件 API」能力 Key', 'Enable at least one key with Gemini Files API capability'],
['第三方推送服务,用于通知服务的 Bark 渠道', 'Third-party push service for the Bark notification channel'], ['第三方推送服务,用于通知服务的 Bark 渠道', 'Third-party push service for the Bark notification channel'],
+43 -1
View File
@@ -910,6 +910,48 @@ export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; descripti
{ key: 'default_cache_ttl', value: 3600, description: '默认缓存 TTL(秒)' }, { key: 'default_cache_ttl', value: 3600, description: '默认缓存 TTL(秒)' },
{ key: 'fallback_enabled', value: true, description: '是否启用故障转移' }, { key: 'fallback_enabled', value: true, description: '是否启用故障转移' },
{ key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' }, { key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' },
{ key: 'enable_model_directives', value: true, description: '模型后缀参数模块开关' },
{
key: 'model_directives',
value: {
reasoning_effort: {
enabled: true,
api_formats: {
'openai:chat': {
enabled: true,
suffixes: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'fast'],
mappings: {},
},
'openai:responses': {
enabled: true,
suffixes: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'fast'],
mappings: {},
},
'openai:responses:compact': {
enabled: true,
suffixes: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'fast'],
mappings: {},
},
'openai:search': {
enabled: true,
suffixes: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
mappings: {},
},
'claude:messages': {
enabled: true,
suffixes: ['low', 'medium', 'high', 'xhigh', 'max'],
mappings: {},
},
'gemini:generate_content': {
enabled: true,
suffixes: ['low', 'medium', 'high', 'xhigh', 'max'],
mappings: {},
},
},
},
},
description: '模型后缀参数配置',
},
{ key: 'module.important_notification.enabled', value: false, description: '通知服务总开关' }, { key: 'module.important_notification.enabled', value: false, description: '通知服务总开关' },
{ key: 'module.important_notification.email_enabled', value: false, description: '通知服务邮件推送开关' }, { key: 'module.important_notification.email_enabled', value: false, description: '通知服务邮件推送开关' },
{ key: 'module.important_notification.email_recipients', value: '', description: '通知服务管理员收件人' }, { key: 'module.important_notification.email_recipients', value: '', description: '通知服务管理员收件人' },
@@ -1085,7 +1127,7 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
{ {
name: 'model_directives', name: 'model_directives',
display_name: '模型后缀参数', display_name: '模型后缀参数',
description: '允许通过模型名后缀覆盖推理参数', description: '允许通过模型名后缀覆盖推理参数或服务层级',
category: 'integration', category: 'integration',
available: true, available: true,
enabled: true, enabled: true,
+29
View File
@@ -2646,6 +2646,17 @@ function refreshMockS3BackupModuleStatus() {
} }
} }
function refreshMockModelDirectivesModuleStatus() {
const moduleStatus = MOCK_MODULE_STATUSES.model_directives
if (!moduleStatus) return
const enabled = mockSystemConfigValue('enable_model_directives') === true
MOCK_MODULE_STATUSES.model_directives = {
...moduleStatus,
enabled,
active: moduleStatus.available && enabled && moduleStatus.config_validated,
}
}
// 系统配置详情 // 系统配置详情
registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_config, params) => { registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_config, params) => {
await delay() await delay()
@@ -2689,6 +2700,9 @@ registerDynamicRoute('PUT', '/api/admin/system/configs/:configKey', async (confi
if (key.startsWith('backup_s3_')) { if (key.startsWith('backup_s3_')) {
refreshMockS3BackupModuleStatus() refreshMockS3BackupModuleStatus()
} }
if (key === 'enable_model_directives') {
refreshMockModelDirectivesModuleStatus()
}
return createMockResponse(entry) return createMockResponse(entry)
}) })
@@ -2727,6 +2741,21 @@ registerDynamicRoute('PUT', '/api/admin/modules/status/:moduleName/enabled', asy
} }
const body = JSON.parse(config.data || '{}') as { enabled?: boolean } const body = JSON.parse(config.data || '{}') as { enabled?: boolean }
const enabled = body.enabled === true const enabled = body.enabled === true
if (params.moduleName === 'model_directives') {
const index = MOCK_SYSTEM_CONFIGS.findIndex(item => item.key === 'enable_model_directives')
const entry = {
key: 'enable_model_directives',
value: enabled,
description: '模型后缀参数模块开关',
}
if (index === -1) {
MOCK_SYSTEM_CONFIGS.push(entry)
} else {
MOCK_SYSTEM_CONFIGS[index] = { ...MOCK_SYSTEM_CONFIGS[index], ...entry }
}
refreshMockModelDirectivesModuleStatus()
return createMockResponse(MOCK_MODULE_STATUSES.model_directives)
}
if (params.moduleName === 's3_backup') { if (params.moduleName === 's3_backup') {
const index = MOCK_SYSTEM_CONFIGS.findIndex(item => item.key === 'backup_s3_enabled') const index = MOCK_SYSTEM_CONFIGS.findIndex(item => item.key === 'backup_s3_enabled')
const entry = { const entry = {
@@ -1,21 +1,32 @@
<template> <template>
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="模型参数指令" title="模型后缀参数"
description="统一管理模型名后缀对应的参数映射" description="允许通过模型名后缀覆盖推理参数或服务层级"
> >
<template #actions> <template #actions>
<Button <div class="flex flex-wrap items-center justify-end gap-3">
variant="outline" <div class="flex h-9 items-center gap-2 rounded-md border px-3">
:disabled="loading" <span class="text-sm text-muted-foreground">模块状态</span>
@click="loadConfig" <Switch
> :model-value="moduleEnabled"
<RefreshCw :disabled="loading || moduleSaving"
class="w-4 h-4 mr-2" aria-label="启用模型后缀参数模块"
:class="{ 'animate-spin': loading }" @update:model-value="setModuleEnabled"
/> />
刷新 </div>
</Button> <Button
variant="outline"
:disabled="loading || saving || moduleSaving"
@click="loadConfig"
>
<RefreshCw
class="w-4 h-4 mr-2"
:class="{ 'animate-spin': loading }"
/>
刷新
</Button>
</div>
</template> </template>
</PageHeader> </PageHeader>
@@ -26,7 +37,7 @@
> >
<ModelDirectivesPanel <ModelDirectivesPanel
:config="modelDirectivesConfig" :config="modelDirectivesConfig"
:loading="loading || saving" :loading="loading || saving || moduleSaving"
@save="saveConfig" @save="saveConfig"
/> />
</Card> </Card>
@@ -39,8 +50,10 @@ import { onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next' import { RefreshCw } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import Switch from '@/components/ui/switch.vue'
import { PageContainer, PageHeader } from '@/components/layout' import { PageContainer, PageHeader } from '@/components/layout'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import { useModuleStore } from '@/stores/modules'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { getErrorMessage } from '@/types/api-error' import { getErrorMessage } from '@/types/api-error'
@@ -52,20 +65,33 @@ import {
} from './module-management/modelDirectivesConfig' } from './module-management/modelDirectivesConfig'
const { success, error } = useToast() const { success, error } = useToast()
const moduleStore = useModuleStore()
const modelDirectivesConfig = ref<ModelDirectivesConfig>(createDefaultModelDirectivesConfig()) const modelDirectivesConfig = ref<ModelDirectivesConfig>(createDefaultModelDirectivesConfig())
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const moduleSaving = ref(false)
const moduleEnabled = ref(false)
async function loadConfig() { async function loadConfig() {
loading.value = true loading.value = true
try { try {
const response = await adminApi.getSystemConfig('model_directives') const [configResult, modulesResult] = await Promise.allSettled([
const normalized = normalizeModelDirectivesConfig(response.value) adminApi.getSystemConfig('model_directives'),
moduleStore.fetchModules(),
])
if (modulesResult.status === 'fulfilled') {
moduleEnabled.value = modulesResult.value.model_directives?.enabled === true
} else {
error('获取模型后缀参数模块状态失败')
log.error('获取模型后缀参数模块状态失败:', modulesResult.reason)
}
if (configResult.status === 'rejected') throw configResult.reason
const normalized = normalizeModelDirectivesConfig(configResult.value.value)
modelDirectivesConfig.value = normalized modelDirectivesConfig.value = normalized
} catch (err) { } catch (err) {
error('获取模型参数指令配置失败') error('获取模型后缀参数配置失败')
log.error('获取模型参数指令配置失败:', err) log.error('获取模型后缀参数配置失败:', err)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -78,18 +104,32 @@ async function saveConfig(nextConfig: ModelDirectivesConfig) {
await adminApi.updateSystemConfig( await adminApi.updateSystemConfig(
'model_directives', 'model_directives',
normalized, normalized,
'模型参数指令配置' '模型后缀参数配置'
) )
modelDirectivesConfig.value = normalized modelDirectivesConfig.value = normalized
success('模型参数指令配置已保存') success('模型后缀参数配置已保存')
} catch (err) { } catch (err) {
error(getErrorMessage(err, '保存模型参数指令配置失败')) error(getErrorMessage(err, '保存模型后缀参数配置失败'))
log.error('保存模型参数指令配置失败:', err) log.error('保存模型后缀参数配置失败:', err)
} finally { } finally {
saving.value = false saving.value = false
} }
} }
async function setModuleEnabled(value: boolean) {
moduleSaving.value = true
try {
await moduleStore.setEnabled('model_directives', Boolean(value))
moduleEnabled.value = moduleStore.modules.model_directives?.enabled === true
success(moduleEnabled.value ? '模型后缀参数模块已启用' : '模型后缀参数模块已禁用')
} catch (err) {
error(getErrorMessage(err, '更新模型后缀参数模块状态失败'))
log.error('更新模型后缀参数模块状态失败:', err)
} finally {
moduleSaving.value = false
}
}
onMounted(() => { onMounted(() => {
loadConfig() loadConfig()
}) })
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import ModelDirectivesManagement from '../ModelDirectivesManagement.vue'
const apiMocks = vi.hoisted(() => ({
getSystemConfig: vi.fn(),
updateSystemConfig: vi.fn(),
}))
const moduleStoreMocks = vi.hoisted(() => ({
modules: {} as Record<string, { enabled: boolean }>,
fetchModules: vi.fn(),
setEnabled: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
vi.mock('@/api/admin', () => ({
adminApi: {
getSystemConfig: apiMocks.getSystemConfig,
updateSystemConfig: apiMocks.updateSystemConfig,
},
}))
vi.mock('@/stores/modules', () => ({
useModuleStore: () => moduleStoreMocks,
}))
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMocks }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } }))
vi.mock('@/components/layout', async () => {
const { defineComponent, h } = await import('vue')
const component = (tag: string) => defineComponent({
setup(_, { slots }) {
return () => h(tag, [slots.default?.(), slots.actions?.()])
},
})
return { PageContainer: component('main'), PageHeader: component('header') }
})
vi.mock('@/components/ui/button.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('button', attrs, slots.default?.())
},
}) }
})
vi.mock('@/components/ui/card.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ setup(_, { slots }) {
return () => h('section', slots.default?.())
} }) }
})
vi.mock('@/components/ui/switch.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({
inheritAttrs: false,
props: { modelValue: Boolean, disabled: Boolean },
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('button', {
...attrs,
disabled: props.disabled,
'aria-pressed': props.modelValue,
onClick: () => emit('update:modelValue', !props.modelValue),
})
},
}) }
})
vi.mock('@/views/admin/module-management/ModelDirectivesPanel.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({
props: { config: { type: Object, required: true }, loading: Boolean },
emits: ['save'],
setup(props, { emit }) {
return () => h('div', [
h('div', {
'data-testid': 'directive-panel',
'data-reasoning-enabled': String(
(props.config as { reasoning_effort?: { enabled?: boolean } }).reasoning_effort?.enabled,
),
}),
h('button', {
'data-testid': 'save-config',
onClick: () => emit('save', {
...(props.config as Record<string, unknown>),
reasoning_effort: {
...((props.config as { reasoning_effort: Record<string, unknown> }).reasoning_effort),
enabled: false,
},
}),
}),
])
},
}) }
})
let app: App | undefined
let root: HTMLElement | undefined
async function flushPromises() {
await Promise.resolve()
await Promise.resolve()
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
apiMocks.getSystemConfig.mockResolvedValue({
key: 'model_directives',
value: { reasoning_effort: { enabled: true, api_formats: {} } },
})
apiMocks.updateSystemConfig.mockResolvedValue(undefined)
moduleStoreMocks.modules = { model_directives: { enabled: false } }
moduleStoreMocks.fetchModules.mockResolvedValue(moduleStoreMocks.modules)
moduleStoreMocks.setEnabled.mockImplementation(async (_name: string, enabled: boolean) => {
moduleStoreMocks.modules = { model_directives: { enabled } }
return true
})
})
afterEach(() => {
app?.unmount()
root?.remove()
app = undefined
root = undefined
})
describe('ModelDirectivesManagement', () => {
it('binds the page switch to the real module status, not the reasoning sub-setting', async () => {
root = document.createElement('div')
document.body.appendChild(root)
app = createApp(defineComponent({
setup: () => () => h(ModelDirectivesManagement),
}))
app.mount(root)
await flushPromises()
const moduleSwitch = root.querySelector(
'button[aria-label="启用模型后缀参数模块"]',
) as HTMLButtonElement
expect(moduleSwitch.getAttribute('aria-pressed')).toBe('false')
expect(root.querySelector('[data-testid="directive-panel"]')
?.getAttribute('data-reasoning-enabled')).toBe('true')
moduleSwitch.click()
await flushPromises()
expect(moduleStoreMocks.setEnabled).toHaveBeenCalledWith('model_directives', true)
expect(moduleSwitch.getAttribute('aria-pressed')).toBe('true')
})
it('keeps the configuration visible when only the module status request fails', async () => {
moduleStoreMocks.fetchModules.mockRejectedValueOnce(new Error('module status unavailable'))
root = document.createElement('div')
document.body.appendChild(root)
app = createApp(defineComponent({
setup: () => () => h(ModelDirectivesManagement),
}))
app.mount(root)
await flushPromises()
expect(root.querySelector('[data-testid="directive-panel"]')
?.getAttribute('data-reasoning-enabled')).toBe('true')
expect(toastMocks.error).toHaveBeenCalledWith('获取模型后缀参数模块状态失败')
})
it('applies the module status even when the configuration request fails', async () => {
apiMocks.getSystemConfig.mockRejectedValueOnce(new Error('configuration unavailable'))
moduleStoreMocks.modules = { model_directives: { enabled: true } }
moduleStoreMocks.fetchModules.mockResolvedValueOnce(moduleStoreMocks.modules)
root = document.createElement('div')
document.body.appendChild(root)
app = createApp(defineComponent({
setup: () => () => h(ModelDirectivesManagement),
}))
app.mount(root)
await flushPromises()
const moduleSwitch = root.querySelector(
'button[aria-label="启用模型后缀参数模块"]',
) as HTMLButtonElement
expect(moduleSwitch.getAttribute('aria-pressed')).toBe('true')
expect(toastMocks.error).toHaveBeenCalledWith('获取模型后缀参数配置失败')
})
})
@@ -3,27 +3,27 @@
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 class="text-base font-semibold"> <h3 class="text-base font-semibold">
模型参数指令 推理参数映射
</h3> </h3>
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">
各端点可分别启用正式参数映射,并在必要时覆盖内置值。 按 API 端点配置模型名后缀及其请求参数映射。
</p> </p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<Switch <Switch
:model-value="config.reasoning_effort.enabled" :model-value="config.reasoning_effort.enabled"
:disabled="loading" :disabled="loading"
aria-label="启用模型参数指令" aria-label="启用推理参数映射"
@update:model-value="onReasoningEnabledChange" @update:model-value="onReasoningEnabledChange"
/> />
</div> </div>
</div> </div>
<div class="overflow-hidden rounded-lg border"> <div class="overflow-hidden rounded-lg border">
<div class="hidden gap-2 border-b bg-muted/40 px-4 py-3 text-xs font-medium text-muted-foreground lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]"> <div class="hidden gap-3 border-b bg-muted/40 px-4 py-3 text-xs font-medium text-muted-foreground lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(14rem,1fr)_minmax(0,2fr)_auto]">
<div>API 端点</div> <div>API 端点</div>
<div>模型指令</div> <div>模型后缀</div>
<div>自定义映射</div> <div>有效映射</div>
<div class="text-right"> <div class="text-right">
状态 状态
</div> </div>
@@ -32,61 +32,120 @@
<div <div
v-for="format in MODEL_DIRECTIVE_API_FORMATS" v-for="format in MODEL_DIRECTIVE_API_FORMATS"
:key="format.key" :key="format.key"
class="grid grid-cols-1 items-center gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]" class="grid grid-cols-1 items-start gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,1fr)_minmax(0,2fr)_auto]"
> >
<div> <div class="min-w-0">
<div class="mb-1 text-xs font-medium text-muted-foreground lg:hidden"> <div class="mb-1 text-xs font-medium text-muted-foreground lg:hidden">
API 端点 API 端点
</div> </div>
<div class="text-sm font-medium"> <div class="text-sm font-medium">
{{ format.label }} {{ format.label }}
</div> </div>
<code class="mt-1 block text-xs text-muted-foreground"> <code class="mt-1 block max-w-full !whitespace-normal break-all text-xs text-muted-foreground">
{{ format.parameter }} {{ format.parameter }}
</code> </code>
</div> </div>
<div> <div>
<div class="mb-1 text-xs font-medium text-muted-foreground lg:hidden"> <div class="mb-1 text-xs font-medium text-muted-foreground lg:hidden">
模型指令 模型后缀
</div> </div>
<Select <div class="flex items-center gap-1.5">
:model-value="selectedSuffixes[format.key] ?? 'low'" <Select
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled" :model-value="selectedSuffixes[format.key] ?? PREFERRED_MODEL_DIRECTIVE_SUFFIX"
@update:model-value="value => selectSuffix(format.key, value)" :disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
> @update:model-value="value => selectSuffix(format.key, value)"
<SelectTrigger
class="h-9 w-full rounded-lg lg:w-32"
:aria-label="`${format.label} 模型指令`"
> >
<SelectValue /> <SelectTrigger
</SelectTrigger> class="h-9 min-w-0 flex-1 rounded-lg"
<SelectContent> :aria-label="`${format.label} 模型后缀`"
<SelectItem
v-for="suffix in availableSuffixes(format.key)"
:key="suffix"
:value="suffix"
:text-value="suffixLabel(suffix)"
> >
{{ suffixLabel(suffix) }} <SelectValue />
</SelectItem> </SelectTrigger>
</SelectContent> <SelectContent>
</Select> <SelectItem
v-for="suffix in availableSuffixes(format.key)"
:key="suffix"
:value="suffix"
:text-value="suffixLabel(suffix)"
>
{{ suffixLabel(suffix) }}
</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
class="h-9 w-9 shrink-0 text-muted-foreground"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
:title="`新增 ${format.label} 自定义后缀`"
:aria-label="`新增 ${format.label} 自定义后缀`"
@click="startAddingSuffix(format.key)"
>
<Plus class="h-4 w-4" />
</Button>
</div>
<div
v-if="addingSuffixes.has(format.key)"
class="mt-2"
>
<div class="flex items-center gap-1.5">
<Input
v-model="customSuffixDrafts[format.key]"
class="h-9 min-w-0 flex-1 font-mono text-xs"
:disabled="loading"
:aria-label="`${format.label} 自定义后缀名称`"
placeholder="vendor-option"
@keyup.enter="addCustomSuffix(format.key)"
@keyup.esc="cancelAddingSuffix(format.key)"
@update:model-value="clearCustomSuffixError(format.key)"
/>
<Button
size="icon"
variant="ghost"
class="h-9 w-9 shrink-0"
:disabled="loading"
:title="`添加 ${format.label} 自定义后缀`"
:aria-label="`添加 ${format.label} 自定义后缀`"
@click="addCustomSuffix(format.key)"
>
<Check class="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
class="h-9 w-9 shrink-0 text-muted-foreground"
:disabled="loading"
:title="`取消新增 ${format.label} 自定义后缀`"
:aria-label="`取消新增 ${format.label} 自定义后缀`"
@click="cancelAddingSuffix(format.key)"
>
<X class="h-4 w-4" />
</Button>
</div>
<p
v-if="customSuffixErrors[format.key]"
class="mt-1 text-xs text-destructive"
role="alert"
>
{{ customSuffixErrors[format.key] }}
</p>
</div>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ selectedSuffixDescription(format.key) }} {{ selectedSuffixDescription(format.key) }}
</p> </p>
<div class="mt-2 flex items-center justify-between gap-2"> <div class="mt-2 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">启用此指令</span> <span class="text-xs text-muted-foreground">后缀状态</span>
<Switch <Switch
:model-value="selectedSuffixEnabled(format.key)" :model-value="selectedSuffixEnabled(format.key)"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled" :disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || isPendingCustomSuffix(format.key)"
:aria-label="`${format.label} ${selectedSuffixes[format.key] ?? 'low'} 指令`" :aria-label="`${format.label} ${selectedSuffixes[format.key] ?? PREFERRED_MODEL_DIRECTIVE_SUFFIX} 后缀`"
@update:model-value="value => onSuffixEnabledChange(format.key, value)" @update:model-value="value => onSuffixEnabledChange(format.key, value)"
/> />
</div> </div>
</div> </div>
<div> <div>
<div class="mb-1 text-xs font-medium text-muted-foreground lg:hidden"> <div class="mb-1 text-xs font-medium text-muted-foreground lg:hidden">
自定义映射 有效映射
</div> </div>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -94,11 +153,11 @@
:id="mappingInputId(format.key)" :id="mappingInputId(format.key)"
:model-value="localMappingParams[mappingKey(format.key)]" :model-value="localMappingParams[mappingKey(format.key)]"
class="h-24 min-h-24 resize-none overflow-auto font-mono text-xs leading-5" class="h-24 min-h-24 resize-none overflow-auto font-mono text-xs leading-5"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !selectedSuffixEnabled(format.key)" :disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
:aria-label="`${format.label} ${selectedSuffixes[format.key] ?? 'low'} 映射参数`" :aria-label="`${format.label} ${selectedSuffixes[format.key] ?? PREFERRED_MODEL_DIRECTIVE_SUFFIX} 映射参数`"
:aria-invalid="Boolean(mappingErrors[mappingKey(format.key)])" :aria-invalid="Boolean(mappingErrors[mappingKey(format.key)])"
:aria-describedby="mappingErrors[mappingKey(format.key)] ? mappingErrorId(format.key) : undefined" :aria-describedby="mappingErrors[mappingKey(format.key)] ? mappingErrorId(format.key) : undefined"
title="自定义映射 JSON" title="有效映射 JSON"
placeholder="{}" placeholder="{}"
@update:model-value="value => onMappingDraftChange(format.key, value)" @update:model-value="value => onMappingDraftChange(format.key, value)"
/> />
@@ -118,10 +177,22 @@
</p> </p>
</div> </div>
<Button <Button
v-if="hasCustomMapping(format.key) || isPendingCustomSuffix(format.key)"
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-9 w-9 shrink-0 text-muted-foreground" class="h-9 w-9 shrink-0 text-muted-foreground"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !selectedSuffixEnabled(format.key) || !hasMappingParamChanges(format.key)" :disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
:title="resetMappingTitle(format.key, format.label)"
:aria-label="resetMappingTitle(format.key, format.label)"
@click="resetMappingOverride(format.key)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
class="h-9 w-9 shrink-0 text-muted-foreground"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !hasMappingParamChanges(format.key)"
:title="`保存 ${format.label} 映射参数`" :title="`保存 ${format.label} 映射参数`"
:aria-label="`保存 ${format.label} 映射参数`" :aria-label="`保存 ${format.label} 映射参数`"
@click="saveMappingParam(format.key)" @click="saveMappingParam(format.key)"
@@ -135,7 +206,7 @@
<Switch <Switch
:model-value="formatConfig(format.key).enabled" :model-value="formatConfig(format.key).enabled"
:disabled="loading || !config.reasoning_effort.enabled" :disabled="loading || !config.reasoning_effort.enabled"
:aria-label="`${format.label} 模型参数指令`" :aria-label="`${format.label} 端点参数映射`"
@update:model-value="value => onApiFormatEnabledChange(format.key, value)" @update:model-value="value => onApiFormatEnabledChange(format.key, value)"
/> />
</div> </div>
@@ -147,8 +218,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { Save } from 'lucide-vue-next' import { Check, Plus, RotateCcw, Save, X } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -161,7 +233,12 @@ import Textarea from '@/components/ui/textarea.vue'
import { import {
MODEL_DIRECTIVE_SUFFIX_METADATA, MODEL_DIRECTIVE_SUFFIX_METADATA,
MODEL_DIRECTIVE_API_FORMATS, MODEL_DIRECTIVE_API_FORMATS,
MODEL_DIRECTIVE_SUFFIXES,
PREFERRED_MODEL_DIRECTIVE_SUFFIX,
defaultModelDirectiveSuffixesForApiFormat, defaultModelDirectiveSuffixesForApiFormat,
modelDirectiveEffectiveMappingPreview,
modelDirectiveMappingOverrideFromEffective,
normalizeModelDirectiveSuffix,
updateModelDirectiveMappingOverride, updateModelDirectiveMappingOverride,
updateModelDirectiveSuffixEnabled, updateModelDirectiveSuffixEnabled,
type ReasoningApiFormatConfig, type ReasoningApiFormatConfig,
@@ -178,20 +255,28 @@ const emit = defineEmits<{
}>() }>()
const selectedSuffixes = reactive<Record<string, string>>({}) const selectedSuffixes = reactive<Record<string, string>>({})
const selectedSuffixTouched = reactive(new Set<string>())
const localMappingParams = reactive<Record<string, string>>({}) const localMappingParams = reactive<Record<string, string>>({})
const mappingErrors = reactive<Record<string, string>>({}) const mappingErrors = reactive<Record<string, string>>({})
const dirtyMappingKeys = reactive(new Set<string>()) const dirtyMappingKeys = reactive(new Set<string>())
const addingSuffixes = reactive(new Set<string>())
const customSuffixDrafts = reactive<Record<string, string>>({})
const customSuffixErrors = reactive<Record<string, string>>({})
const localCustomSuffixes = reactive<Record<string, string[]>>({})
watch(() => props.config.reasoning_effort.api_formats, (newFormats) => { watch(() => props.config.reasoning_effort.api_formats, (newFormats) => {
for (const format of MODEL_DIRECTIVE_API_FORMATS) { for (const format of MODEL_DIRECTIVE_API_FORMATS) {
const fc = newFormats[format.key] const fc = newFormats[format.key]
const selectedSuffix = selectedSuffixes[format.key] const currentSelectedSuffix = selectedSuffixes[format.key]
?? firstConfiguredSuffix(format.key, fc) const selectedSuffix = selectedSuffixTouched.has(format.key)
?? 'low' && currentSelectedSuffix
&& availableSuffixes(format.key).includes(currentSelectedSuffix)
? currentSelectedSuffix
: preferredSuffix(format.key, fc)
selectedSuffixes[format.key] = selectedSuffix selectedSuffixes[format.key] = selectedSuffix
for (const suffix of availableSuffixes(format.key)) { for (const suffix of availableSuffixes(format.key)) {
const key = mappingKey(format.key, suffix) const key = mappingKey(format.key, suffix)
const authoritativeText = mappingOverrideText(fc?.mappings?.[suffix]) const authoritativeText = effectiveMappingText(format.key, suffix, fc?.mappings?.[suffix])
if (dirtyMappingKeys.has(key)) { if (dirtyMappingKeys.has(key)) {
if (localMappingParams[key] === authoritativeText) { if (localMappingParams[key] === authoritativeText) {
dirtyMappingKeys.delete(key) dirtyMappingKeys.delete(key)
@@ -219,16 +304,27 @@ function availableSuffixes(apiFormat: string): string[] {
...defaultModelDirectiveSuffixesForApiFormat(apiFormat), ...defaultModelDirectiveSuffixesForApiFormat(apiFormat),
...(configured?.suffixes ?? []), ...(configured?.suffixes ?? []),
...Object.keys(configured?.mappings ?? {}), ...Object.keys(configured?.mappings ?? {}),
...(localCustomSuffixes[apiFormat] ?? []),
])] ])]
} }
function firstConfiguredSuffix( function preferredSuffix(
apiFormat: string, apiFormat: string,
config: ReasoningApiFormatConfig | undefined, config: ReasoningApiFormatConfig | undefined,
): string | undefined { ): string {
return availableSuffixes(apiFormat).find(suffix => ( const suffixes = availableSuffixes(apiFormat)
const configured = suffixes.find(suffix => (
config?.suffixes.includes(suffix) || config?.mappings?.[suffix] !== undefined config?.suffixes.includes(suffix) || config?.mappings?.[suffix] !== undefined
)) ))
if (configured && suffixes.includes(PREFERRED_MODEL_DIRECTIVE_SUFFIX)) {
return config?.suffixes.includes(PREFERRED_MODEL_DIRECTIVE_SUFFIX)
|| config?.mappings?.[PREFERRED_MODEL_DIRECTIVE_SUFFIX] !== undefined
? PREFERRED_MODEL_DIRECTIVE_SUFFIX
: configured
}
return configured ?? (suffixes.includes(PREFERRED_MODEL_DIRECTIVE_SUFFIX)
? PREFERRED_MODEL_DIRECTIVE_SUFFIX
: suffixes[0] ?? PREFERRED_MODEL_DIRECTIVE_SUFFIX)
} }
function mappingKey( function mappingKey(
@@ -243,9 +339,14 @@ function selectSuffix(apiFormat: string, suffix: string) {
throw new Error(`Unsupported model directive suffix: ${suffix}`) throw new Error(`Unsupported model directive suffix: ${suffix}`)
} }
selectedSuffixes[apiFormat] = suffix selectedSuffixes[apiFormat] = suffix
selectedSuffixTouched.add(apiFormat)
const key = mappingKey(apiFormat, suffix) const key = mappingKey(apiFormat, suffix)
if (!Object.prototype.hasOwnProperty.call(localMappingParams, key)) { if (!Object.prototype.hasOwnProperty.call(localMappingParams, key)) {
localMappingParams[key] = mappingOverrideText(formatConfig(apiFormat).mappings[suffix]) localMappingParams[key] = effectiveMappingText(
apiFormat,
suffix,
formatConfig(apiFormat).mappings[suffix],
)
delete mappingErrors[key] delete mappingErrors[key]
} }
} }
@@ -254,7 +355,7 @@ function onMappingDraftChange(apiFormat: string, value: string) {
const suffix = selectedSuffixes[apiFormat] ?? 'low' const suffix = selectedSuffixes[apiFormat] ?? 'low'
const key = mappingKey(apiFormat, suffix) const key = mappingKey(apiFormat, suffix)
localMappingParams[key] = value localMappingParams[key] = value
if (value === mappingOverrideText(formatConfig(apiFormat).mappings[suffix])) { if (value === effectiveMappingText(apiFormat, suffix, formatConfig(apiFormat).mappings[suffix])) {
dirtyMappingKeys.delete(key) dirtyMappingKeys.delete(key)
} else { } else {
dirtyMappingKeys.add(key) dirtyMappingKeys.add(key)
@@ -285,25 +386,110 @@ function mappingErrorId(apiFormat: string): string {
return `${mappingInputId(apiFormat)}-error` return `${mappingInputId(apiFormat)}-error`
} }
function mappingOverrideText(mapping: unknown): string { function effectiveMappingText(
apiFormat: string,
suffix: string,
override: unknown,
): string {
const mapping = modelDirectiveEffectiveMappingPreview(apiFormat, suffix, override)
return mapping === undefined ? '' : JSON.stringify(mapping, null, 2) return mapping === undefined ? '' : JSON.stringify(mapping, null, 2)
} }
function hasBuiltInMapping(apiFormat: string): boolean {
const suffix = selectedSuffixes[apiFormat] ?? PREFERRED_MODEL_DIRECTIVE_SUFFIX
return modelDirectiveEffectiveMappingPreview(apiFormat, suffix, undefined) !== undefined
}
function hasCustomMapping(apiFormat: string): boolean { function hasCustomMapping(apiFormat: string): boolean {
const suffix = selectedSuffixes[apiFormat] ?? 'low' const suffix = selectedSuffixes[apiFormat] ?? 'low'
return Object.prototype.hasOwnProperty.call(formatConfig(apiFormat).mappings, suffix) return Object.prototype.hasOwnProperty.call(formatConfig(apiFormat).mappings, suffix)
} }
function mappingStatus(apiFormat: string): string { function mappingStatus(apiFormat: string): string {
return hasCustomMapping(apiFormat) if (isPendingCustomSuffix(apiFormat)) return '保存非空映射后启用此后缀'
? '自定义映射' if (hasCustomMapping(apiFormat) && hasBuiltInMapping(apiFormat)) {
: '内置映射' return '自定义覆盖(已合并显示)'
}
if (hasCustomMapping(apiFormat)) return '自定义映射'
if (hasBuiltInMapping(apiFormat)) return '内置映射预览(运行时按目标模型调整)'
return '尚未配置映射'
}
function resetMappingTitle(apiFormat: string, formatLabel: string): string {
if (isPendingCustomSuffix(apiFormat)) return `删除 ${formatLabel} 待配置后缀`
return hasBuiltInMapping(apiFormat)
? `恢复 ${formatLabel} 内置映射`
: `删除 ${formatLabel} 自定义映射`
} }
function hasMappingParamChanges(apiFormat: string): boolean { function hasMappingParamChanges(apiFormat: string): boolean {
const suffix = selectedSuffixes[apiFormat] ?? 'low' const suffix = selectedSuffixes[apiFormat] ?? 'low'
return (localMappingParams[mappingKey(apiFormat, suffix)] ?? '') return (localMappingParams[mappingKey(apiFormat, suffix)] ?? '')
!== mappingOverrideText(formatConfig(apiFormat).mappings[suffix]) !== effectiveMappingText(apiFormat, suffix, formatConfig(apiFormat).mappings[suffix])
}
function startAddingSuffix(apiFormat: string) {
addingSuffixes.add(apiFormat)
customSuffixDrafts[apiFormat] = ''
delete customSuffixErrors[apiFormat]
}
function cancelAddingSuffix(apiFormat: string) {
addingSuffixes.delete(apiFormat)
delete customSuffixDrafts[apiFormat]
delete customSuffixErrors[apiFormat]
}
function clearCustomSuffixError(apiFormat: string) {
delete customSuffixErrors[apiFormat]
}
function addCustomSuffix(apiFormat: string) {
const rawSuffix = customSuffixDrafts[apiFormat] ?? ''
const suffix = normalizeModelDirectiveSuffix(rawSuffix)
if (!suffix || suffix.startsWith('-') || suffix.endsWith('-') || /\s/.test(suffix)) {
customSuffixErrors[apiFormat] = '后缀只能包含不带空格的模型名片段'
return
}
if (MODEL_DIRECTIVE_SUFFIXES.includes(suffix as typeof MODEL_DIRECTIVE_SUFFIXES[number])
&& !defaultModelDirectiveSuffixesForApiFormat(apiFormat).includes(
suffix as typeof MODEL_DIRECTIVE_SUFFIXES[number],
)) {
customSuffixErrors[apiFormat] = '该 API 端点不支持此内置后缀'
return
}
const existing = availableSuffixes(apiFormat).find(item => item.toLowerCase() === suffix.toLowerCase())
if (existing) {
selectedSuffixes[apiFormat] = existing
selectedSuffixTouched.add(apiFormat)
cancelAddingSuffix(apiFormat)
return
}
localCustomSuffixes[apiFormat] = [
...(localCustomSuffixes[apiFormat] ?? []),
suffix,
]
selectedSuffixes[apiFormat] = suffix
selectedSuffixTouched.add(apiFormat)
const key = mappingKey(apiFormat, suffix)
localMappingParams[key] = ''
dirtyMappingKeys.delete(key)
delete mappingErrors[key]
cancelAddingSuffix(apiFormat)
}
function isPendingCustomSuffix(apiFormat: string): boolean {
const suffix = selectedSuffixes[apiFormat] ?? PREFERRED_MODEL_DIRECTIVE_SUFFIX
return (localCustomSuffixes[apiFormat] ?? []).includes(suffix)
&& !formatConfig(apiFormat).suffixes.includes(suffix)
&& !Object.prototype.hasOwnProperty.call(formatConfig(apiFormat).mappings, suffix)
}
function removeLocalCustomSuffix(apiFormat: string, suffix: string) {
localCustomSuffixes[apiFormat] = (localCustomSuffixes[apiFormat] ?? [])
.filter(item => item !== suffix)
} }
function onReasoningEnabledChange(value: boolean) { function onReasoningEnabledChange(value: boolean) {
@@ -330,6 +516,7 @@ function onApiFormatEnabledChange(apiFormat: string, value: boolean) {
function onSuffixEnabledChange(apiFormat: string, value: boolean) { function onSuffixEnabledChange(apiFormat: string, value: boolean) {
const current = formatConfig(apiFormat) const current = formatConfig(apiFormat)
const suffix = selectedSuffixes[apiFormat] ?? 'low' const suffix = selectedSuffixes[apiFormat] ?? 'low'
selectedSuffixTouched.add(apiFormat)
emit('save', { emit('save', {
...props.config, ...props.config,
reasoning_effort: { reasoning_effort: {
@@ -350,7 +537,7 @@ function saveMappingParam(apiFormat: string) {
const suffix = selectedSuffixes[apiFormat] ?? 'low' const suffix = selectedSuffixes[apiFormat] ?? 'low'
const key = mappingKey(apiFormat, suffix) const key = mappingKey(apiFormat, suffix)
const rawMapping = (localMappingParams[key] ?? '').trim() const rawMapping = (localMappingParams[key] ?? '').trim()
let mapping: Record<string, unknown> | undefined let effectiveMapping: Record<string, unknown> | undefined
if (rawMapping) { if (rawMapping) {
try { try {
const parsed = JSON.parse(rawMapping) const parsed = JSON.parse(rawMapping)
@@ -358,7 +545,7 @@ function saveMappingParam(apiFormat: string) {
mappingErrors[key] = '映射参数必须是 JSON 对象' mappingErrors[key] = '映射参数必须是 JSON 对象'
return return
} }
mapping = Object.keys(parsed).length > 0 effectiveMapping = Object.keys(parsed).length > 0
? parsed as Record<string, unknown> ? parsed as Record<string, unknown>
: undefined : undefined
} catch { } catch {
@@ -367,9 +554,23 @@ function saveMappingParam(apiFormat: string) {
} }
} }
if (isPendingCustomSuffix(apiFormat) && !effectiveMapping) {
mappingErrors[key] = '自定义后缀必须配置非空 JSON 映射'
return
}
delete mappingErrors[key] delete mappingErrors[key]
localMappingParams[key] = mappingOverrideText(mapping) const mapping = effectiveMapping
? modelDirectiveMappingOverrideFromEffective(apiFormat, suffix, effectiveMapping)
: undefined
localMappingParams[key] = effectiveMappingText(apiFormat, suffix, mapping)
const mappings = updateModelDirectiveMappingOverride(current.mappings, suffix, mapping) const mappings = updateModelDirectiveMappingOverride(current.mappings, suffix, mapping)
const hasBuiltIn = hasBuiltInMapping(apiFormat)
const suffixes = isPendingCustomSuffix(apiFormat) && mapping
? updateModelDirectiveSuffixEnabled(current.suffixes, suffix, true)
: !mapping && !hasBuiltIn
? updateModelDirectiveSuffixEnabled(current.suffixes, suffix, false)
: current.suffixes
emit('save', { emit('save', {
...props.config, ...props.config,
reasoning_effort: { reasoning_effort: {
@@ -378,10 +579,45 @@ function saveMappingParam(apiFormat: string) {
...props.config.reasoning_effort.api_formats, ...props.config.reasoning_effort.api_formats,
[apiFormat]: { [apiFormat]: {
...current, ...current,
suffixes,
mappings, mappings,
}, },
}, },
}, },
}) })
} }
function resetMappingOverride(apiFormat: string) {
const current = formatConfig(apiFormat)
const suffix = selectedSuffixes[apiFormat] ?? PREFERRED_MODEL_DIRECTIVE_SUFFIX
if (isPendingCustomSuffix(apiFormat)) {
removeLocalCustomSuffix(apiFormat, suffix)
const key = mappingKey(apiFormat, suffix)
dirtyMappingKeys.delete(key)
delete mappingErrors[key]
delete localMappingParams[key]
selectedSuffixTouched.delete(apiFormat)
selectedSuffixes[apiFormat] = preferredSuffix(apiFormat, current)
return
}
const mappings = updateModelDirectiveMappingOverride(current.mappings, suffix, undefined)
const suffixes = hasBuiltInMapping(apiFormat)
? current.suffixes
: updateModelDirectiveSuffixEnabled(current.suffixes, suffix, false)
const key = mappingKey(apiFormat, suffix)
dirtyMappingKeys.delete(key)
delete mappingErrors[key]
localMappingParams[key] = effectiveMappingText(apiFormat, suffix, undefined)
removeLocalCustomSuffix(apiFormat, suffix)
emit('save', {
...props.config,
reasoning_effort: {
...props.config.reasoning_effort,
api_formats: {
...props.config.reasoning_effort.api_formats,
[apiFormat]: { ...current, suffixes, mappings },
},
},
})
}
</script> </script>
@@ -84,6 +84,28 @@ vi.mock('@/components/ui/textarea.vue', async () => {
} }
}) })
vi.mock('@/components/ui/input.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
inheritAttrs: false,
props: { modelValue: String, disabled: Boolean },
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
value: props.modelValue,
disabled: props.disabled,
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLInputElement).value,
),
})
},
}),
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = [] const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountPanel(onSave?: (value: ReturnType<typeof createDefaultModelDirectivesConfig>) => void) { function mountPanel(onSave?: (value: ReturnType<typeof createDefaultModelDirectivesConfig>) => void) {
@@ -120,7 +142,16 @@ describe('ModelDirectivesPanel', () => {
it('shows Codex ultra and authoritative custom suffixes in the OpenAI selector', async () => { it('shows Codex ultra and authoritative custom suffixes in the OpenAI selector', async () => {
const { config, root } = mountPanel() const { config, root } = mountPanel()
const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement
expect(suffixSelect.value).toBe('low')
expect([...suffixSelect.options].map(option => option.value)).toContain('ultra') expect([...suffixSelect.options].map(option => option.value)).toContain('ultra')
const searchSuffixSelect = root.querySelectorAll('select').item(3) as HTMLSelectElement
expect([...searchSuffixSelect.options].map(option => option.value)).not.toContain('fast')
const builtInMapping = root.querySelector(
'textarea[aria-label="OpenAI Responses low 映射参数"]',
) as HTMLTextAreaElement
expect(JSON.parse(builtInMapping.value)).toEqual({ reasoning: { effort: 'low' } })
expect(root.textContent).toContain('内置映射预览(运行时按目标模型调整)')
const current = config.value.reasoning_effort.api_formats['openai:responses'] const current = config.value.reasoning_effort.api_formats['openai:responses']
config.value = { config.value = {
@@ -149,18 +180,18 @@ describe('ModelDirectivesPanel', () => {
])) ]))
selectSuffix(suffixSelect, 'ultra') selectSuffix(suffixSelect, 'ultra')
await nextTick() await nextTick()
expect(root.textContent).toContain('Codex Ultra 预设,请求推理强度为 max') expect(root.textContent).toContain('Codex Ultra 推理预设,仅支持兼容的 Codex 模型')
}) })
it('retains per-suffix drafts and validation state while switching suffixes', async () => { it('retains per-suffix drafts and validation state while switching suffixes', async () => {
const { root } = mountPanel() const { root } = mountPanel()
const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement
const noneMapping = root.querySelector( const lowMapping = root.querySelector(
'textarea[aria-label="OpenAI Responses none 映射参数"]', 'textarea[aria-label="OpenAI Responses low 映射参数"]',
) as HTMLTextAreaElement ) as HTMLTextAreaElement
noneMapping.value = '{' lowMapping.value = '{'
noneMapping.dispatchEvent(new Event('input', { bubbles: true })) lowMapping.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick() await nextTick()
const saveButton = root.querySelector( const saveButton = root.querySelector(
'button[aria-label="保存 OpenAI Responses 映射参数"]', 'button[aria-label="保存 OpenAI Responses 映射参数"]',
@@ -171,11 +202,11 @@ describe('ModelDirectivesPanel', () => {
selectSuffix(suffixSelect, 'medium') selectSuffix(suffixSelect, 'medium')
await nextTick() await nextTick()
selectSuffix(suffixSelect, 'none') selectSuffix(suffixSelect, 'low')
await nextTick() await nextTick()
const restoredDraft = root.querySelector( const restoredDraft = root.querySelector(
'textarea[aria-label="OpenAI Responses none 映射参数"]', 'textarea[aria-label="OpenAI Responses low 映射参数"]',
) as HTMLTextAreaElement ) as HTMLTextAreaElement
expect(restoredDraft.value).toBe('{') expect(restoredDraft.value).toBe('{')
expect(restoredDraft.getAttribute('aria-invalid')).toBe('true') expect(restoredDraft.getAttribute('aria-invalid')).toBe('true')
@@ -188,7 +219,7 @@ describe('ModelDirectivesPanel', () => {
selectSuffix(suffixSelect, 'medium') selectSuffix(suffixSelect, 'medium')
await nextTick() await nextTick()
selectSuffix(suffixSelect, 'none') selectSuffix(suffixSelect, 'low')
await nextTick() await nextTick()
const current = config.value.reasoning_effort.api_formats['openai:responses'] const current = config.value.reasoning_effort.api_formats['openai:responses']
@@ -222,7 +253,7 @@ describe('ModelDirectivesPanel', () => {
const savedConfigs: Array<ReturnType<typeof createDefaultModelDirectivesConfig>> = [] const savedConfigs: Array<ReturnType<typeof createDefaultModelDirectivesConfig>> = []
const { root } = mountPanel(value => savedConfigs.push(value)) const { root } = mountPanel(value => savedConfigs.push(value))
const mapping = root.querySelector( const mapping = root.querySelector(
'textarea[aria-label="OpenAI Responses none 映射参数"]', 'textarea[aria-label="OpenAI Responses low 映射参数"]',
) as HTMLTextAreaElement ) as HTMLTextAreaElement
mapping.value = '{"reasoning":{"effort":"medium"}}' mapping.value = '{"reasoning":{"effort":"medium"}}'
mapping.dispatchEvent(new Event('input', { bubbles: true })) mapping.dispatchEvent(new Event('input', { bubbles: true }))
@@ -234,7 +265,7 @@ describe('ModelDirectivesPanel', () => {
saveButton.click() saveButton.click()
await nextTick() await nextTick()
expect(savedConfigs).toHaveLength(1) expect(savedConfigs).toHaveLength(1)
expect(savedConfigs[0].reasoning_effort.api_formats['openai:responses'].mappings.none) expect(savedConfigs[0].reasoning_effort.api_formats['openai:responses'].mappings.low)
.toEqual({ reasoning: { effort: 'medium' } }) .toEqual({ reasoning: { effort: 'medium' } })
expect(saveButton.disabled).toBe(false) expect(saveButton.disabled).toBe(false)
@@ -242,4 +273,183 @@ describe('ModelDirectivesPanel', () => {
await nextTick() await nextTick()
expect(savedConfigs).toHaveLength(2) expect(savedConfigs).toHaveLength(2)
}) })
it('selects the first enabled suffix when the loaded config disables low', async () => {
const { config, root } = mountPanel()
const current = config.value.reasoning_effort.api_formats['openai:responses']
config.value = {
...config.value,
reasoning_effort: {
...config.value.reasoning_effort,
api_formats: {
...config.value.reasoning_effort.api_formats,
'openai:responses': {
...current,
suffixes: ['high'],
mappings: {},
},
},
},
}
await nextTick()
const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement
expect(suffixSelect.value).toBe('high')
const mapping = root.querySelector(
'textarea[aria-label="OpenAI Responses high 映射参数"]',
) as HTMLTextAreaElement
expect(JSON.parse(mapping.value)).toEqual({ reasoning: { effort: 'high' } })
})
it('enables a custom suffix atomically with its first mapping', async () => {
const savedConfigs: Array<ReturnType<typeof createDefaultModelDirectivesConfig>> = []
const { config, root } = mountPanel(value => savedConfigs.push(value))
const addButton = root.querySelector(
'button[aria-label="新增 OpenAI Responses 自定义后缀"]',
) as HTMLButtonElement
addButton.click()
await nextTick()
const input = root.querySelector(
'input[aria-label="OpenAI Responses 自定义后缀名称"]',
) as HTMLInputElement
input.value = 'vendor-depth'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const confirmButton = root.querySelector(
'button[aria-label="添加 OpenAI Responses 自定义后缀"]',
) as HTMLButtonElement
confirmButton.click()
await nextTick()
const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement
expect(suffixSelect.value).toBe('vendor-depth')
expect([...suffixSelect.options].map(option => option.value)).toContain('vendor-depth')
expect(savedConfigs).toHaveLength(0)
expect(root.textContent).toContain('保存非空映射后启用此后缀')
const mapping = root.querySelector(
'textarea[aria-label="OpenAI Responses vendor-depth 映射参数"]',
) as HTMLTextAreaElement
mapping.value = '{"vendor_option":"deep"}'
mapping.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const saveButton = root.querySelector(
'button[aria-label="保存 OpenAI Responses 映射参数"]',
) as HTMLButtonElement
saveButton.click()
await nextTick()
expect(savedConfigs).toHaveLength(1)
const saved = savedConfigs[0].reasoning_effort.api_formats['openai:responses']
expect(saved.suffixes).toContain('vendor-depth')
expect(saved.mappings['vendor-depth']).toEqual({ vendor_option: 'deep' })
config.value = savedConfigs[0]
await nextTick()
expect(suffixSelect.value).toBe('vendor-depth')
})
it('removes one custom override without disabling its suffix', async () => {
const savedConfigs: Array<ReturnType<typeof createDefaultModelDirectivesConfig>> = []
const { config, root } = mountPanel(value => savedConfigs.push(value))
const current = config.value.reasoning_effort.api_formats['openai:responses']
config.value = {
...config.value,
reasoning_effort: {
...config.value.reasoning_effort,
api_formats: {
...config.value.reasoning_effort.api_formats,
'openai:responses': {
...current,
mappings: {
low: { reasoning: { effort: 'medium', summary: 'auto' } },
},
},
},
},
}
await nextTick()
const resetButton = root.querySelector(
'button[aria-label="恢复 OpenAI Responses 内置映射"]',
) as HTMLButtonElement
resetButton.click()
await nextTick()
expect(savedConfigs).toHaveLength(1)
const saved = savedConfigs[0].reasoning_effort.api_formats['openai:responses']
expect(saved.mappings).not.toHaveProperty('low')
expect(saved.suffixes).toContain('low')
})
it('disables a persisted custom suffix when its mapping is cleared', async () => {
const savedConfigs: Array<ReturnType<typeof createDefaultModelDirectivesConfig>> = []
const { config, root } = mountPanel(value => savedConfigs.push(value))
const current = config.value.reasoning_effort.api_formats['openai:responses']
config.value = {
...config.value,
reasoning_effort: {
...config.value.reasoning_effort,
api_formats: {
...config.value.reasoning_effort.api_formats,
'openai:responses': {
...current,
suffixes: [...current.suffixes, 'vendor-depth'],
mappings: { 'vendor-depth': { vendor_option: 'deep' } },
},
},
},
}
await nextTick()
const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement
selectSuffix(suffixSelect, 'vendor-depth')
await nextTick()
const mapping = root.querySelector(
'textarea[aria-label="OpenAI Responses vendor-depth 映射参数"]',
) as HTMLTextAreaElement
mapping.value = '{}'
mapping.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const saveButton = root.querySelector(
'button[aria-label="保存 OpenAI Responses 映射参数"]',
) as HTMLButtonElement
saveButton.click()
await nextTick()
expect(savedConfigs).toHaveLength(1)
const saved = savedConfigs[0].reasoning_effort.api_formats['openai:responses']
expect(saved.suffixes).not.toContain('vendor-depth')
expect(saved.mappings).not.toHaveProperty('vendor-depth')
})
it('stores only the custom difference from the visible built-in mapping', async () => {
const savedConfigs: Array<ReturnType<typeof createDefaultModelDirectivesConfig>> = []
const { root } = mountPanel(value => savedConfigs.push(value))
const mapping = root.querySelector(
'textarea[aria-label="OpenAI Responses low 映射参数"]',
) as HTMLTextAreaElement
mapping.value = JSON.stringify({
reasoning: { effort: 'low', summary: 'auto' },
trace: true,
})
mapping.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const saveButton = root.querySelector(
'button[aria-label="保存 OpenAI Responses 映射参数"]',
) as HTMLButtonElement
saveButton.click()
await nextTick()
expect(savedConfigs).toHaveLength(1)
expect(savedConfigs[0].reasoning_effort.api_formats['openai:responses'].mappings.low)
.toEqual({ reasoning: { summary: 'auto' }, trace: true })
expect(JSON.parse(mapping.value)).toEqual({
reasoning: { effort: 'low', summary: 'auto' },
trace: true,
})
})
}) })
@@ -7,6 +7,9 @@ import {
REASONING_EFFORTS, REASONING_EFFORTS,
createDefaultModelDirectivesConfig, createDefaultModelDirectivesConfig,
defaultModelDirectiveSuffixesForApiFormat, defaultModelDirectiveSuffixesForApiFormat,
modelDirectiveBuiltInMappingPreview,
modelDirectiveEffectiveMappingPreview,
modelDirectiveMappingOverrideFromEffective,
normalizeModelDirectivesConfig, normalizeModelDirectivesConfig,
updateModelDirectiveMappingOverride, updateModelDirectiveMappingOverride,
updateModelDirectiveSuffixEnabled, updateModelDirectiveSuffixEnabled,
@@ -29,12 +32,51 @@ describe('modelDirectivesConfig', () => {
'fast', 'fast',
]) ])
expect(defaultModelDirectiveSuffixesForApiFormat('openai:responses')).toContain('ultra') expect(defaultModelDirectiveSuffixesForApiFormat('openai:responses')).toContain('ultra')
expect(defaultModelDirectiveSuffixesForApiFormat('openai:search')).toEqual(MODEL_DIRECTIVE_SUFFIXES) expect(defaultModelDirectiveSuffixesForApiFormat('openai:search')).toContain('ultra')
expect(defaultModelDirectiveSuffixesForApiFormat('openai:search')).not.toContain('fast')
expect(defaultModelDirectiveSuffixesForApiFormat('claude:messages')).not.toContain('ultra') expect(defaultModelDirectiveSuffixesForApiFormat('claude:messages')).not.toContain('ultra')
expect(defaultModelDirectiveSuffixesForApiFormat('gemini:generate_content')).not.toContain('ultra') expect(defaultModelDirectiveSuffixesForApiFormat('gemini:generate_content')).not.toContain('ultra')
expect(MODEL_DIRECTIVE_SUFFIX_METADATA.fast.description).toBe('Fast 服务层级') expect(MODEL_DIRECTIVE_SUFFIX_METADATA.fast.description).toBe('Fast 服务层级')
}) })
it('previews built-in mappings and merges configured overrides', () => {
expect(modelDirectiveBuiltInMappingPreview('openai:chat', 'low')).toEqual({
reasoning_effort: 'low',
})
expect(modelDirectiveBuiltInMappingPreview('openai:responses', 'fast')).toEqual({
service_tier: 'priority',
})
expect(modelDirectiveBuiltInMappingPreview('openai:search', 'fast')).toBeUndefined()
expect(modelDirectiveBuiltInMappingPreview('claude:messages', 'medium')).toEqual({
output_config: { effort: 'medium' },
thinking: { type: 'enabled', budget_tokens: 2048 },
})
expect(modelDirectiveBuiltInMappingPreview('gemini:generate_content', 'high')).toEqual({
generationConfig: {
thinkingConfig: { includeThoughts: true, thinkingBudget: 4096 },
},
})
expect(modelDirectiveEffectiveMappingPreview('openai:responses', 'low', {
reasoning: { effort: 'medium', summary: 'auto' },
trace: true,
})).toEqual({
reasoning: { effort: 'medium', summary: 'auto' },
trace: true,
})
expect(modelDirectiveMappingOverrideFromEffective('openai:responses', 'low', {
reasoning: { effort: 'low', summary: 'auto' },
trace: true,
})).toEqual({
reasoning: { summary: 'auto' },
trace: true,
})
expect(modelDirectiveMappingOverrideFromEffective('openai:responses', 'low', {
reasoning: { effort: 'low' },
})).toBeUndefined()
})
it('creates a config whose mappings contain overrides only', () => { it('creates a config whose mappings contain overrides only', () => {
const config = createDefaultModelDirectivesConfig() const config = createDefaultModelDirectivesConfig()
@@ -70,6 +112,45 @@ describe('modelDirectivesConfig', () => {
]) ])
}) })
it('drops the previously persisted no-op fast suffix from OpenAI Search', () => {
const config = normalizeModelDirectivesConfig({
reasoning_effort: {
api_formats: {
'openai:search': {
suffixes: ['low', 'fast'],
mappings: { fast: { service_tier: 'priority' } },
},
},
},
})
expect(config.reasoning_effort.api_formats['openai:search'].suffixes).toEqual(['low'])
expect(config.reasoning_effort.api_formats['openai:search'].mappings).toEqual({})
})
it('drops known built-in suffixes that the selected API format cannot execute', () => {
const config = normalizeModelDirectivesConfig({
reasoning_effort: {
api_formats: {
'claude:messages': {
suffixes: ['none', 'low', 'ultra', 'VendorFuture'],
mappings: {
none: { thinking: { type: 'disabled' } },
ultra: { thinking: { type: 'adaptive' } },
VendorFuture: { thinking: { type: 'adaptive' } },
},
},
},
},
})
expect(config.reasoning_effort.api_formats['claude:messages'].suffixes)
.toEqual(['low', 'VendorFuture'])
expect(config.reasoning_effort.api_formats['claude:messages'].mappings).toEqual({
VendorFuture: { thinking: { type: 'adaptive' } },
})
})
it('preserves every explicit mapping as an authoritative override', () => { it('preserves every explicit mapping as an authoritative override', () => {
const customHigh = { reasoning: { effort: 'high' }, trace: { sample: true } } const customHigh = { reasoning: { effort: 'high' }, trace: { sample: true } }
const futureMapping = { vendor_option: 'keep-me' } const futureMapping = { vendor_option: 'keep-me' }
@@ -164,6 +245,67 @@ describe('modelDirectivesConfig', () => {
}) })
}) })
it('canonicalizes API format aliases without keeping duplicate keys', () => {
const aliasOnly = normalizeModelDirectivesConfig({
reasoning_effort: {
api_formats: {
'OPENAI:RESPONSES': {
enabled: false,
mappings: { low: { reasoning: { effort: 'medium' } } },
},
},
},
})
expect(aliasOnly.reasoning_effort.api_formats['openai:responses']).toMatchObject({
enabled: false,
suffixes: ['low'],
mappings: { low: { reasoning: { effort: 'medium' } } },
})
expect(aliasOnly.reasoning_effort.api_formats).not.toHaveProperty('OPENAI:RESPONSES')
const canonicalWins = normalizeModelDirectivesConfig({
reasoning_effort: {
api_formats: {
'OPENAI:RESPONSES': { enabled: false, suffixes: ['low'] },
'openai:responses': { enabled: true, suffixes: ['high'] },
},
},
})
expect(canonicalWins.reasoning_effort.api_formats['openai:responses']).toMatchObject({
enabled: true,
suffixes: ['high'],
})
const pathAlias = normalizeModelDirectivesConfig({
reasoning_effort: {
api_formats: {
'/v1/responses': {
enabled: false,
suffixes: ['medium'],
mappings: { medium: { reasoning: { effort: 'high' } } },
},
},
},
})
expect(pathAlias.reasoning_effort.api_formats['openai:responses']).toMatchObject({
enabled: false,
suffixes: ['medium'],
mappings: { medium: { reasoning: { effort: 'high' } } },
})
expect(pathAlias.reasoning_effort.api_formats).not.toHaveProperty('/v1/responses')
const legacyPath = normalizeModelDirectivesConfig({
reasoning_effort: {
api_formats: {
'openai:cli': { suffixes: ['high'], mappings: {} },
},
},
})
expect(legacyPath.reasoning_effort.api_formats['openai:responses'].suffixes)
.toEqual(['high'])
expect(legacyPath.reasoning_effort.api_formats).not.toHaveProperty('openai:cli')
})
it('removes empty overrides without mutating unrelated custom mappings', () => { it('removes empty overrides without mutating unrelated custom mappings', () => {
const mappings = { const mappings = {
low: { reasoning: { effort: 'custom-low' } }, low: { reasoning: { effort: 'custom-low' } },
@@ -1,3 +1,5 @@
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
export interface ReasoningApiFormatConfig { export interface ReasoningApiFormatConfig {
[key: string]: unknown [key: string]: unknown
enabled: boolean enabled: boolean
@@ -33,6 +35,8 @@ export type ReasoningEffort = typeof REASONING_EFFORTS[number]
export const MODEL_DIRECTIVE_SUFFIXES = [...REASONING_EFFORTS, 'ultra', 'fast'] as const export const MODEL_DIRECTIVE_SUFFIXES = [...REASONING_EFFORTS, 'ultra', 'fast'] as const
export type ModelDirectiveSuffix = typeof MODEL_DIRECTIVE_SUFFIXES[number] export type ModelDirectiveSuffix = typeof MODEL_DIRECTIVE_SUFFIXES[number]
export const PREFERRED_MODEL_DIRECTIVE_SUFFIX: ModelDirectiveSuffix = 'low'
export const MODEL_DIRECTIVE_SUFFIX_METADATA: Readonly< export const MODEL_DIRECTIVE_SUFFIX_METADATA: Readonly<
Record<ModelDirectiveSuffix, { label: string, description: string }> Record<ModelDirectiveSuffix, { label: string, description: string }>
> = { > = {
@@ -43,7 +47,7 @@ export const MODEL_DIRECTIVE_SUFFIX_METADATA: Readonly<
high: { label: 'high', description: '高推理投入' }, high: { label: 'high', description: '高推理投入' },
xhigh: { label: 'xhigh', description: '超高推理投入' }, xhigh: { label: 'xhigh', description: '超高推理投入' },
max: { label: 'max', description: '模型支持时使用最大推理投入' }, max: { label: 'max', description: '模型支持时使用最大推理投入' },
ultra: { label: 'ultra', description: 'Codex Ultra 预设,请求推理强度为 max' }, ultra: { label: 'ultra', description: 'Codex Ultra 推理预设,仅支持兼容的 Codex 模型' },
fast: { label: 'fast', description: 'Fast 服务层级' }, fast: { label: 'fast', description: 'Fast 服务层级' },
} }
@@ -88,15 +92,19 @@ const CROSS_PROVIDER_SUFFIXES: readonly ModelDirectiveSuffix[] = [
'max', 'max',
] ]
const OPENAI_SEARCH_SUFFIXES: readonly ModelDirectiveSuffix[] = MODEL_DIRECTIVE_SUFFIXES
.filter(suffix => suffix !== 'fast')
export function defaultModelDirectiveSuffixesForApiFormat( export function defaultModelDirectiveSuffixesForApiFormat(
apiFormat: string, apiFormat: string,
): readonly ModelDirectiveSuffix[] { ): readonly ModelDirectiveSuffix[] {
switch (apiFormat) { switch (normalizeModelDirectiveApiFormat(apiFormat)) {
case 'openai:chat': case 'openai:chat':
case 'openai:responses': case 'openai:responses':
case 'openai:responses:compact': case 'openai:responses:compact':
case 'openai:search':
return MODEL_DIRECTIVE_SUFFIXES return MODEL_DIRECTIVE_SUFFIXES
case 'openai:search':
return OPENAI_SEARCH_SUFFIXES
case 'claude:messages': case 'claude:messages':
case 'gemini:generate_content': case 'gemini:generate_content':
return CROSS_PROVIDER_SUFFIXES return CROSS_PROVIDER_SUFFIXES
@@ -105,6 +113,155 @@ export function defaultModelDirectiveSuffixesForApiFormat(
} }
} }
/**
* Returns the generic built-in patch shown by the admin UI. The runtime may
* refine Claude/Gemini shapes and model capability checks for the mapped model.
*/
export function modelDirectiveBuiltInMappingPreview(
apiFormat: string,
suffix: string,
): Record<string, unknown> | undefined {
apiFormat = normalizeModelDirectiveApiFormat(apiFormat)
const normalizedSuffix = normalizeModelDirectiveSuffix(suffix)
if (!normalizedSuffix) return undefined
if (!modelDirectiveSuffixSupportedForApiFormat(apiFormat, normalizedSuffix)) return undefined
if (normalizedSuffix === 'fast') {
if (apiFormat === 'openai:search') return undefined
return ['openai:chat', 'openai:responses', 'openai:responses:compact'].includes(apiFormat)
? { service_tier: 'priority' }
: undefined
}
const isReasoningEffort = REASONING_EFFORTS.some(effort => effort === normalizedSuffix)
const isCodexUltra = normalizedSuffix === 'ultra'
if (!isReasoningEffort && !isCodexUltra) return undefined
switch (apiFormat) {
case 'openai:chat':
return { reasoning_effort: normalizedSuffix }
case 'openai:responses':
case 'openai:responses:compact':
case 'openai:search':
return { reasoning: { effort: normalizedSuffix } }
case 'claude:messages': {
if (!isReasoningEffort) return undefined
return {
output_config: { effort: claudeEffortValue(normalizedSuffix) },
thinking: {
type: 'enabled',
budget_tokens: thinkingBudgetTokens(normalizedSuffix),
},
}
}
case 'gemini:generate_content': {
if (!isReasoningEffort) return undefined
return {
generationConfig: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: thinkingBudgetTokens(normalizedSuffix),
},
},
}
}
default:
return undefined
}
}
export function modelDirectiveEffectiveMappingPreview(
apiFormat: string,
suffix: string,
override: unknown,
): unknown {
const builtIn = modelDirectiveBuiltInMappingPreview(apiFormat, suffix)
if (override === undefined) return cloneJsonValue(builtIn)
if (builtIn === undefined) return cloneJsonValue(override)
return deepMergeJsonValue(builtIn, override)
}
export function modelDirectiveMappingOverrideFromEffective(
apiFormat: string,
suffix: string,
effectiveMapping: Record<string, unknown>,
): Record<string, unknown> | undefined {
const builtIn = modelDirectiveBuiltInMappingPreview(apiFormat, suffix)
if (!builtIn) return Object.keys(effectiveMapping).length > 0
? cloneJsonValue(effectiveMapping)
: undefined
const override = jsonValueDifference(builtIn, effectiveMapping)
return isRecord(override) && Object.keys(override).length > 0
? override
: undefined
}
function claudeEffortValue(effort: ReasoningEffort): string {
return effort === 'none' || effort === 'minimal' ? 'low' : effort
}
function thinkingBudgetTokens(effort: ReasoningEffort): number {
switch (effort) {
case 'none':
return 0
case 'minimal':
return 512
case 'low':
return 1280
case 'medium':
return 2048
case 'high':
return 4096
case 'xhigh':
case 'max':
return 8192
}
}
function deepMergeJsonValue(target: unknown, patch: unknown): unknown {
if (!isRecord(target) || !isRecord(patch)) return cloneJsonValue(patch)
const merged: Record<string, unknown> = cloneJsonValue(target)
for (const [key, patchValue] of Object.entries(patch)) {
merged[key] = Object.prototype.hasOwnProperty.call(merged, key)
? deepMergeJsonValue(merged[key], patchValue)
: cloneJsonValue(patchValue)
}
return merged
}
function jsonValueDifference(base: unknown, value: unknown): unknown {
if (isRecord(base) && isRecord(value)) {
const difference: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
if (!Object.prototype.hasOwnProperty.call(base, key)) {
difference[key] = cloneJsonValue(item)
continue
}
const nestedDifference = jsonValueDifference(base[key], item)
if (nestedDifference !== undefined) difference[key] = nestedDifference
}
return Object.keys(difference).length > 0 ? difference : undefined
}
return JSON.stringify(base) === JSON.stringify(value)
? undefined
: cloneJsonValue(value)
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map(item => cloneJsonValue(item)) as T
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T
}
return value
}
export function createDefaultModelDirectivesConfig(): ModelDirectivesConfig { export function createDefaultModelDirectivesConfig(): ModelDirectivesConfig {
return { return {
reasoning_effort: { reasoning_effort: {
@@ -143,7 +300,7 @@ export function updateModelDirectiveSuffixEnabled(
enabled: boolean, enabled: boolean,
): string[] { ): string[] {
const normalized = suffixes const normalized = suffixes
.map(item => normalizeDirectiveSuffix(item)) .map(item => normalizeModelDirectiveSuffix(item))
.filter((item): item is string => Boolean(item)) .filter((item): item is string => Boolean(item))
const next = new Set(normalized) const next = new Set(normalized)
if (enabled) next.add(suffix) if (enabled) next.add(suffix)
@@ -153,15 +310,16 @@ export function updateModelDirectiveSuffixEnabled(
return [...known, ...next] return [...known, ...next]
} }
function normalizeMappings(value: unknown): Record<string, unknown> { function normalizeMappings(apiFormat: string, value: unknown): Record<string, unknown> {
if (!isRecord(value)) { if (!isRecord(value)) {
return {} return {}
} }
const normalized: Record<string, unknown> = {} const normalized: Record<string, unknown> = {}
for (const [rawSuffix, mapping] of Object.entries(value)) { for (const [rawSuffix, mapping] of Object.entries(value)) {
const suffix = normalizeDirectiveSuffix(rawSuffix) const suffix = normalizeModelDirectiveSuffix(rawSuffix)
if (!suffix) continue if (!suffix) continue
if (!modelDirectiveSuffixSupportedForApiFormat(apiFormat, suffix)) continue
normalized[suffix] = mapping normalized[suffix] = mapping
} }
return normalized return normalized
@@ -180,9 +338,14 @@ function normalizeSuffixes(
const normalized = new Set( const normalized = new Set(
source source
.filter((item): item is string => typeof item === 'string') .filter((item): item is string => typeof item === 'string')
.map(item => normalizeDirectiveSuffix(item)) .map(item => normalizeModelDirectiveSuffix(item))
.filter((item): item is string => Boolean(item)), .filter((item): item is string => Boolean(item)),
) )
for (const suffix of normalized) {
if (!modelDirectiveSuffixSupportedForApiFormat(apiFormat, suffix)) {
normalized.delete(suffix)
}
}
const known = MODEL_DIRECTIVE_SUFFIXES.filter(item => normalized.delete(item)) const known = MODEL_DIRECTIVE_SUFFIXES.filter(item => normalized.delete(item))
return [...known, ...normalized] return [...known, ...normalized]
@@ -199,7 +362,15 @@ export function normalizeModelDirectivesConfig(value: unknown): ModelDirectivesC
} }
const sourceApiFormats = reasoning.api_formats const sourceApiFormats = reasoning.api_formats
if (isRecord(sourceApiFormats)) { if (isRecord(sourceApiFormats)) {
for (const [apiFormat, rawConfig] of Object.entries(sourceApiFormats)) { const sourceEntries = Object.entries(sourceApiFormats).sort(([left], [right]) => {
const leftCanonical = normalizeModelDirectiveApiFormat(left)
const rightCanonical = normalizeModelDirectiveApiFormat(right)
const leftIsCanonical = left === leftCanonical
const rightIsCanonical = right === rightCanonical
return Number(leftIsCanonical) - Number(rightIsCanonical)
})
for (const [rawApiFormat, rawConfig] of sourceEntries) {
const apiFormat = normalizeModelDirectiveApiFormat(rawApiFormat)
if (typeof rawConfig === 'boolean') { if (typeof rawConfig === 'boolean') {
apiFormats[apiFormat] = { apiFormats[apiFormat] = {
enabled: rawConfig, enabled: rawConfig,
@@ -212,7 +383,7 @@ export function normalizeModelDirectivesConfig(value: unknown): ModelDirectivesC
...preservedConfig, ...preservedConfig,
enabled: typeof rawConfig.enabled === 'boolean' ? rawConfig.enabled : true, enabled: typeof rawConfig.enabled === 'boolean' ? rawConfig.enabled : true,
suffixes: normalizeSuffixes(apiFormat, rawConfig.suffixes, rawConfig.mappings), suffixes: normalizeSuffixes(apiFormat, rawConfig.suffixes, rawConfig.mappings),
mappings: normalizeMappings(rawConfig.mappings), mappings: normalizeMappings(apiFormat, rawConfig.mappings),
} }
} }
} }
@@ -231,7 +402,7 @@ export function normalizeModelDirectivesConfig(value: unknown): ModelDirectivesC
} }
} }
function normalizeDirectiveSuffix(value: string): string | undefined { export function normalizeModelDirectiveSuffix(value: string): string | undefined {
const trimmed = value.trim() const trimmed = value.trim()
if (!trimmed) return undefined if (!trimmed) return undefined
const normalized = trimmed.toLowerCase() const normalized = trimmed.toLowerCase()
@@ -240,6 +411,45 @@ function normalizeDirectiveSuffix(value: string): string | undefined {
: trimmed : trimmed
} }
function normalizeModelDirectiveApiFormat(value: string): string {
const normalized = normalizeApiFormatAlias(value)
switch (normalized) {
case 'openai:cli':
return 'openai:responses'
case 'openai:compact':
return 'openai:responses:compact'
case 'claude:chat':
case 'claude:cli':
return 'claude:messages'
case 'gemini:chat':
case 'gemini:cli':
return 'gemini:generate_content'
case '/v1/chat/completions':
return 'openai:chat'
case '/v1/responses':
return 'openai:responses'
case '/v1/responses/compact':
return 'openai:responses:compact'
case '/v1/alpha/search':
return 'openai:search'
case '/v1/messages':
return 'claude:messages'
default:
return normalized
}
}
function modelDirectiveSuffixSupportedForApiFormat(
apiFormat: string,
suffix: string,
): boolean {
const normalizedSuffix = normalizeModelDirectiveSuffix(suffix)
if (!normalizedSuffix) return false
const builtInSuffix = MODEL_DIRECTIVE_SUFFIXES.find(item => item === normalizedSuffix)
return !builtInSuffix
|| defaultModelDirectiveSuffixesForApiFormat(apiFormat).includes(builtInSuffix)
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
} }