Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
let homeDir = ''
|
|
const originalHermesHome = process.env.HERMES_HOME
|
|
const originalLocalAppData = process.env.LOCALAPPDATA
|
|
const originalAppData = process.env.APPDATA
|
|
|
|
function hermesPath(...parts: string[]) {
|
|
return join(homeDir, '.hermes', ...parts)
|
|
}
|
|
|
|
function writeConfig(content: string) {
|
|
mkdirSync(hermesPath(), { recursive: true })
|
|
writeFileSync(hermesPath('config.yaml'), content)
|
|
}
|
|
|
|
function writeModelsCache(data: Record<string, unknown>) {
|
|
mkdirSync(hermesPath(), { recursive: true })
|
|
writeFileSync(hermesPath('models_dev_cache.json'), JSON.stringify(data))
|
|
}
|
|
|
|
async function loadModelContext() {
|
|
process.env.HERMES_HOME = hermesPath()
|
|
delete process.env.LOCALAPPDATA
|
|
delete process.env.APPDATA
|
|
vi.resetModules()
|
|
vi.doMock('os', async () => ({
|
|
...(await vi.importActual<typeof import('os')>('os')),
|
|
homedir: () => homeDir,
|
|
}))
|
|
// Mock getDb to return null to avoid "database is locked" errors in parallel tests
|
|
vi.doMock('../../packages/server/src/db/index', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/db/index')>('../../packages/server/src/db/index')
|
|
return {
|
|
...actual,
|
|
getDb: () => null,
|
|
}
|
|
})
|
|
return import('../../packages/server/src/services/hermes/model-context')
|
|
}
|
|
|
|
describe('getModelContextLength', () => {
|
|
beforeEach(() => {
|
|
homeDir = mkdtempSync(join(tmpdir(), 'hwui-model-context-'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.doUnmock('os')
|
|
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
|
else process.env.HERMES_HOME = originalHermesHome
|
|
if (originalLocalAppData === undefined) delete process.env.LOCALAPPDATA
|
|
else process.env.LOCALAPPDATA = originalLocalAppData
|
|
if (originalAppData === undefined) delete process.env.APPDATA
|
|
else process.env.APPDATA = originalAppData
|
|
if (homeDir) rmSync(homeDir, { recursive: true, force: true })
|
|
homeDir = ''
|
|
})
|
|
|
|
it('does not borrow a same-named model context from another provider when the configured provider is uncached', async () => {
|
|
writeConfig(`model:\n default: gpt-5.5\n provider: openai-codex\n`)
|
|
writeModelsCache({
|
|
openai: {
|
|
models: {
|
|
'gpt-5.5': { limit: { context: 1_050_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(256_000)
|
|
})
|
|
|
|
it('does not scan other providers when the configured provider exists without that model', async () => {
|
|
writeConfig(`model:\n default: gpt-5.5\n provider: openai-codex\n`)
|
|
writeModelsCache({
|
|
'openai-codex': {
|
|
models: {
|
|
'gpt-5.4': { limit: { context: 256_000 } },
|
|
},
|
|
},
|
|
openai: {
|
|
models: {
|
|
'gpt-5.5': { limit: { context: 1_050_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(256_000)
|
|
})
|
|
|
|
it('uses the configured provider cache entry when the provider matches', async () => {
|
|
writeConfig(`model:\n default: gpt-5.5\n provider: openai\n`)
|
|
writeModelsCache({
|
|
openai: {
|
|
models: {
|
|
'gpt-5.5': { limit: { context: 1_050_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_050_000)
|
|
})
|
|
|
|
it('prefers requested provider model context_length over top-level default context_length', async () => {
|
|
writeConfig(`model:\n default: gpt-5.5\n provider: openai-codex\n context_length: 272000\n\nproviders:\n qwen:\n name: Qwen\n default_model: qwen3.6-plus\n models:\n qwen3.6-plus:\n context_length: 1048576\n`)
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength({ provider: 'qwen', model: 'qwen3.6-plus' })).toBe(1_048_576)
|
|
})
|
|
|
|
it('uses provider-level context_length when the requested model belongs to that provider', async () => {
|
|
writeConfig(`model:\n default: gpt-5.5\n provider: openai-codex\n context_length: 272000\n\nproviders:\n qwen:\n name: Qwen\n default_model: qwen3.6-plus\n models:\n - qwen3.6-plus\n context_length: 1048576\n`)
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength({ provider: 'qwen', model: 'qwen3.6-plus' })).toBe(1_048_576)
|
|
})
|
|
|
|
it('keeps legacy model-name cache lookup when no provider is configured', async () => {
|
|
writeConfig(`model:\n default: gpt-5.5\n`)
|
|
writeModelsCache({
|
|
openai: {
|
|
models: {
|
|
'gpt-5.5': { limit: { context: 1_050_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_050_000)
|
|
})
|
|
|
|
it('keeps providerless legacy lookup on global exact matches before prefixed suffix matches', async () => {
|
|
writeConfig(`model:\n default: gpt-5\n`)
|
|
writeModelsCache({
|
|
vercel: {
|
|
models: {
|
|
'openai/gpt-5': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
openai: {
|
|
models: {
|
|
'gpt-5': { limit: { context: 400_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(400_000)
|
|
})
|
|
|
|
it('maps WUI provider keys to model-cache provider keys before looking up limits', async () => {
|
|
writeConfig(`model:\n default: gemini-3.1-pro-preview\n provider: gemini\n`)
|
|
writeModelsCache({
|
|
google: {
|
|
models: {
|
|
'gemini-3.1-pro-preview': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_000_000)
|
|
})
|
|
|
|
it('uses gateway provider aliases with prefixed model names inside the aliased provider only', async () => {
|
|
writeConfig(`model:\n default: openai/gpt-5\n provider: ai-gateway\n`)
|
|
writeModelsCache({
|
|
vercel: {
|
|
models: {
|
|
'openai/gpt-5': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
openai: {
|
|
models: {
|
|
'gpt-5': { limit: { context: 400_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_000_000)
|
|
})
|
|
|
|
it('resolves provider: custom through model.base_url before falling back to the default context length', async () => {
|
|
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom\n base_url: https://api.deepseek.com\n`)
|
|
writeModelsCache({
|
|
deepseek: {
|
|
models: {
|
|
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_000_000)
|
|
})
|
|
|
|
it('resolves custom:name providers when the matched custom provider base_url points at a builtin provider', async () => {
|
|
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom:deepseek\n\ncustom_providers:\n - name: deepseek\n base_url: https://api.deepseek.com\n model: deepseek-v4-pro\n`)
|
|
writeModelsCache({
|
|
deepseek: {
|
|
models: {
|
|
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_000_000)
|
|
})
|
|
|
|
it('prefers the builtin provider inferred from a matched custom provider base_url over an arbitrary custom provider name', async () => {
|
|
writeConfig(`model:\n default: shared-model\n provider: custom:corp-proxy\n\ncustom_providers:\n - name: corp-proxy\n base_url: https://api.deepseek.com\n model: shared-model\n`)
|
|
writeModelsCache({
|
|
deepseek: {
|
|
models: {
|
|
'shared-model': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
openai: {
|
|
models: {
|
|
'shared-model': { limit: { context: 400_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_000_000)
|
|
})
|
|
|
|
it('does not trust a stale custom:name provider hint without a matching custom provider entry', async () => {
|
|
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom:deepseek\n`)
|
|
writeModelsCache({
|
|
deepseek: {
|
|
models: {
|
|
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(256_000)
|
|
})
|
|
|
|
it('does not trust custom:name alone when the matched custom provider entry points at an unknown proxy url', async () => {
|
|
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom:deepseek\n\ncustom_providers:\n - name: deepseek\n base_url: https://proxy.example.com/v1\n model: deepseek-v4-pro\n`)
|
|
writeModelsCache({
|
|
deepseek: {
|
|
models: {
|
|
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(256_000)
|
|
})
|
|
|
|
it('does not fall through to a unique global match after a resolved custom:name provider misses in its scoped cache provider', async () => {
|
|
writeConfig(`model:\n default: gpt-5.5\n provider: custom:deepseek\n\ncustom_providers:\n - name: deepseek\n base_url: https://api.deepseek.com\n model: gpt-5.5\n`)
|
|
writeModelsCache({
|
|
openai: {
|
|
models: {
|
|
'gpt-5.5': { limit: { context: 400_000 } },
|
|
},
|
|
},
|
|
deepseek: {
|
|
models: {
|
|
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(256_000)
|
|
})
|
|
|
|
it('allows a unique global model-name fallback for unresolved custom providers', async () => {
|
|
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom\n base_url: https://proxy.example.com/v1\n`)
|
|
writeModelsCache({
|
|
deepseek: {
|
|
models: {
|
|
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_000_000)
|
|
})
|
|
|
|
it('still allows the unique global fallback when provider: custom matches a custom provider entry that cannot be mapped to a builtin cache provider', async () => {
|
|
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom\n\ncustom_providers:\n - name: corp-proxy\n base_url: https://proxy.example.com/v1\n model: deepseek-v4-pro\n`)
|
|
writeModelsCache({
|
|
deepseek: {
|
|
models: {
|
|
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(1_000_000)
|
|
})
|
|
|
|
it('keeps the unresolved custom-provider fallback strict to exact or case-insensitive model-name matches', async () => {
|
|
writeConfig(`model:\n default: gpt-5\n provider: custom\n base_url: https://proxy.example.com/v1\n`)
|
|
writeModelsCache({
|
|
vercel: {
|
|
models: {
|
|
'openai/gpt-5': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(256_000)
|
|
})
|
|
|
|
it('does not guess across multiple cache providers when a custom provider remains unresolved', async () => {
|
|
writeConfig(`model:\n default: shared-model\n provider: custom\n base_url: https://proxy.example.com/v1\n`)
|
|
writeModelsCache({
|
|
deepseek: {
|
|
models: {
|
|
'shared-model': { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
openai: {
|
|
models: {
|
|
'shared-model': { limit: { context: 400_000 } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const { getModelContextLength } = await loadModelContext()
|
|
|
|
expect(getModelContextLength()).toBe(256_000)
|
|
})
|
|
})
|