mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(hermes): add model token limits config
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
¤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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user