refactor: rewrite model-context to use js-yaml, add context_length to provider form (#177)
* fix: context-length API returns 200K instead of actual model context Two bugs cause the /api/hermes/sessions/context-length endpoint to always return DEFAULT_CONTEXT_LENGTH (200K): 1. getModelContextLength ignores config.yaml model.context_length The function only checks models_dev_cache.json (which doesn't exist in default installations) and falls back to the hardcoded 200K default, completely ignoring the user's explicit model.context_length setting in config.yaml. 2. getDefaultModel regex fails when api_key/base_url come before default The regex /^model:\s*\n\s+default:\s*(.+)$/m assumes 'default' is the first child key under 'model:', but when api_key or base_url appear first in the YAML, the match fails. This causes getModelContextLength to short-circuit to DEFAULT_CONTEXT_LENGTH before even reaching the cache lookup. Fix: - Add getDefaultModelRobust() that extracts the entire model: block first, then searches for default: within it - Add getConfigContextLength() that reads model.context_length from config.yaml as a fallback (matching hermes-agent priority) - Update getModelContextLength() resolution order: 1. models_dev_cache.json (existing) 2. config.yaml model.context_length (new) 3. DEFAULT_CONTEXT_LENGTH (existing fallback) Closes #169 * refactor: rewrite model-context to use js-yaml, add context_length to provider form - Replace fragile regex-based YAML parsing with js-yaml for reliable config.yaml reads - Fix context_length resolution priority: config.yaml override > custom_providers > models_dev_cache > 200K default - Add context_length input field when adding custom providers in ProviderFormModal - Backend: persist context_length to custom_providers models.<model>.context_length in config.yaml - Add i18n keys (contextLength, contextLengthPlaceholder) to all 8 locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use NInputNumber instead of NInput type=number for context_length NInput does not support type="number" in Naive UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: devilardis <53129661@qq.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ export interface CustomProvider {
|
||||
base_url: string
|
||||
api_key: string
|
||||
model: string
|
||||
context_length?: number
|
||||
providerKey?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
|
||||
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, useMessage } from 'naive-ui'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CodexLoginModal from './CodexLoginModal.vue'
|
||||
@@ -29,6 +29,7 @@ const formData = ref({
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: '',
|
||||
context_length: null as number | null,
|
||||
})
|
||||
|
||||
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
@@ -75,7 +76,7 @@ watch(() => formData.value.base_url, (url) => {
|
||||
|
||||
watch(providerType, () => {
|
||||
modelOptions.value = []
|
||||
formData.value = { name: '', base_url: '', api_key: '', model: '' }
|
||||
formData.value = { name: '', base_url: '', api_key: '', model: '', context_length: null }
|
||||
selectedPreset.value = null
|
||||
})
|
||||
|
||||
@@ -154,11 +155,13 @@ async function handleSave() {
|
||||
? selectedPreset.value
|
||||
: null
|
||||
|
||||
const contextLength = formData.value.context_length ?? undefined
|
||||
await modelsStore.addProvider({
|
||||
name: formData.value.name.trim(),
|
||||
base_url: formData.value.base_url.trim(),
|
||||
api_key: formData.value.api_key.trim(),
|
||||
model: formData.value.model,
|
||||
context_length: contextLength,
|
||||
providerKey,
|
||||
})
|
||||
message.success(t('models.providerAdded'))
|
||||
@@ -270,6 +273,16 @@ function handleClose() {
|
||||
</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="providerType === 'custom'" :label="t('models.contextLength')">
|
||||
<NInputNumber
|
||||
v-model:value="formData.context_length as number | null"
|
||||
:placeholder="t('models.contextLengthPlaceholder')"
|
||||
:min="0"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -259,6 +259,8 @@ export default {
|
||||
builtIn: 'Integriert',
|
||||
customType: 'Benutzerdefiniert',
|
||||
provider: 'Anbieter',
|
||||
contextLength: 'Kontextlange',
|
||||
contextLengthPlaceholder: 'z.B. 200000 (optional)',
|
||||
local: 'Lokal ({host})',
|
||||
selectProviderRequired: 'Bitte wahlen Sie einen Anbieter',
|
||||
baseUrlRequired: 'Basis-URL ist erforderlich',
|
||||
|
||||
@@ -284,6 +284,8 @@ export default {
|
||||
builtIn: 'Built-in',
|
||||
customType: 'Custom',
|
||||
provider: 'Provider',
|
||||
contextLength: 'Context Length',
|
||||
contextLengthPlaceholder: 'e.g. 200000 (optional)',
|
||||
local: 'Local ({host})',
|
||||
selectProviderRequired: 'Please select a provider',
|
||||
baseUrlRequired: 'Base URL is required',
|
||||
|
||||
@@ -259,6 +259,8 @@ export default {
|
||||
builtIn: 'Integrado',
|
||||
customType: 'Personalizado',
|
||||
provider: 'Proveedor',
|
||||
contextLength: 'Longitud del contexto',
|
||||
contextLengthPlaceholder: 'ej. 200000 (opcional)',
|
||||
local: 'Local ({host})',
|
||||
selectProviderRequired: 'Por favor, selecciona un proveedor',
|
||||
baseUrlRequired: 'La URL base es obligatoria',
|
||||
|
||||
@@ -259,6 +259,8 @@ export default {
|
||||
builtIn: 'Integre',
|
||||
customType: 'Personnalise',
|
||||
provider: 'Fournisseur',
|
||||
contextLength: 'Longueur du contexte',
|
||||
contextLengthPlaceholder: 'ex. 200000 (facultatif)',
|
||||
local: 'Local ({host})',
|
||||
selectProviderRequired: 'Veuillez selectionner un fournisseur',
|
||||
baseUrlRequired: 'L\'URL de base est requise',
|
||||
|
||||
@@ -259,6 +259,8 @@ export default {
|
||||
builtIn: '組み込み',
|
||||
customType: 'カスタム',
|
||||
provider: 'プロバイダー',
|
||||
contextLength: 'コンテキスト長',
|
||||
contextLengthPlaceholder: '例: 200000(任意)',
|
||||
local: 'ローカル ({host})',
|
||||
selectProviderRequired: 'プロバイダーを選択してください',
|
||||
baseUrlRequired: 'ベース URL は必須です',
|
||||
|
||||
@@ -259,6 +259,8 @@ export default {
|
||||
builtIn: '내장',
|
||||
customType: '사용자 지정',
|
||||
provider: 'Provider',
|
||||
contextLength: '컨텍스트 길이',
|
||||
contextLengthPlaceholder: '예: 200000 (선택사항)',
|
||||
local: '로컬 ({host})',
|
||||
selectProviderRequired: 'Provider를 선택해 주세요',
|
||||
baseUrlRequired: 'Base URL을 입력해 주세요',
|
||||
|
||||
@@ -259,6 +259,8 @@ export default {
|
||||
builtIn: 'Integrado',
|
||||
customType: 'Personalizado',
|
||||
provider: 'Provedor',
|
||||
contextLength: 'Tamanho do contexto',
|
||||
contextLengthPlaceholder: 'ex: 200000 (opcional)',
|
||||
local: 'Local ({host})',
|
||||
selectProviderRequired: 'Por favor, selecione um provedor',
|
||||
baseUrlRequired: 'A URL base e obrigatoria',
|
||||
|
||||
@@ -284,6 +284,8 @@ export default {
|
||||
builtIn: '内置',
|
||||
customType: '自定义',
|
||||
provider: 'Provider',
|
||||
contextLength: '上下文长度',
|
||||
contextLengthPlaceholder: '例如 200000(可选)',
|
||||
local: '本地 ({host})',
|
||||
selectProviderRequired: '请选择 Provider',
|
||||
baseUrlRequired: 'Base URL 为必填项',
|
||||
|
||||
@@ -5,9 +5,17 @@ import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { readConfigYaml, writeConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||
import { logger } from '../../services/logger'
|
||||
|
||||
function buildProviderEntry(name: string, base_url: string, api_key: string, model: string, context_length?: number) {
|
||||
const entry: any = { name, base_url, api_key, model }
|
||||
if (context_length && context_length > 0) {
|
||||
entry.models = { [model]: { context_length } }
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
export async function create(ctx: any) {
|
||||
const { name, base_url, api_key, model, providerKey } = ctx.request.body as {
|
||||
name: string; base_url: string; api_key: string; model: string; providerKey?: string | null
|
||||
const { name, base_url, api_key, model, context_length, providerKey } = ctx.request.body as {
|
||||
name: string; base_url: string; api_key: string; model: string; context_length?: number; providerKey?: string | null
|
||||
}
|
||||
console.log(name, base_url, api_key, model, providerKey)
|
||||
if (!name || !base_url || !model) {
|
||||
@@ -30,8 +38,13 @@ export async function create(ctx: any) {
|
||||
existing.base_url = base_url
|
||||
existing.api_key = api_key
|
||||
existing.model = model
|
||||
if (context_length && context_length > 0) {
|
||||
if (!existing.models) existing.models = {}
|
||||
existing.models[model] = existing.models[model] || {}
|
||||
existing.models[model].context_length = context_length
|
||||
}
|
||||
} else {
|
||||
config.custom_providers.push({ name: name.trim().toLowerCase().replace(/ /g, '-'), base_url, api_key, model })
|
||||
config.custom_providers.push(buildProviderEntry(name.trim().toLowerCase().replace(/ /g, '-'), base_url, api_key, model, context_length))
|
||||
}
|
||||
config.model.default = model
|
||||
config.model.provider = poolKey
|
||||
@@ -51,8 +64,13 @@ export async function create(ctx: any) {
|
||||
existing.base_url = base_url
|
||||
existing.api_key = api_key
|
||||
existing.model = model
|
||||
if (context_length && context_length > 0) {
|
||||
if (!existing.models) existing.models = {}
|
||||
existing.models[model] = existing.models[model] || {}
|
||||
existing.models[model].context_length = context_length
|
||||
}
|
||||
} else {
|
||||
config.custom_providers.push({ name: poolKey, base_url, api_key, model })
|
||||
config.custom_providers.push(buildProviderEntry(poolKey, base_url, api_key, model, context_length))
|
||||
}
|
||||
config.model.default = model
|
||||
config.model.provider = `custom:${poolKey}`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolve, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { readFileSync, existsSync, statSync } from 'fs'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
const HERMES_BASE = resolve(homedir(), '.hermes')
|
||||
const MODELS_DEV_CACHE = resolve(HERMES_BASE, 'models_dev_cache.json')
|
||||
@@ -21,6 +22,18 @@ interface ProviderEntry {
|
||||
models?: Record<string, ModelEntry>
|
||||
}
|
||||
|
||||
// --- Config YAML helpers (js-yaml) ---
|
||||
|
||||
function loadConfig(profileDir: string): any | null {
|
||||
const configPath = join(profileDir, 'config.yaml')
|
||||
if (!existsSync(configPath)) return null
|
||||
try {
|
||||
return yaml.load(readFileSync(configPath, 'utf-8')) as any
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- In-memory cache: parsed models_dev_cache (1.7MB), invalidated by mtime ---
|
||||
|
||||
let _cache: Record<string, ProviderEntry> | null = null
|
||||
@@ -55,16 +68,59 @@ function getProfileDir(profile?: string): string {
|
||||
return existsSync(dir) ? dir : HERMES_BASE
|
||||
}
|
||||
|
||||
function getDefaultModel(profileDir: string): string | null {
|
||||
const configPath = join(profileDir, 'config.yaml')
|
||||
if (!existsSync(configPath)) return null
|
||||
try {
|
||||
const content = readFileSync(configPath, 'utf-8')
|
||||
const match = content.match(/^model:\s*\n\s+default:\s*(.+)$/m)
|
||||
return match ? match[1].trim() : null
|
||||
} catch {
|
||||
return null
|
||||
function getDefaultModel(config: any): string | null {
|
||||
const model = config?.model
|
||||
if (!model || typeof model !== 'object') return null
|
||||
return typeof model.default === 'string' ? model.default.trim() || null : null
|
||||
}
|
||||
|
||||
function getDefaultProvider(config: any): string | null {
|
||||
const model = config?.model
|
||||
if (!model || typeof model !== 'object') return null
|
||||
return typeof model.provider === 'string' ? model.provider.trim() || null : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read context_length from config.yaml, only as a sibling of default.
|
||||
* e.g. model:\n default: gpt-5.4\n context_length: 200000
|
||||
*/
|
||||
function getConfigContextLength(config: any): number | null {
|
||||
const model = config?.model
|
||||
if (!model || typeof model !== 'object') return null
|
||||
const val = model.context_length
|
||||
if (typeof val !== 'number' || !Number.isFinite(val) || val <= 0) return null
|
||||
return val
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup context_length from custom_providers in config.yaml.
|
||||
* - "custom:xxx" → strip prefix, match by name
|
||||
* - "custom" → match by model name
|
||||
*/
|
||||
function lookupCustomProviderContextLength(config: any, modelName: string, provider: string | null): number | null {
|
||||
const providers: any[] = Array.isArray(config?.custom_providers) ? config.custom_providers : []
|
||||
if (!provider || !provider.startsWith('custom')) return null
|
||||
|
||||
let matched: any = null
|
||||
|
||||
if (provider === 'custom') {
|
||||
matched = providers.find((cp: any) => cp.model === modelName)
|
||||
} else {
|
||||
const suffix = provider.slice('custom:'.length)
|
||||
matched = providers.find((cp: any) => cp.name === suffix)
|
||||
}
|
||||
|
||||
if (!matched) return null
|
||||
|
||||
const models = matched.models
|
||||
if (!models || typeof models !== 'object') return null
|
||||
|
||||
const modelEntry = models[modelName]
|
||||
if (!modelEntry || typeof modelEntry !== 'object') return null
|
||||
|
||||
const val = modelEntry.context_length
|
||||
if (typeof val !== 'number' || !Number.isFinite(val) || val <= 0) return null
|
||||
return val
|
||||
}
|
||||
|
||||
// --- Context lookup ---
|
||||
@@ -95,12 +151,33 @@ function lookupContextFromCache(modelName: string): number | null {
|
||||
|
||||
/**
|
||||
* Get the context length for the current profile's default model.
|
||||
* Results are cached in memory (5min TTL) and invalidated by file mtime.
|
||||
* Resolution order:
|
||||
* 1. config.yaml model.context_length (highest priority, user override)
|
||||
* 2. custom_providers models.<model>.context_length
|
||||
* 3. models_dev_cache.json (built-in model database)
|
||||
* 4. DEFAULT_CONTEXT_LENGTH (200K hardcoded fallback)
|
||||
*/
|
||||
export function getModelContextLength(profile?: string): number {
|
||||
const profileDir = getProfileDir(profile)
|
||||
const model = getDefaultModel(profileDir)
|
||||
const config = loadConfig(profileDir)
|
||||
if (!config) return DEFAULT_CONTEXT_LENGTH
|
||||
|
||||
const model = getDefaultModel(config)
|
||||
if (!model) return DEFAULT_CONTEXT_LENGTH
|
||||
|
||||
return lookupContextFromCache(model) || DEFAULT_CONTEXT_LENGTH
|
||||
// 1. Global context_length override in config.yaml
|
||||
const configCtx = getConfigContextLength(config)
|
||||
if (configCtx && configCtx > 0) return configCtx
|
||||
|
||||
// 2. Custom provider context_length
|
||||
const provider = getDefaultProvider(config)
|
||||
const customCtx = lookupCustomProviderContextLength(config, model, provider)
|
||||
if (customCtx && customCtx > 0) return customCtx
|
||||
|
||||
// 3. models_dev_cache.json
|
||||
const cached = lookupContextFromCache(model)
|
||||
if (cached) return cached
|
||||
|
||||
// 4. Fallback
|
||||
return DEFAULT_CONTEXT_LENGTH
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user