From ac2282591d3ec004493337c4e123d3cc10b941c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Tue, 26 May 2026 03:00:31 +0800 Subject: [PATCH] feat(hermes): add provider routing config --- scripts/dev-api.js | 92 +++++ src-tauri/src/commands/hermes.rs | 395 +++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 177 +++++++-- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 19 + tests/hermes-config-page-ui.test.js | 15 + tests/hermes-provider-routing-config.test.js | 113 ++++++ 8 files changed, 787 insertions(+), 28 deletions(-) create mode 100644 tests/hermes-provider-routing-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 46c03b0..7ae0d4c 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3335,6 +3335,8 @@ const HERMES_APPROVAL_CRON_MODES = new Set(['deny', 'approve']) const HERMES_LOGGING_LEVELS = new Set(['DEBUG', 'INFO', 'WARNING']) const HERMES_AGENT_IMAGE_INPUT_MODES = new Set(['auto', 'native', 'text']) const HERMES_PROMPT_CACHE_TTLS = new Set(['5m', '1h']) +const HERMES_PROVIDER_ROUTING_SORTS = new Set(['price', 'throughput', 'latency']) +const HERMES_PROVIDER_ROUTING_DATA_COLLECTION = new Set(['allow', 'deny']) const HERMES_DISPLAY_TOOL_PROGRESS_VALUES = new Set(['off', 'new', 'all', 'verbose']) const HERMES_DISPLAY_STREAMING_VALUES = new Set(['inherit', 'true', 'false']) const HERMES_DISPLAY_RESUME_VALUES = new Set(['full', 'minimal']) @@ -3506,6 +3508,36 @@ function normalizeHermesPromptCacheTtl(value, strict = false) { return '5m' } +function normalizeHermesProviderRoutingSort(value, strict = false) { + const sort = String(value ?? '').trim().toLowerCase() || 'price' + if (HERMES_PROVIDER_ROUTING_SORTS.has(sort)) return sort + if (strict) throw new Error('provider_routing.sort 必须是 price、throughput 或 latency') + return 'price' +} + +function normalizeHermesProviderRoutingDataCollection(value, strict = false) { + const policy = String(value ?? '').trim().toLowerCase() || 'allow' + if (HERMES_PROVIDER_ROUTING_DATA_COLLECTION.has(policy)) return policy + if (strict) throw new Error('provider_routing.data_collection 必须是 allow 或 deny') + return 'allow' +} + +function normalizeHermesProviderRoutingList(value, key) { + const seen = new Set() + const normalized = [] + for (const item of normalizeHermesMultilineList(value)) { + const provider = String(item ?? '').trim().toLowerCase() + if (!/^[a-zA-Z0-9_.-]+$/.test(provider)) { + throw new Error(`${key} 只能包含字母、数字、下划线、点和短横线`) + } + if (!seen.has(provider)) { + seen.add(provider) + normalized.push(provider) + } + } + return normalized +} + function normalizeHermesAuxiliaryProvider(value, key, strict = false) { const provider = String(value ?? '').trim().toLowerCase() || 'auto' if (HERMES_AUXILIARY_PROVIDERS.has(provider)) return provider @@ -3766,6 +3798,45 @@ export function mergeHermesOpenrouterCacheConfig(config = {}, form = {}) { return next } +export function buildHermesProviderRoutingConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const providerRouting = root.provider_routing && typeof root.provider_routing === 'object' && !Array.isArray(root.provider_routing) + ? root.provider_routing + : {} + return { + providerRoutingSort: normalizeHermesProviderRoutingSort(providerRouting.sort, false), + providerRoutingOnly: normalizeHermesProviderRoutingList(providerRouting.only || [], 'provider_routing.only').join('\n'), + providerRoutingIgnore: normalizeHermesProviderRoutingList(providerRouting.ignore || [], 'provider_routing.ignore').join('\n'), + providerRoutingOrder: normalizeHermesProviderRoutingList(providerRouting.order || [], 'provider_routing.order').join('\n'), + providerRoutingRequireParameters: readHermesBool(providerRouting.require_parameters, false), + providerRoutingDataCollection: normalizeHermesProviderRoutingDataCollection(providerRouting.data_collection, false), + } +} + +export function mergeHermesProviderRoutingConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesProviderRoutingConfigValues(next) + const providerRouting = next.provider_routing && typeof next.provider_routing === 'object' && !Array.isArray(next.provider_routing) + ? mergeConfigsPreservingFields(next.provider_routing, {}) + : {} + providerRouting.sort = normalizeHermesProviderRoutingSort(Object.hasOwn(form, 'providerRoutingSort') ? form.providerRoutingSort : currentValues.providerRoutingSort, true) + providerRouting.require_parameters = formHermesBool(form, 'providerRoutingRequireParameters', currentValues.providerRoutingRequireParameters) + providerRouting.data_collection = normalizeHermesProviderRoutingDataCollection(Object.hasOwn(form, 'providerRoutingDataCollection') ? form.providerRoutingDataCollection : currentValues.providerRoutingDataCollection, true) + + for (const [field, formKey] of [ + ['only', 'providerRoutingOnly'], + ['ignore', 'providerRoutingIgnore'], + ['order', 'providerRoutingOrder'], + ]) { + const values = normalizeHermesProviderRoutingList(Object.hasOwn(form, formKey) ? form[formKey] : currentValues[formKey], `provider_routing.${field}`) + if (values.length) providerRouting[field] = values + else delete providerRouting[field] + } + + next.provider_routing = providerRouting + return next +} + function hermesAuxiliaryTask(root, key) { const auxiliary = root.auxiliary && typeof root.auxiliary === 'object' && !Array.isArray(root.auxiliary) ? root.auxiliary @@ -10952,6 +11023,27 @@ const handlers = { } }, + hermes_provider_routing_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesProviderRoutingConfigValues(config), + } + }, + + hermes_provider_routing_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesProviderRoutingConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesProviderRoutingConfigValues(next), + } + }, + hermes_auxiliary_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index c964cb3..cf00c09 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2168,6 +2168,8 @@ const HERMES_CHANNEL_PLATFORMS: [&str; 10] = [ const HERMES_DISPLAY_TOOL_PROGRESS_VALUES: [&str; 4] = ["off", "new", "all", "verbose"]; const HERMES_DISPLAY_STREAMING_VALUES: [&str; 3] = ["inherit", "true", "false"]; const HERMES_PROMPT_CACHE_TTLS: [&str; 2] = ["5m", "1h"]; +const HERMES_PROVIDER_ROUTING_SORTS: [&str; 3] = ["price", "throughput", "latency"]; +const HERMES_PROVIDER_ROUTING_DATA_COLLECTION: [&str; 2] = ["allow", "deny"]; const HERMES_AUXILIARY_PROVIDERS: [&str; 7] = [ "auto", "openrouter", @@ -2277,6 +2279,67 @@ fn normalize_hermes_prompt_cache_ttl( } } +fn normalize_hermes_provider_routing_sort( + value: Option, + strict: bool, +) -> Result { + let sort = value.unwrap_or_default().trim().to_ascii_lowercase(); + let sort = if sort.is_empty() { + "price".to_string() + } else { + sort + }; + if HERMES_PROVIDER_ROUTING_SORTS.contains(&sort.as_str()) { + Ok(sort) + } else if strict { + Err("provider_routing.sort 必须是 price、throughput 或 latency".to_string()) + } else { + Ok("price".to_string()) + } +} + +fn normalize_hermes_provider_routing_data_collection( + value: Option, + strict: bool, +) -> Result { + let data_collection = value.unwrap_or_default().trim().to_ascii_lowercase(); + let data_collection = if data_collection.is_empty() { + "allow".to_string() + } else { + data_collection + }; + if HERMES_PROVIDER_ROUTING_DATA_COLLECTION.contains(&data_collection.as_str()) { + Ok(data_collection) + } else if strict { + Err("provider_routing.data_collection 必须是 allow 或 deny".to_string()) + } else { + Ok("allow".to_string()) + } +} + +fn normalize_hermes_provider_routing_list( + raw: Option, + key: &str, +) -> Result, String> { + let mut values = Vec::new(); + for item in normalize_hermes_multiline_list(raw) { + let provider = item.trim().to_ascii_lowercase(); + if provider.is_empty() { + continue; + } + if !provider + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-')) + { + return Err(format!("{key} 只能包含 provider slug,每行一个")); + } + if !values.contains(&provider) { + values.push(provider); + } + } + Ok(values) +} + fn normalize_hermes_auxiliary_provider( value: Option, key: &str, @@ -3568,6 +3631,127 @@ fn merge_hermes_openrouter_cache_config( Ok(()) } +fn provider_routing_list_from_yaml( + map: Option<&serde_yaml::Mapping>, + key: &str, +) -> Result, String> { + let raw = map + .map(|map| yaml_string_sequence_field(map, key).join("\n")) + .unwrap_or_default(); + normalize_hermes_provider_routing_list(Some(raw), &format!("provider_routing.{key}")) +} + +fn build_hermes_provider_routing_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let provider_routing = root.and_then(|map| yaml_get_mapping(map, "provider_routing")); + let sort = normalize_hermes_provider_routing_sort( + provider_routing.and_then(|map| yaml_string_field(map, "sort")), + false, + ) + .unwrap_or_else(|_| "price".to_string()); + let data_collection = normalize_hermes_provider_routing_data_collection( + provider_routing.and_then(|map| yaml_string_field(map, "data_collection")), + false, + ) + .unwrap_or_else(|_| "allow".to_string()); + let only = provider_routing_list_from_yaml(provider_routing, "only").unwrap_or_default(); + let ignore = provider_routing_list_from_yaml(provider_routing, "ignore").unwrap_or_default(); + let order = provider_routing_list_from_yaml(provider_routing, "order").unwrap_or_default(); + + serde_json::json!({ + "providerRoutingSort": sort, + "providerRoutingOnly": only.join("\n"), + "providerRoutingIgnore": ignore.join("\n"), + "providerRoutingOrder": order.join("\n"), + "providerRoutingRequireParameters": provider_routing.and_then(|map| yaml_bool_field(map, "require_parameters")).unwrap_or(false), + "providerRoutingDataCollection": data_collection, + }) +} + +fn merge_hermes_provider_routing_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_provider_routing_config_values(config); + let sort = normalize_hermes_provider_routing_sort( + if form.get("providerRoutingSort").is_some() { + form_string(form, "providerRoutingSort") + } else { + current["providerRoutingSort"] + .as_str() + .map(ToString::to_string) + }, + true, + )?; + let data_collection = normalize_hermes_provider_routing_data_collection( + if form.get("providerRoutingDataCollection").is_some() { + form_string(form, "providerRoutingDataCollection") + } else { + current["providerRoutingDataCollection"] + .as_str() + .map(ToString::to_string) + }, + true, + )?; + let require_parameters = + form_bool(form, "providerRoutingRequireParameters").unwrap_or_else(|| { + current["providerRoutingRequireParameters"] + .as_bool() + .unwrap_or(false) + }); + + let only = normalize_hermes_provider_routing_list( + form_string(form, "providerRoutingOnly").or_else(|| { + current["providerRoutingOnly"] + .as_str() + .map(ToString::to_string) + }), + "provider_routing.only", + )?; + let ignore = normalize_hermes_provider_routing_list( + form_string(form, "providerRoutingIgnore").or_else(|| { + current["providerRoutingIgnore"] + .as_str() + .map(ToString::to_string) + }), + "provider_routing.ignore", + )?; + let order = normalize_hermes_provider_routing_list( + form_string(form, "providerRoutingOrder").or_else(|| { + current["providerRoutingOrder"] + .as_str() + .map(ToString::to_string) + }), + "provider_routing.order", + )?; + + let root = ensure_yaml_object(config)?; + let provider_routing = yaml_child_object(root, "provider_routing")?; + provider_routing.insert(yaml_key("sort"), serde_yaml::Value::String(sort)); + provider_routing.insert( + yaml_key("require_parameters"), + serde_yaml::Value::Bool(require_parameters), + ); + provider_routing.insert( + yaml_key("data_collection"), + serde_yaml::Value::String(data_collection), + ); + + for (key, values) in [("only", only), ("ignore", ignore), ("order", order)] { + if values.is_empty() { + provider_routing.remove(yaml_key(key)); + } else { + provider_routing.insert( + yaml_key(key), + serde_yaml::Value::Sequence( + values.into_iter().map(serde_yaml::Value::String).collect(), + ), + ); + } + } + Ok(()) +} + fn hermes_auxiliary_task<'a>( root: Option<&'a serde_yaml::Mapping>, key: &str, @@ -7574,6 +7758,30 @@ pub fn hermes_openrouter_cache_config_save(form: Value) -> Result })) } +#[tauri::command] +pub fn hermes_provider_routing_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_provider_routing_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_provider_routing_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_provider_routing_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_provider_routing_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_auxiliary_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -13334,6 +13542,193 @@ streaming: } } +#[cfg(test)] +mod hermes_provider_routing_config_tests { + use super::{ + build_hermes_provider_routing_config_values, merge_hermes_provider_routing_config, + }; + use serde_json::json; + + #[test] + fn provider_routing_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_provider_routing_config_values(&config); + assert_eq!(values["providerRoutingSort"], "price"); + assert_eq!(values["providerRoutingOnly"], ""); + assert_eq!(values["providerRoutingIgnore"], ""); + assert_eq!(values["providerRoutingOrder"], ""); + assert_eq!(values["providerRoutingRequireParameters"], false); + assert_eq!(values["providerRoutingDataCollection"], "allow"); + } + + #[test] + fn provider_routing_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +provider_routing: + sort: throughput + only: + - anthropic + - google + ignore: + - deepinfra + order: + - anthropic + - google + - together + require_parameters: true + data_collection: deny +"#, + ) + .unwrap(); + + let values = build_hermes_provider_routing_config_values(&config); + assert_eq!(values["providerRoutingSort"], "throughput"); + assert_eq!(values["providerRoutingOnly"], "anthropic\ngoogle"); + assert_eq!(values["providerRoutingIgnore"], "deepinfra"); + assert_eq!( + values["providerRoutingOrder"], + "anthropic\ngoogle\ntogether" + ); + assert_eq!(values["providerRoutingRequireParameters"], true); + assert_eq!(values["providerRoutingDataCollection"], "deny"); + } + + #[test] + fn merge_provider_routing_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: openrouter +openrouter: + response_cache: true +provider_routing: + sort: price + custom_flag: keep-routing +"#, + ) + .unwrap(); + + merge_hermes_provider_routing_config( + &mut config, + &json!({ + "providerRoutingSort": "latency", + "providerRoutingOnly": " anthropic \n google \n anthropic ", + "providerRoutingIgnore": "deepinfra\nfireworks", + "providerRoutingOrder": "google\nanthropic", + "providerRoutingRequireParameters": true, + "providerRoutingDataCollection": "deny", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("openrouter")); + assert_eq!(config["openrouter"]["response_cache"].as_bool(), Some(true)); + assert_eq!(config["provider_routing"]["sort"].as_str(), Some("latency")); + assert_eq!( + config["provider_routing"]["only"].as_sequence().unwrap(), + &vec![ + serde_yaml::Value::String("anthropic".to_string()), + serde_yaml::Value::String("google".to_string()), + ] + ); + assert_eq!( + config["provider_routing"]["ignore"].as_sequence().unwrap(), + &vec![ + serde_yaml::Value::String("deepinfra".to_string()), + serde_yaml::Value::String("fireworks".to_string()), + ] + ); + assert_eq!( + config["provider_routing"]["order"].as_sequence().unwrap(), + &vec![ + serde_yaml::Value::String("google".to_string()), + serde_yaml::Value::String("anthropic".to_string()), + ] + ); + assert_eq!( + config["provider_routing"]["require_parameters"].as_bool(), + Some(true) + ); + assert_eq!( + config["provider_routing"]["data_collection"].as_str(), + Some("deny") + ); + assert_eq!( + config["provider_routing"]["custom_flag"].as_str(), + Some("keep-routing") + ); + } + + #[test] + fn merge_provider_routing_config_removes_empty_lists() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +provider_routing: + only: + - anthropic + ignore: + - deepinfra + order: + - google +"#, + ) + .unwrap(); + + merge_hermes_provider_routing_config( + &mut config, + &json!({ + "providerRoutingOnly": "", + "providerRoutingIgnore": " \n ", + "providerRoutingOrder": "", + "providerRoutingRequireParameters": false, + "providerRoutingDataCollection": "allow", + }), + ) + .unwrap(); + + assert_eq!(config["provider_routing"]["sort"].as_str(), Some("price")); + assert_eq!( + config["provider_routing"]["require_parameters"].as_bool(), + Some(false) + ); + assert_eq!( + config["provider_routing"]["data_collection"].as_str(), + Some("allow") + ); + let provider_routing = config["provider_routing"].as_mapping().unwrap(); + assert!(!provider_routing.contains_key(super::yaml_key("only"))); + assert!(!provider_routing.contains_key(super::yaml_key("ignore"))); + assert!(!provider_routing.contains_key(super::yaml_key("order"))); + } + + #[test] + fn merge_provider_routing_config_rejects_invalid_values() { + for (form, expected) in [ + ( + json!({ "providerRoutingSort": "random" }), + "provider_routing.sort", + ), + ( + json!({ "providerRoutingDataCollection": "maybe" }), + "provider_routing.data_collection", + ), + ( + json!({ "providerRoutingOnly": "bad provider" }), + "provider_routing.only", + ), + ( + json!({ "providerRoutingOrder": "../secret" }), + "provider_routing.order", + ), + ] { + let mut config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let err = merge_hermes_provider_routing_config(&mut config, &form).unwrap_err(); + assert!(err.contains(expected), "{err}"); + } + } +} + #[cfg(test)] mod hermes_auxiliary_config_tests { use super::{build_hermes_auxiliary_config_values, merge_hermes_auxiliary_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ff839f..3c34cfd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -265,6 +265,8 @@ pub fn run() { hermes::hermes_prompt_caching_config_save, hermes::hermes_openrouter_cache_config_read, hermes::hermes_openrouter_cache_config_save, + hermes::hermes_provider_routing_config_read, + hermes::hermes_provider_routing_config_save, hermes::hermes_auxiliary_config_read, hermes::hermes_auxiliary_config_save, hermes::hermes_tool_loop_guardrails_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 7ef6a5b..b98d5dc 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -32,6 +32,15 @@ const OPENROUTER_CACHE_DEFAULTS = { openrouterResponseCacheTtl: 300, } +const PROVIDER_ROUTING_DEFAULTS = { + providerRoutingSort: 'price', + providerRoutingOnly: '', + providerRoutingIgnore: '', + providerRoutingOrder: '', + providerRoutingRequireParameters: false, + providerRoutingDataCollection: 'allow', +} + const AUXILIARY_DEFAULTS = { auxiliaryVisionProvider: 'auto', auxiliaryVisionModel: '', @@ -245,6 +254,8 @@ const APPROVAL_MODES = ['manual', 'smart', 'off'] const APPROVAL_CRON_MODES = ['deny', 'approve'] const LOGGING_LEVELS = ['DEBUG', 'INFO', 'WARNING'] const PROMPT_CACHE_TTLS = ['5m', '1h'] +const PROVIDER_ROUTING_SORTS = ['price', 'throughput', 'latency'] +const PROVIDER_ROUTING_DATA_COLLECTION = ['allow', 'deny'] const AUXILIARY_PROVIDERS = ['auto', 'openrouter', 'nous', 'gemini', 'ollama-cloud', 'codex', 'main'] export function render() { @@ -256,6 +267,7 @@ export function render() { let compressionValues = { ...COMPRESSION_DEFAULTS } let promptCachingValues = { ...PROMPT_CACHING_DEFAULTS } let openrouterCacheValues = { ...OPENROUTER_CACHE_DEFAULTS } + let providerRoutingValues = { ...PROVIDER_ROUTING_DEFAULTS } let auxiliaryValues = { ...AUXILIARY_DEFAULTS } let toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS } let memoryValues = { ...MEMORY_DEFAULTS } @@ -284,6 +296,7 @@ export function render() { let compressionLoading = true let promptCachingLoading = true let openrouterCacheLoading = true + let providerRoutingLoading = true let auxiliaryLoading = true let toolGuardrailsLoading = true let memoryLoading = true @@ -312,6 +325,7 @@ export function render() { let compressionSaving = false let promptCachingSaving = false let openrouterCacheSaving = false + let providerRoutingSaving = false let auxiliarySaving = false let toolGuardrailsSaving = false let memorySaving = false @@ -340,6 +354,7 @@ export function render() { let compressionError = null let promptCachingError = null let openrouterCacheError = null + let providerRoutingError = null let auxiliaryError = null let toolGuardrailsError = null let memoryError = null @@ -373,7 +388,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving } function option(labelKey, value, selected) { @@ -390,7 +405,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -438,7 +453,7 @@ export function render() { } function renderCompressionPanel() { - const disabled = loading || saving || compressionLoading || compressionSaving || promptCachingSaving || openrouterCacheSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || compressionLoading || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -488,7 +503,7 @@ export function render() { } function renderPromptCachingPanel() { - const disabled = loading || saving || promptCachingLoading || promptCachingSaving || openrouterCacheSaving || runtimeSaving || compressionSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || promptCachingLoading || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || runtimeSaving || compressionSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -518,7 +533,7 @@ export function render() { } function renderOpenrouterCachePanel() { - const disabled = loading || saving || openrouterCacheLoading || openrouterCacheSaving || runtimeSaving || compressionSaving || promptCachingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || openrouterCacheLoading || openrouterCacheSaving || providerRoutingSaving || runtimeSaving || compressionSaving || promptCachingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -551,6 +566,62 @@ export function render() { ` } + function renderProviderRoutingPanel() { + const disabled = loading || saving || providerRoutingLoading || providerRoutingSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesProviderRoutingConfigTitle')}
+
${t('engine.hermesProviderRoutingConfigDesc')}
+
+
+ ${providerRoutingSaving ? t('engine.hermesConfigStatusSaving') : providerRoutingLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesProviderRoutingConfigStatusReady')} + +
+
+
+ ${renderError(providerRoutingError)} +
+ + +
+
+ +
+
+ + + +
+
${t('engine.hermesProviderRoutingConfigFootnote')}
+
+
+ ` + } + function auxiliaryProviderOptions(selected) { return AUXILIARY_PROVIDERS .map(provider => option(`engine.hermesAuxiliaryConfigProvider_${provider}`, provider, selected)) @@ -558,7 +629,7 @@ export function render() { } function renderAuxiliaryConfigPanel() { - const disabled = loading || saving || auxiliaryLoading || auxiliarySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || auxiliaryLoading || auxiliarySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -635,7 +706,7 @@ export function render() { } function renderToolGuardrailsPanel() { - const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -697,7 +768,7 @@ export function render() { } function renderMemoryPanel() { - const disabled = loading || saving || memoryLoading || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || memoryLoading || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -747,7 +818,7 @@ export function render() { } function renderSkillsConfigPanel() { - const disabled = loading || saving || skillsLoading || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || skillsLoading || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -779,7 +850,7 @@ export function render() { } function renderQuickCommandsConfigPanel() { - const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -805,7 +876,7 @@ export function render() { } function renderAgentToolsetsConfigPanel() { - const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -831,7 +902,7 @@ export function render() { } function renderPlatformToolsetsConfigPanel() { - const disabled = loading || saving || platformToolsetsLoading || platformToolsetsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || platformToolsetsLoading || platformToolsetsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -857,7 +928,7 @@ export function render() { } function renderAgentRuntimeConfigPanel() { - const disabled = loading || saving || agentRuntimeLoading || agentRuntimeSaving || agentToolsetsSaving || platformToolsetsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving + const disabled = loading || saving || agentRuntimeLoading || agentRuntimeSaving || agentToolsetsSaving || platformToolsetsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving return `
@@ -919,7 +990,7 @@ export function render() { } function renderUnauthorizedDmConfigPanel() { - const disabled = loading || saving || unauthorizedDmLoading || unauthorizedDmSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || securitySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || unauthorizedDmLoading || unauthorizedDmSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || securitySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -949,7 +1020,7 @@ export function render() { } function renderSecurityConfigPanel() { - const disabled = loading || saving || securityLoading || securitySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || securityLoading || securitySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -991,7 +1062,7 @@ export function render() { } function renderDisplayConfigPanel() { - const disabled = loading || saving || displayLoading || displaySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || displayLoading || displaySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -1089,7 +1160,7 @@ export function render() { } function renderHumanDelayConfigPanel() { - const disabled = loading || saving || humanDelayLoading || humanDelaySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || humanDelayLoading || humanDelaySaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -1127,7 +1198,7 @@ export function render() { } function renderStreamingPanel() { - const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -1179,7 +1250,7 @@ export function render() { } function renderExecutionLimitsPanel() { - const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving + const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving return `
@@ -1251,7 +1322,7 @@ export function render() { } function renderIoSafetyPanel() { - const disabled = loading || saving || ioSafetyLoading || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving + const disabled = loading || saving || ioSafetyLoading || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving return `
@@ -1291,7 +1362,7 @@ export function render() { } function renderCheckpointsPanel() { - const disabled = loading || saving || checkpointsLoading || checkpointsSaving || ioSafetySaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving + const disabled = loading || saving || checkpointsLoading || checkpointsSaving || ioSafetySaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving return `
@@ -1349,7 +1420,7 @@ export function render() { } function renderCronPanel() { - const disabled = loading || saving || cronLoading || cronSaving || checkpointsSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving + const disabled = loading || saving || cronLoading || cronSaving || checkpointsSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving return `
@@ -1383,7 +1454,7 @@ export function render() { } function renderLoggingPanel() { - const disabled = loading || saving || loggingLoading || loggingSaving || checkpointsSaving || cronSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving + const disabled = loading || saving || loggingLoading || loggingSaving || checkpointsSaving || cronSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving return `
@@ -1431,7 +1502,7 @@ export function render() { } function renderApprovalsPanel() { - const disabled = loading || saving || approvalsLoading || approvalsSaving || checkpointsSaving || cronSaving || loggingSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving + const disabled = loading || saving || approvalsLoading || approvalsSaving || checkpointsSaving || cronSaving || loggingSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving return `
@@ -1481,7 +1552,7 @@ export function render() { } function renderPrivacyPanel() { - const disabled = loading || saving || privacyLoading || privacySaving || approvalsSaving || cronSaving || loggingSaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving + const disabled = loading || saving || privacyLoading || privacySaving || approvalsSaving || cronSaving || loggingSaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving return `
@@ -1509,7 +1580,7 @@ export function render() { } function renderBrowserPanel() { - const disabled = loading || saving || browserLoading || browserSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || sttSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving + const disabled = loading || saving || browserLoading || browserSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || sttSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving return `
@@ -1553,7 +1624,7 @@ export function render() { } function renderSttPanel() { - const disabled = loading || saving || sttLoading || sttSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving + const disabled = loading || saving || sttLoading || sttSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving return `
@@ -1611,7 +1682,7 @@ export function render() { } function renderTerminalPanel() { - const disabled = loading || saving || terminalLoading || terminalSaving || approvalsSaving || cronSaving || loggingSaving || browserSaving || sttSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving + const disabled = loading || saving || terminalLoading || terminalSaving || approvalsSaving || cronSaving || loggingSaving || browserSaving || sttSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving return `
@@ -1710,6 +1781,7 @@ export function render() { ${renderCompressionPanel()} ${renderPromptCachingPanel()} ${renderOpenrouterCachePanel()} + ${renderProviderRoutingPanel()} ${renderAuxiliaryConfigPanel()} ${renderToolGuardrailsPanel()} ${renderMemoryPanel()} @@ -1745,6 +1817,7 @@ export function render() { el.querySelector('#hm-compression-save')?.addEventListener('click', saveCompression) el.querySelector('#hm-prompt-caching-save')?.addEventListener('click', savePromptCaching) el.querySelector('#hm-openrouter-cache-save')?.addEventListener('click', saveOpenrouterCache) + el.querySelector('#hm-provider-routing-save')?.addEventListener('click', saveProviderRouting) el.querySelector('#hm-auxiliary-save')?.addEventListener('click', saveAuxiliaryConfig) el.querySelector('#hm-tool-guardrails-save')?.addEventListener('click', saveToolGuardrails) el.querySelector('#hm-memory-save')?.addEventListener('click', saveMemory) @@ -1795,6 +1868,11 @@ export function render() { openrouterCacheValues = { ...OPENROUTER_CACHE_DEFAULTS, ...(data?.values || {}) } } + async function loadProviderRouting() { + const data = await api.hermesProviderRoutingConfigRead() + providerRoutingValues = { ...PROVIDER_ROUTING_DEFAULTS, ...(data?.values || {}) } + } + async function loadAuxiliaryConfig() { const data = await api.hermesAuxiliaryConfigRead() auxiliaryValues = { ...AUXILIARY_DEFAULTS, ...(data?.values || {}) } @@ -1916,6 +1994,7 @@ export function render() { compressionLoading = true promptCachingLoading = true openrouterCacheLoading = true + providerRoutingLoading = true auxiliaryLoading = true toolGuardrailsLoading = true memoryLoading = true @@ -1944,6 +2023,7 @@ export function render() { compressionError = null promptCachingError = null openrouterCacheError = null + providerRoutingError = null auxiliaryError = null toolGuardrailsError = null memoryError = null @@ -2007,6 +2087,14 @@ export function render() { openrouterCacheLoading = false draw() } + try { + await loadProviderRouting() + } catch (err) { + providerRoutingError = humanizeError(err, t('engine.hermesProviderRoutingConfigLoadFailed') || 'Load provider routing config failed') + } finally { + providerRoutingLoading = false + draw() + } try { await loadAuxiliaryConfig() } catch (err) { @@ -2224,6 +2312,9 @@ export function render() { try { await loadOpenrouterCache() } catch {} + try { + await loadProviderRouting() + } catch {} try { await loadAuxiliaryConfig() } catch {} @@ -2409,6 +2500,36 @@ export function render() { } } + async function saveProviderRouting() { + const form = { + providerRoutingSort: el.querySelector('#hm-provider-routing-sort')?.value || 'price', + providerRoutingOnly: el.querySelector('#hm-provider-routing-only')?.value || '', + providerRoutingIgnore: el.querySelector('#hm-provider-routing-ignore')?.value || '', + providerRoutingOrder: el.querySelector('#hm-provider-routing-order')?.value || '', + providerRoutingRequireParameters: !!el.querySelector('#hm-provider-routing-require-parameters')?.checked, + providerRoutingDataCollection: el.querySelector('#hm-provider-routing-data-collection')?.value || 'allow', + } + providerRoutingSaving = true + providerRoutingError = null + draw() + try { + const result = await api.hermesProviderRoutingConfigSave(form) + providerRoutingValues = { ...PROVIDER_ROUTING_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesProviderRoutingConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + providerRoutingError = humanizeError(err, t('engine.hermesProviderRoutingConfigSaveFailed') || 'Save provider routing config failed') + toast(providerRoutingError, 'error') + } finally { + providerRoutingSaving = false + draw() + } + } + async function saveAuxiliaryConfig() { const form = { auxiliaryVisionProvider: el.querySelector('#hm-auxiliary-vision-provider')?.value || 'auto', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 0122c3d..0e9c840 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -517,6 +517,8 @@ export const api = { hermesPromptCachingConfigSave: (form) => invoke('hermes_prompt_caching_config_save', { form }), hermesOpenrouterCacheConfigRead: () => invoke('hermes_openrouter_cache_config_read'), hermesOpenrouterCacheConfigSave: (form) => invoke('hermes_openrouter_cache_config_save', { form }), + hermesProviderRoutingConfigRead: () => invoke('hermes_provider_routing_config_read'), + hermesProviderRoutingConfigSave: (form) => invoke('hermes_provider_routing_config_save', { form }), hermesAuxiliaryConfigRead: () => invoke('hermes_auxiliary_config_read'), hermesAuxiliaryConfigSave: (form) => invoke('hermes_auxiliary_config_save', { form }), hermesToolLoopGuardrailsConfigRead: () => invoke('hermes_tool_loop_guardrails_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 8939411..ca008b1 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -725,6 +725,25 @@ export default { hermesOpenrouterCacheConfigResponseCache: _('启用 response_cache', 'Enable response_cache', '啟用 response_cache'), hermesOpenrouterCacheConfigResponseCacheTtl: _('缓存有效期(秒)', 'Cache TTL (sec)', '快取有效期(秒)'), hermesOpenrouterCacheConfigFootnote: _('这里写入 openrouter.response_cache 和 openrouter.response_cache_ttl。OpenRouter 其他高级字段会保留在 raw YAML 中。', 'This writes openrouter.response_cache and openrouter.response_cache_ttl. Other advanced OpenRouter fields stay in raw YAML.', '這裡寫入 openrouter.response_cache 和 openrouter.response_cache_ttl。OpenRouter 其他進階欄位會保留在 raw YAML 中。'), + hermesProviderRoutingConfigTitle: _('OpenRouter 路由', 'OpenRouter provider routing', 'OpenRouter 路由'), + hermesProviderRoutingConfigDesc: _('为 OpenRouter 指定 provider 排序、白名单、黑名单和隐私偏好,用于控制成本、速度和可用性。', 'Set OpenRouter provider sorting, allowlist, blocklist, and privacy preferences to control cost, speed, and availability.', '為 OpenRouter 指定 provider 排序、白名單、黑名單和隱私偏好,用於控制成本、速度和可用性。'), + hermesProviderRoutingConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesProviderRoutingConfigSave: _('保存路由策略', 'Save routing policy', '儲存路由策略'), + hermesProviderRoutingConfigSaveSuccess: _('OpenRouter 路由策略已保存,建议重启 Hermes Gateway 生效', 'OpenRouter routing policy saved. Restart Hermes Gateway to take effect.', 'OpenRouter 路由策略已儲存,建議重啟 Hermes Gateway 生效'), + hermesProviderRoutingConfigLoadFailed: _('加载 OpenRouter 路由策略失败', 'Load OpenRouter routing policy failed', '載入 OpenRouter 路由策略失敗'), + hermesProviderRoutingConfigSaveFailed: _('保存 OpenRouter 路由策略失败', 'Save OpenRouter routing policy failed', '儲存 OpenRouter 路由策略失敗'), + hermesProviderRoutingConfigSort: _('排序策略', 'Sort policy', '排序策略'), + hermesProviderRoutingConfigSort_price: _('优先低价格', 'Prefer lower price', '優先低價格'), + hermesProviderRoutingConfigSort_throughput: _('优先高吞吐', 'Prefer higher throughput', '優先高吞吐'), + hermesProviderRoutingConfigSort_latency: _('优先低延迟', 'Prefer lower latency', '優先低延遲'), + hermesProviderRoutingConfigOnly: _('只使用这些 provider(每行一个)', 'Only use these providers (one per line)', '只使用這些 provider(每行一個)'), + hermesProviderRoutingConfigIgnore: _('排除这些 provider(每行一个)', 'Ignore these providers (one per line)', '排除這些 provider(每行一個)'), + hermesProviderRoutingConfigOrder: _('优先顺序(每行一个)', 'Preferred order (one per line)', '優先順序(每行一個)'), + hermesProviderRoutingConfigRequireParameters: _('只选择支持当前参数的 provider', 'Only choose providers that support current parameters', '只選擇支援目前參數的 provider'), + hermesProviderRoutingConfigDataCollection: _('数据收集偏好', 'Data collection preference', '資料收集偏好'), + hermesProviderRoutingConfigDataCollection_allow: _('允许', 'Allow', '允許'), + hermesProviderRoutingConfigDataCollection_deny: _('拒绝', 'Deny', '拒絕'), + hermesProviderRoutingConfigFootnote: _('这里写入 provider_routing.sort、only、ignore、order、require_parameters 和 data_collection。未知字段会保留在 raw YAML 中。', 'This writes provider_routing.sort, only, ignore, order, require_parameters, and data_collection. Unknown fields stay in raw YAML.', '這裡寫入 provider_routing.sort、only、ignore、order、require_parameters 和 data_collection。未知欄位會保留在 raw YAML 中。'), hermesAuxiliaryConfigTitle: _('辅助模型', 'Auxiliary models', '輔助模型'), hermesAuxiliaryConfigDesc: _('为图片分析、网页提取和历史会话搜索指定独立模型,避免这些任务挤占主对话模型。', 'Assign separate models for image analysis, web extraction, and session search so these tasks do not compete with the main chat model.', '為圖片分析、網頁提取和歷史會話搜尋指定獨立模型,避免這些任務擠占主對話模型。'), hermesAuxiliaryConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index b226230..b55f986 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -160,6 +160,20 @@ test('Hermes 配置页会暴露 OpenRouter 响应缓存结构化配置字段', ( } }) +test('Hermes 配置页会暴露 OpenRouter Provider Routing 结构化配置字段', () => { + for (const id of [ + 'hm-provider-routing-save', + 'hm-provider-routing-sort', + 'hm-provider-routing-only', + 'hm-provider-routing-ignore', + 'hm-provider-routing-order', + 'hm-provider-routing-require-parameters', + 'hm-provider-routing-data-collection', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露辅助模型结构化配置字段', () => { for (const id of [ 'hm-auxiliary-save', @@ -345,6 +359,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('DisplayConfig') || key.includes('PromptCachingConfig') || key.includes('OpenrouterCacheConfig') || + key.includes('ProviderRoutingConfig') || key.includes('AuxiliaryConfig') || key.includes('StreamingConfig') || key.includes('ExecutionLimits') || diff --git a/tests/hermes-provider-routing-config.test.js b/tests/hermes-provider-routing-config.test.js new file mode 100644 index 0000000..b67f01c --- /dev/null +++ b/tests/hermes-provider-routing-config.test.js @@ -0,0 +1,113 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesProviderRoutingConfigValues, + mergeHermesProviderRoutingConfig, +} from '../scripts/dev-api.js' + +test('Hermes Provider Routing 配置读取会提供上游默认值', () => { + const values = buildHermesProviderRoutingConfigValues({}) + + assert.deepEqual(values, { + providerRoutingSort: 'price', + providerRoutingOnly: '', + providerRoutingIgnore: '', + providerRoutingOrder: '', + providerRoutingRequireParameters: false, + providerRoutingDataCollection: 'allow', + }) +}) + +test('Hermes Provider Routing 配置读取会回显 YAML 字段', () => { + const values = buildHermesProviderRoutingConfigValues({ + provider_routing: { + sort: 'throughput', + only: ['anthropic', 'google'], + ignore: ['deepinfra'], + order: ['anthropic', 'google', 'together'], + require_parameters: true, + data_collection: 'deny', + }, + }) + + assert.equal(values.providerRoutingSort, 'throughput') + assert.equal(values.providerRoutingOnly, 'anthropic\ngoogle') + assert.equal(values.providerRoutingIgnore, 'deepinfra') + assert.equal(values.providerRoutingOrder, 'anthropic\ngoogle\ntogether') + assert.equal(values.providerRoutingRequireParameters, true) + assert.equal(values.providerRoutingDataCollection, 'deny') +}) + +test('Hermes Provider Routing 配置保存会保留未知字段并写入上游结构', () => { + const next = mergeHermesProviderRoutingConfig({ + model: { provider: 'openrouter' }, + provider_routing: { + sort: 'price', + only: ['anthropic'], + custom_flag: 'keep-routing', + }, + openrouter: { + response_cache: true, + }, + }, { + providerRoutingSort: 'latency', + providerRoutingOnly: ' anthropic \n google \n anthropic ', + providerRoutingIgnore: 'deepinfra\nfireworks', + providerRoutingOrder: 'google\nanthropic', + providerRoutingRequireParameters: true, + providerRoutingDataCollection: 'deny', + }) + + assert.deepEqual(next.model, { provider: 'openrouter' }) + assert.deepEqual(next.openrouter, { response_cache: true }) + assert.equal(next.provider_routing.sort, 'latency') + assert.deepEqual(next.provider_routing.only, ['anthropic', 'google']) + assert.deepEqual(next.provider_routing.ignore, ['deepinfra', 'fireworks']) + assert.deepEqual(next.provider_routing.order, ['google', 'anthropic']) + assert.equal(next.provider_routing.require_parameters, true) + assert.equal(next.provider_routing.data_collection, 'deny') + assert.equal(next.provider_routing.custom_flag, 'keep-routing') +}) + +test('Hermes Provider Routing 配置保存会移除空列表并保留基础策略', () => { + const next = mergeHermesProviderRoutingConfig({ + provider_routing: { + only: ['anthropic'], + ignore: ['deepinfra'], + order: ['google'], + }, + }, { + providerRoutingOnly: '', + providerRoutingIgnore: ' \n ', + providerRoutingOrder: '', + providerRoutingRequireParameters: false, + providerRoutingDataCollection: 'allow', + }) + + assert.equal(next.provider_routing.sort, 'price') + assert.equal(next.provider_routing.require_parameters, false) + assert.equal(next.provider_routing.data_collection, 'allow') + assert.equal(Object.hasOwn(next.provider_routing, 'only'), false) + assert.equal(Object.hasOwn(next.provider_routing, 'ignore'), false) + assert.equal(Object.hasOwn(next.provider_routing, 'order'), false) +}) + +test('Hermes Provider Routing 配置保存会拒绝非法枚举和 provider slug', () => { + assert.throws( + () => mergeHermesProviderRoutingConfig({}, { providerRoutingSort: 'random' }), + /provider_routing\.sort/, + ) + assert.throws( + () => mergeHermesProviderRoutingConfig({}, { providerRoutingDataCollection: 'maybe' }), + /provider_routing\.data_collection/, + ) + assert.throws( + () => mergeHermesProviderRoutingConfig({}, { providerRoutingOnly: 'bad provider' }), + /provider_routing\.only/, + ) + assert.throws( + () => mergeHermesProviderRoutingConfig({}, { providerRoutingOrder: '../secret' }), + /provider_routing\.order/, + ) +})