Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/settings/settings-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type SettingsNavId =
| 'connection'
| 'claude'
| 'agent'
| 'routing'
| 'voice'
| 'display'
| 'appearance'
Expand All @@ -18,6 +19,7 @@ export const SETTINGS_NAV_ITEMS: Array<NavItem> = [
{ id: 'connection', label: 'Connection' },
{ id: 'claude', label: 'Model & Provider' },
{ id: 'agent', label: 'Agent Behavior' },
{ id: 'routing', label: 'Smart Routing' },
{ id: 'voice', label: 'Voice' },
{ id: 'display', label: 'Display' },
{ id: 'appearance', label: 'Appearance' },
Expand Down
49 changes: 49 additions & 0 deletions src/lib/provider-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { PROVIDER_CATALOG, getProviderInfo, getProviderDisplayName } from './provider-catalog'

describe('PROVIDER_CATALOG', () => {
it('includes deepseek', () => {
const ids = PROVIDER_CATALOG.map((p) => p.id)
expect(ids).toContain('deepseek')
})

it('deepseek entry has required fields', () => {
const ds = getProviderInfo('deepseek')
expect(ds).not.toBeNull()
expect(ds?.name).toBe('DeepSeek')
expect(ds?.authTypes).toContain('api-key')
expect(ds?.docsUrl).toContain('deepseek.com')
})

it('deepseek configExample is valid JSON', () => {
const ds = getProviderInfo('deepseek')
expect(() => JSON.parse(ds!.configExample)).not.toThrow()
})

it('deepseek appears before ollama (ordering: external APIs before local)', () => {
const ids = PROVIDER_CATALOG.map((p) => p.id)
expect(ids.indexOf('deepseek')).toBeLessThan(ids.indexOf('ollama'))
})
})

describe('getProviderInfo', () => {
it('returns null for unknown provider', () => {
expect(getProviderInfo('unknown-xyz')).toBeNull()
})

it('is case-insensitive', () => {
expect(getProviderInfo('DeepSeek')).not.toBeNull()
expect(getProviderInfo('ANTHROPIC')).not.toBeNull()
})
})

describe('getProviderDisplayName', () => {
it('returns the catalog name for known providers', () => {
expect(getProviderDisplayName('deepseek')).toBe('DeepSeek')
expect(getProviderDisplayName('anthropic')).toBe('Anthropic')
})

it('title-cases unknown provider ids', () => {
expect(getProviderDisplayName('my-custom-llm')).toBe('My Custom Llm')
})
})
21 changes: 21 additions & 0 deletions src/lib/provider-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,27 @@ export const PROVIDER_CATALOG: Array<ProviderInfo> = [
2,
),
},
{
id: 'deepseek',
name: 'DeepSeek',
description: 'DeepSeek models — cost-efficient reasoning and chat via OpenAI-compatible API.',
authTypes: ['api-key'],
docsUrl: 'https://platform.deepseek.com/api_keys',
configExample: JSON.stringify(
{
auth: {
profiles: {
'deepseek:default': {
provider: 'deepseek',
apiKey: 'sk-your-key-here',
},
},
},
},
null,
2,
),
},
{
id: 'ollama',
name: 'Ollama',
Expand Down
181 changes: 181 additions & 0 deletions src/routes/api/-routing-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import YAML from 'yaml'

vi.mock('@tanstack/react-router', () => ({
createFileRoute: (_path: string) => (opts: unknown) => opts,
}))

vi.mock('../../server/auth-middleware', () => ({
isAuthenticated: () => true,
}))

vi.mock('../../server/gateway-capabilities', () => ({
ensureGatewayProbed: vi.fn(),
getCapabilities: () => ({ config: true }),
}))

vi.mock('../../server/local-provider-discovery', () => ({
ensureDiscovery: vi.fn(),
getDiscoveryStatus: () => [],
getDiscoveredModels: () => [],
}))

let tmpHome = ''
const savedEnv: Record<string, string | undefined> = {}

function setEnv(key: string, value: string | undefined) {
if (!(key in savedEnv)) savedEnv[key] = process.env[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
}

beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'routing-config-test-'))
setEnv('HERMES_HOME', tmpHome)
setEnv('CLAUDE_HOME', undefined)
vi.resetModules()
})

afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
for (const key of Object.keys(savedEnv)) delete savedEnv[key]
fs.rmSync(tmpHome, { recursive: true, force: true })
})

async function loadHandlers() {
const mod = await import('./hermes-config')
return (mod as unknown as { Route: { server: { handlers: Record<string, (r: { request: Request }) => Promise<Response>> } } }).Route.server.handlers
}

// ── GET — routingConfig included in response ──────────────────────────────────

