From 56519808d78547509e57eaf489595d6f98bba349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Tue, 26 May 2026 06:01:41 +0800 Subject: [PATCH] feat(hermes): add model token limits config --- scripts/dev-api.js | 14 ++++ src-tauri/src/commands/hermes.rs | 125 +++++++++++++++++++++++++++- src/engines/hermes/pages/config.js | 12 +++ src/locales/modules/engine.js | 4 +- tests/hermes-config-page-ui.test.js | 2 + tests/hermes-model-config.test.js | 24 +++++- 6 files changed, 176 insertions(+), 5 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index f72d21b..ae15cab 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4303,6 +4303,12 @@ function normalizeHermesModelConfigString(value, key, required = false) { return text } +function normalizeHermesOptionalModelInteger(value, key) { + const raw = String(value ?? '').trim() + if (!raw) return '' + return parseHermesInteger(raw, key, 0, 1, 10000000, true) +} + export function buildHermesModelConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const model = root.model && typeof root.model === 'object' && !Array.isArray(root.model) ? root.model : {} @@ -4311,6 +4317,8 @@ export function buildHermesModelConfigValues(config = {}) { modelDefault: typeof defaultModel === 'string' ? defaultModel.trim() : '', modelProvider: typeof model.provider === 'string' && model.provider.trim() ? model.provider.trim() : 'auto', modelBaseUrl: typeof model.base_url === 'string' ? model.base_url.trim() : '', + modelContextLength: Number.isInteger(model.context_length) && model.context_length > 0 ? String(model.context_length) : '', + modelMaxTokens: Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? String(model.max_tokens) : '', } } @@ -4325,6 +4333,12 @@ export function mergeHermesModelConfig(config = {}, form = {}) { const baseUrl = normalizeHermesModelConfigString(Object.hasOwn(form, 'modelBaseUrl') ? form.modelBaseUrl : currentValues.modelBaseUrl, 'model.base_url') if (baseUrl) model.base_url = baseUrl else delete model.base_url + const contextLength = normalizeHermesOptionalModelInteger(Object.hasOwn(form, 'modelContextLength') ? form.modelContextLength : currentValues.modelContextLength, 'model.context_length') + if (contextLength) model.context_length = contextLength + else delete model.context_length + const maxTokens = normalizeHermesOptionalModelInteger(Object.hasOwn(form, 'modelMaxTokens') ? form.modelMaxTokens : currentValues.modelMaxTokens, 'model.max_tokens') + if (maxTokens) model.max_tokens = maxTokens + else delete model.max_tokens delete model.model next.model = model return next diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 1a23147..96ae7ce 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4434,6 +4434,53 @@ fn hermes_model_form_string( Ok(current.as_str().map(ToString::to_string)) } +fn optional_hermes_model_i64_field( + form: &Value, + form_key: &str, + yaml_key_name: &str, + current: &Value, +) -> Result, String> { + let raw = if let Some(value) = form.get(form_key) { + if value.is_null() { + None + } else if let Some(text) = value.as_str() { + let text = text.trim(); + if text.is_empty() { + None + } else { + Some( + text.parse::() + .map_err(|_| format!("{yaml_key_name} 必须是整数"))?, + ) + } + } else if let Some(value) = value.as_i64() { + Some(value) + } else if let Some(value) = value.as_u64() { + Some(i64::try_from(value).map_err(|_| format!("{yaml_key_name} 必须是整数"))?) + } else { + return Err(format!("{yaml_key_name} 必须是整数")); + } + } else if let Some(text) = current.as_str() { + let text = text.trim(); + if text.is_empty() { + None + } else { + Some( + text.parse::() + .map_err(|_| format!("{yaml_key_name} 必须是整数"))?, + ) + } + } else { + None + }; + + match raw { + Some(value) if (1..=10_000_000).contains(&value) => Ok(Some(value)), + Some(_) => Err(format!("{yaml_key_name} 必须在 1-10000000 范围内")), + None => Ok(None), + } +} + fn build_hermes_model_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let model = root @@ -4460,11 +4507,23 @@ fn build_hermes_model_config_values(config: &serde_yaml::Value) -> Value { .unwrap_or_default() .trim() .to_string(); + let context_length = model + .and_then(|map| yaml_i64_field(map, "context_length")) + .filter(|value| *value > 0) + .map(|value| value.to_string()) + .unwrap_or_default(); + let max_tokens = model + .and_then(|map| yaml_i64_field(map, "max_tokens")) + .filter(|value| *value > 0) + .map(|value| value.to_string()) + .unwrap_or_default(); serde_json::json!({ "modelDefault": model_default, "modelProvider": provider, "modelBaseUrl": base_url, + "modelContextLength": context_length, + "modelMaxTokens": max_tokens, }) } @@ -4500,6 +4559,18 @@ fn merge_hermes_model_config(config: &mut serde_yaml::Value, form: &Value) -> Re "model.base_url", false, )?; + let context_length = optional_hermes_model_i64_field( + form, + "modelContextLength", + "model.context_length", + ¤t["modelContextLength"], + )?; + let max_tokens = optional_hermes_model_i64_field( + form, + "modelMaxTokens", + "model.max_tokens", + ¤t["modelMaxTokens"], + )?; let root = ensure_yaml_object(config)?; let mut model = root @@ -4517,6 +4588,22 @@ fn merge_hermes_model_config(config: &mut serde_yaml::Value, form: &Value) -> Re } else { model.insert(yaml_key("base_url"), serde_yaml::Value::String(base_url)); } + if let Some(context_length) = context_length { + model.insert( + yaml_key("context_length"), + serde_yaml::Value::Number(context_length.into()), + ); + } else { + model.remove(yaml_key("context_length")); + } + if let Some(max_tokens) = max_tokens { + model.insert( + yaml_key("max_tokens"), + serde_yaml::Value::Number(max_tokens.into()), + ); + } else { + model.remove(yaml_key("max_tokens")); + } model.remove(yaml_key("model")); root.insert(yaml_key("model"), serde_yaml::Value::Mapping(model)); Ok(()) @@ -16802,6 +16889,8 @@ mod hermes_model_config_tests { assert_eq!(values["modelDefault"], ""); assert_eq!(values["modelProvider"], "auto"); assert_eq!(values["modelBaseUrl"], ""); + assert_eq!(values["modelContextLength"], ""); + assert_eq!(values["modelMaxTokens"], ""); let config: serde_yaml::Value = serde_yaml::from_str( r#" @@ -16809,6 +16898,8 @@ model: model: anthropic/claude-sonnet-4-6 provider: openrouter base_url: https://openrouter.ai/api/v1 + context_length: 131072 + max_tokens: 8192 "#, ) .unwrap(); @@ -16816,6 +16907,8 @@ model: assert_eq!(values["modelDefault"], "anthropic/claude-sonnet-4-6"); assert_eq!(values["modelProvider"], "openrouter"); assert_eq!(values["modelBaseUrl"], "https://openrouter.ai/api/v1"); + assert_eq!(values["modelContextLength"], "131072"); + assert_eq!(values["modelMaxTokens"], "8192"); } #[test] @@ -16840,6 +16933,8 @@ memory: "modelDefault": "anthropic/claude-opus-4.6", "modelProvider": "openrouter", "modelBaseUrl": "https://openrouter.ai/api/v1", + "modelContextLength": "262144", + "modelMaxTokens": "16384", }), ) .unwrap(); @@ -16853,8 +16948,9 @@ memory: config["model"]["base_url"].as_str(), Some("https://openrouter.ai/api/v1") ); + assert_eq!(config["model"]["context_length"].as_i64(), Some(262144)); + assert_eq!(config["model"]["max_tokens"].as_i64(), Some(16384)); assert_eq!(config["model"]["auth_mode"].as_str(), Some("env")); - assert_eq!(config["model"]["context_length"].as_i64(), Some(200000)); assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true)); } @@ -16879,6 +16975,8 @@ display: "modelDefault": "google/gemini-3-flash-preview", "modelProvider": "auto", "modelBaseUrl": " ", + "modelContextLength": "", + "modelMaxTokens": " ", }), ) .unwrap(); @@ -16890,7 +16988,8 @@ display: assert_eq!(config["model"]["provider"].as_str(), Some("auto")); assert!(config["model"]["base_url"].is_null()); assert!(config["model"]["model"].is_null()); - assert_eq!(config["model"]["max_tokens"].as_i64(), Some(8192)); + assert!(config["model"]["context_length"].is_null()); + assert!(config["model"]["max_tokens"].is_null()); assert_eq!(config["display"]["language"].as_str(), Some("zh")); } @@ -16938,6 +17037,28 @@ model: ) .unwrap_err(); assert!(err.contains("model.base_url")); + + let err = merge_hermes_model_config( + &mut config, + &json!({ + "modelDefault": "gpt-5", + "modelProvider": "auto", + "modelContextLength": "0", + }), + ) + .unwrap_err(); + assert!(err.contains("model.context_length")); + + let err = merge_hermes_model_config( + &mut config, + &json!({ + "modelDefault": "gpt-5", + "modelProvider": "auto", + "modelMaxTokens": "1.5", + }), + ) + .unwrap_err(); + assert!(err.contains("model.max_tokens")); } } diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 6009e0d..d80ff99 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -88,6 +88,8 @@ const MODEL_DEFAULTS = { modelDefault: '', modelProvider: 'auto', modelBaseUrl: '', + modelContextLength: '', + modelMaxTokens: '', } const MODEL_ALIASES_DEFAULTS = { @@ -962,6 +964,14 @@ export function render() { ${t('engine.hermesModelConfigBaseUrl')} + +
${t('engine.hermesModelConfigFootnote')}
@@ -3018,6 +3028,8 @@ export function render() { modelDefault: el.querySelector('#hm-model-default')?.value || '', modelProvider: el.querySelector('#hm-model-provider')?.value || 'auto', modelBaseUrl: el.querySelector('#hm-model-base-url')?.value || '', + modelContextLength: el.querySelector('#hm-model-context-length')?.value || '', + modelMaxTokens: el.querySelector('#hm-model-max-tokens')?.value || '', } modelSaving = true modelError = null diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 3db1d88..7415ba2 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -834,7 +834,9 @@ export default { hermesModelConfigDefault: _('默认模型', 'Default model', '預設模型'), hermesModelConfigProvider: _('Provider', 'Provider', 'Provider'), hermesModelConfigBaseUrl: _('兼容接口地址(可选)', 'Compatible API base URL (optional)', '相容介面位址(可選)'), - hermesModelConfigFootnote: _('默认模型不能为空;provider 为空时建议填 auto。兼容接口地址留空会移除 model.base_url,并保留 model 下其它高级字段。', 'Default model is required; use auto when no provider is pinned. Leaving the base URL blank removes model.base_url while preserving other advanced model fields.', '預設模型不可為空;未固定 provider 時建議填 auto。相容介面位址留空會移除 model.base_url,並保留 model 下其他進階欄位。'), + hermesModelConfigContextLength: _('上下文窗口(可选)', 'Context window (optional)', '上下文視窗(可選)'), + hermesModelConfigMaxTokens: _('单次输出上限(可选)', 'Output token cap (optional)', '單次輸出上限(可選)'), + hermesModelConfigFootnote: _('上下文窗口是输入和输出合计容量,单次输出上限只限制回复长度;两项留空会移除对应字段,由 Hermes 自动检测或使用模型默认值。', 'Context window is the total input plus output capacity. Output token cap only limits reply length. Leave either blank to remove that field so Hermes auto-detects or uses the model default.', '上下文視窗是輸入與輸出合計容量,單次輸出上限只限制回覆長度;兩項留空會移除對應欄位,由 Hermes 自動偵測或使用模型預設值。'), hermesModelAliasesConfigTitle: _('模型别名', 'Model aliases', '模型別名'), hermesModelAliasesConfigDesc: _('配置 /model 命令可用的短别名,把常用模型、provider 和自定义 base_url 固定下来,减少手输错误。', 'Configure short aliases for the /model command, pinning common models, providers, and custom base_url values to reduce manual input errors.', '設定 /model 命令可用的短別名,把常用模型、provider 和自訂 base_url 固定下來,減少手動輸入錯誤。'), hermesModelAliasesConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 0f236bc..fc3901e 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -78,6 +78,8 @@ test('Hermes 配置页会暴露基础模型结构化配置字段', () => { 'hm-model-default', 'hm-model-provider', 'hm-model-base-url', + 'hm-model-context-length', + 'hm-model-max-tokens', ]) { assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) } diff --git a/tests/hermes-model-config.test.js b/tests/hermes-model-config.test.js index 7e78315..efd2a4f 100644 --- a/tests/hermes-model-config.test.js +++ b/tests/hermes-model-config.test.js @@ -11,6 +11,8 @@ test('Hermes 基础模型配置读取会提供默认值并兼容 model.model', ( modelDefault: '', modelProvider: 'auto', modelBaseUrl: '', + modelContextLength: '', + modelMaxTokens: '', }) const values = buildHermesModelConfigValues({ @@ -18,6 +20,8 @@ test('Hermes 基础模型配置读取会提供默认值并兼容 model.model', ( model: 'anthropic/claude-sonnet-4-6', provider: 'openrouter', base_url: 'https://openrouter.ai/api/v1', + context_length: 131072, + max_tokens: 8192, }, }) @@ -25,6 +29,8 @@ test('Hermes 基础模型配置读取会提供默认值并兼容 model.model', ( modelDefault: 'anthropic/claude-sonnet-4-6', modelProvider: 'openrouter', modelBaseUrl: 'https://openrouter.ai/api/v1', + modelContextLength: '131072', + modelMaxTokens: '8192', }) }) @@ -42,14 +48,17 @@ test('Hermes 基础模型配置保存会保留未知字段并写入 model.defaul modelDefault: 'anthropic/claude-opus-4.6', modelProvider: 'openrouter', modelBaseUrl: 'https://openrouter.ai/api/v1', + modelContextLength: '262144', + modelMaxTokens: '16384', }) assert.deepEqual(next.memory, { memory_enabled: true }) assert.equal(next.model.default, 'anthropic/claude-opus-4.6') assert.equal(next.model.provider, 'openrouter') assert.equal(next.model.base_url, 'https://openrouter.ai/api/v1') + assert.equal(next.model.context_length, 262144) + assert.equal(next.model.max_tokens, 16384) assert.equal(next.model.auth_mode, 'env') - assert.equal(next.model.context_length, 200000) }) test('Hermes 基础模型配置保存空 base_url 会删除该字段但保留 model 其它字段', () => { @@ -65,12 +74,15 @@ test('Hermes 基础模型配置保存空 base_url 会删除该字段但保留 mo modelDefault: 'google/gemini-3-flash-preview', modelProvider: 'auto', modelBaseUrl: ' ', + modelContextLength: '', + modelMaxTokens: ' ', }) assert.equal(next.model.default, 'google/gemini-3-flash-preview') assert.equal(next.model.provider, 'auto') assert.equal(Object.hasOwn(next.model, 'base_url'), false) - assert.equal(next.model.max_tokens, 8192) + assert.equal(Object.hasOwn(next.model, 'context_length'), false) + assert.equal(Object.hasOwn(next.model, 'max_tokens'), false) assert.deepEqual(next.display, { language: 'zh' }) }) @@ -87,4 +99,12 @@ test('Hermes 基础模型配置保存会拒绝空模型和字段类型错误', ( () => mergeHermesModelConfig({}, { modelDefault: 'gpt-5', modelProvider: 'auto', modelBaseUrl: 123 }), /model\.base_url/, ) + assert.throws( + () => mergeHermesModelConfig({}, { modelDefault: 'gpt-5', modelProvider: 'auto', modelContextLength: '0' }), + /model\.context_length/, + ) + assert.throws( + () => mergeHermesModelConfig({}, { modelDefault: 'gpt-5', modelProvider: 'auto', modelMaxTokens: '1.5' }), + /model\.max_tokens/, + ) })