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:
ekko
2026-04-24 11:18:11 +08:00
committed by GitHub
parent 30e88797ef
commit 82965ae6e2
12 changed files with 143 additions and 18 deletions
@@ -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
}