mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 20:19:51 +08:00
fix(model-directives): harden suffix configuration
This commit is contained in:
@@ -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"] {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+220
-10
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+143
-1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user