feat(hermes): add model token limits config

This commit is contained in:
晴天
2026-05-26 06:01:41 +08:00
parent 975613416d
commit 56519808d7
6 changed files with 176 additions and 5 deletions

View File

@@ -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

View File

@@ -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<Option<i64>, 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::<i64>()
.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::<i64>()
.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",
&current["modelContextLength"],
)?;
let max_tokens = optional_hermes_model_i64_field(
form,
"modelMaxTokens",
"model.max_tokens",
&current["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"));
}
}

View File

@@ -88,6 +88,8 @@ const MODEL_DEFAULTS = {
modelDefault: '',
modelProvider: 'auto',
modelBaseUrl: '',
modelContextLength: '',
modelMaxTokens: '',
}
const MODEL_ALIASES_DEFAULTS = {
@@ -962,6 +964,14 @@ export function render() {
<span class="hm-field-label">${t('engine.hermesModelConfigBaseUrl')}</span>
<input id="hm-model-base-url" class="hm-input" value="${esc(modelValues.modelBaseUrl)}" placeholder="https://openrouter.ai/api/v1" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesModelConfigContextLength')}</span>
<input id="hm-model-context-length" class="hm-input" type="number" inputmode="numeric" min="1" max="10000000" step="1" value="${esc(modelValues.modelContextLength)}" placeholder="131072" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesModelConfigMaxTokens')}</span>
<input id="hm-model-max-tokens" class="hm-input" type="number" inputmode="numeric" min="1" max="10000000" step="1" value="${esc(modelValues.modelMaxTokens)}" placeholder="8192" ${disabled ? 'disabled' : ''}>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesModelConfigFootnote')}</div>
</div>
@@ -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

View File

@@ -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'),

View File

@@ -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}`)
}

View File

@@ -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/,
)
})