describe('GET /api/hermes-config — routingConfig field', () => {
it('includes routingConfig with defaults when no routing block exists', async () => {
const handlers = await loadHandlers()
const res = await handlers.GET({ request: new Request('http://localhost/api/hermes-config') })
const body = await res.json() as Record<string, unknown>

expect(body.routingConfig).toBeDefined()
const rc = body.routingConfig as Record<string, unknown>
expect(rc.enabled).toBe(false)
expect(rc.default_provider).toBe('anthropic')
expect(rc.default_model).toBe('claude-sonnet-4-6')
const esc = rc.escalation as Record<string, unknown>
expect(esc.opus_threshold).toBe(0.75)
expect(esc.daily_opus_budget_usd).toBe(5.0)
expect(rc.pool).toEqual([])
expect(rc.policy).toEqual([])
})

it('reflects persisted routing block when config.yaml has one', async () => {
fs.writeFileSync(
path.join(tmpHome, 'config.yaml'),
YAML.stringify({ routing: { enabled: true, default_provider: 'openai', default_model: 'gpt-5.4' } }),
'utf-8',
)
const handlers = await loadHandlers()
const res = await handlers.GET({ request: new Request('http://localhost/api/hermes-config') })
const body = await res.json() as Record<string, unknown>
const rc = body.routingConfig as Record<string, unknown>

expect(rc.enabled).toBe(true)
expect(rc.default_provider).toBe('openai')
expect(rc.default_model).toBe('gpt-5.4')
})
})

// ── PATCH set-routing-config ──────────────────────────────────────────────────

describe('PATCH /api/hermes-config — set-routing-config action', () => {
it('writes a routing block to a fresh config.yaml', async () => {
const handlers = await loadHandlers()
const res = await handlers.PATCH({
request: new Request('http://localhost/api/hermes-config', {
method: 'PATCH',
body: JSON.stringify({
action: 'set-routing-config',
routing: { enabled: true, default_model: 'claude-sonnet-4-6' },
}),
}),
})
const body = await res.json() as Record<string, unknown>
expect(body.ok).toBe(true)
expect(body.message).toBe('Routing configuration saved.')

const onDisk = YAML.parse(fs.readFileSync(path.join(tmpHome, 'config.yaml'), 'utf-8')) as Record<string, unknown>
const routing = onDisk.routing as Record<string, unknown>
expect(routing.enabled).toBe(true)
expect(routing.default_model).toBe('claude-sonnet-4-6')
})

it('merges over an existing routing block without losing other keys', async () => {
fs.writeFileSync(
path.join(tmpHome, 'config.yaml'),
YAML.stringify({ routing: { enabled: false, default_provider: 'anthropic' }, provider: 'anthropic' }),
'utf-8',
)
const handlers = await loadHandlers()
await handlers.PATCH({
request: new Request('http://localhost/api/hermes-config', {
method: 'PATCH',
body: JSON.stringify({ action: 'set-routing-config', routing: { enabled: true } }),
}),
})
const onDisk = YAML.parse(fs.readFileSync(path.join(tmpHome, 'config.yaml'), 'utf-8')) as Record<string, unknown>
const routing = onDisk.routing as Record<string, unknown>

expect(routing.enabled).toBe(true)
expect(routing.default_provider).toBe('anthropic')
expect(onDisk.provider).toBe('anthropic')
})

it('saves escalation block correctly', async () => {
const handlers = await loadHandlers()
await handlers.PATCH({
request: new Request('http://localhost/api/hermes-config', {
method: 'PATCH',
body: JSON.stringify({
action: 'set-routing-config',
routing: { escalation: { opus_threshold: 0.9, daily_opus_budget_usd: 10.0 } },
}),
}),
})
const onDisk = YAML.parse(fs.readFileSync(path.join(tmpHome, 'config.yaml'), 'utf-8')) as Record<string, unknown>
const esc = (onDisk.routing as Record<string, unknown>).escalation as Record<string, unknown>
expect(esc.opus_threshold).toBe(0.9)
expect(esc.daily_opus_budget_usd).toBe(10.0)
})

it('rejects missing routing field with 400', async () => {
const handlers = await loadHandlers()
const res = await handlers.PATCH({
request: new Request('http://localhost/api/hermes-config', {
method: 'PATCH',
body: JSON.stringify({ action: 'set-routing-config' }),
}),
})
expect(res.status).toBe(400)
})

it('returns 503 when gateway capability is unavailable', async () => {
vi.doMock('../../server/gateway-capabilities', () => ({
ensureGatewayProbed: vi.fn(),
getCapabilities: () => ({ config: false }),
}))
const handlers = await loadHandlers()
const res = await handlers.PATCH({
request: new Request('http://localhost/api/hermes-config', {
method: 'PATCH',
body: JSON.stringify({ action: 'set-routing-config', routing: { enabled: true } }),
}),
})
expect(res.status).toBe(503)
vi.doUnmock('../../server/gateway-capabilities')
})
})
Loading