From c127a4c1cef52990b6c2bc14a3e05c5bdeb07d27 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 28 Jun 2026 14:45:58 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(router):=20steps=201-3=20=E2=80=94=20t?= =?UTF-8?q?ypes,=20routing=20config=20schema,=20DeepSeek=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the foundational layer for Executive Router v1. No dispatch logic is wired yet; these are the schema, config, and catalog primitives that Steps 4-7 will build on. src/types/router-config.ts (new) Complete type system for the routing pipeline: TaskType, ContextLen, Urgency, TaskClassification, RouteDecision, RouterPoolEntry, PolicyMatch, PolicyRule, RouterEscalationConfig, RoutingConfig. src/server/hermes-config-store.ts (additive) - SetRoutingConfigPatch action added to HermesConfigPatch union - DEFAULT_ROUTING_CONFIG constant (Sonnet default, Opus threshold 0.75, 5 USD/day cap, empty pool/policy) - readRoutingConfig(paths?) safe parser for the routing: block in ~/.hermes/config.yaml; validates every field, falls back to defaults on missing or malformed values, never returns partial data - applySetRoutingConfig shallow-merges patch into existing routing: block without touching other config keys - Existing applyHermesConfigPatch switch extended; exhaustive check still passes src/lib/provider-catalog.ts (additive) DeepSeek entry added between MiniMax and Ollama. Uses OpenAI- compatible API so no new backend handler is needed; baseUrl override in openai-compat-api.ts already supports it. Tests: 23 tests, all passing (vitest run) src/server/hermes-config-store.routing.test.ts: 15 tests covering readRoutingConfig and set-routing-config patch with real temp-dir YAML I/O src/lib/provider-catalog.test.ts: 8 tests covering DeepSeek catalog entry, getProviderInfo, getProviderDisplayName No changes to gateway-capabilities.ts, chat dispatch, or PR #661. --- src/lib/provider-catalog.test.ts | 49 +++++ src/lib/provider-catalog.ts | 21 ++ .../hermes-config-store.routing.test.ts | 202 ++++++++++++++++++ src/server/hermes-config-store.ts | 133 ++++++++++++ src/types/router-config.ts | 99 +++++++++ 5 files changed, 504 insertions(+) create mode 100644 src/lib/provider-catalog.test.ts create mode 100644 src/server/hermes-config-store.routing.test.ts create mode 100644 src/types/router-config.ts diff --git a/src/lib/provider-catalog.test.ts b/src/lib/provider-catalog.test.ts new file mode 100644 index 0000000000..04efc62cc6 --- /dev/null +++ b/src/lib/provider-catalog.test.ts @@ -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') + }) +}) diff --git a/src/lib/provider-catalog.ts b/src/lib/provider-catalog.ts index 06f208f5d2..7b6a1d659d 100644 --- a/src/lib/provider-catalog.ts +++ b/src/lib/provider-catalog.ts @@ -117,6 +117,27 @@ export const PROVIDER_CATALOG: Array = [ 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', diff --git a/src/server/hermes-config-store.routing.test.ts b/src/server/hermes-config-store.routing.test.ts new file mode 100644 index 0000000000..0289444244 --- /dev/null +++ b/src/server/hermes-config-store.routing.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import YAML from 'yaml' + +import { + DEFAULT_ROUTING_CONFIG, + readRoutingConfig, + applyHermesConfigPatch, + resolveHermesConfigPaths, +} from './hermes-config-store' +import type { HermesConfigPaths } from './hermes-config-migration' + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function makeTmpHome(): { dir: string; paths: HermesConfigPaths } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-test-')) + const paths: HermesConfigPaths = { + hermesHome: dir, + configPath: path.join(dir, 'config.yaml'), + envPath: path.join(dir, '.env'), + authProfilesPath: path.join(dir, 'auth-profiles.json'), + } + return { dir, paths } +} + +function writeConfig(configPath: string, content: Record) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + fs.writeFileSync(configPath, YAML.stringify(content), 'utf-8') +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +describe('readRoutingConfig', () => { + let dir: string + let paths: HermesConfigPaths + + beforeEach(() => { + const tmp = makeTmpHome() + dir = tmp.dir + paths = tmp.paths + }) + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }) + }) + + it('returns defaults when config.yaml does not exist', () => { + const result = readRoutingConfig(paths) + expect(result.enabled).toBe(false) + expect(result.default_provider).toBe('anthropic') + expect(result.default_model).toBe('claude-sonnet-4-6') + expect(result.escalation.opus_threshold).toBe(0.75) + expect(result.escalation.daily_opus_budget_usd).toBe(5.0) + expect(result.pool).toEqual([]) + expect(result.policy).toEqual([]) + }) + + it('returns defaults when config.yaml has no routing block', () => { + writeConfig(paths.configPath, { provider: 'anthropic', model: 'claude-sonnet-4-6' }) + const result = readRoutingConfig(paths) + expect(result).toMatchObject(DEFAULT_ROUTING_CONFIG) + }) + + it('returns defaults when routing block is not an object', () => { + writeConfig(paths.configPath, { routing: 'invalid' }) + const result = readRoutingConfig(paths) + expect(result.enabled).toBe(false) + }) + + it('reads enabled flag', () => { + writeConfig(paths.configPath, { routing: { enabled: true } }) + expect(readRoutingConfig(paths).enabled).toBe(true) + }) + + it('reads default_provider and default_model', () => { + writeConfig(paths.configPath, { + routing: { default_provider: 'openai', default_model: 'gpt-5.4' }, + }) + const result = readRoutingConfig(paths) + expect(result.default_provider).toBe('openai') + expect(result.default_model).toBe('gpt-5.4') + }) + + it('reads escalation thresholds', () => { + writeConfig(paths.configPath, { + routing: { escalation: { opus_threshold: 0.9, daily_opus_budget_usd: 10.0 } }, + }) + const result = readRoutingConfig(paths) + expect(result.escalation.opus_threshold).toBe(0.9) + expect(result.escalation.daily_opus_budget_usd).toBe(10.0) + }) + + it('falls back escalation fields to defaults when malformed', () => { + writeConfig(paths.configPath, { routing: { escalation: { opus_threshold: 'bad' } } }) + const result = readRoutingConfig(paths) + expect(result.escalation.opus_threshold).toBe(DEFAULT_ROUTING_CONFIG.escalation.opus_threshold) + }) + + it('parses a valid pool entry', () => { + writeConfig(paths.configPath, { + routing: { + pool: [ + { provider: 'anthropic', models: ['claude-sonnet-4-6'], enabled: true }, + { provider: 'openai', models: ['gpt-5.4'], base_url: 'https://api.openai.com/v1', enabled: false }, + ], + }, + }) + const result = readRoutingConfig(paths) + expect(result.pool).toHaveLength(2) + expect(result.pool[0]).toEqual({ provider: 'anthropic', models: ['claude-sonnet-4-6'], enabled: true }) + expect(result.pool[1].base_url).toBe('https://api.openai.com/v1') + }) + + it('skips pool entries missing a provider', () => { + writeConfig(paths.configPath, { + routing: { pool: [{ models: ['gpt-5.4'], enabled: true }] }, + }) + expect(readRoutingConfig(paths).pool).toHaveLength(0) + }) + + it('parses a valid policy rule', () => { + writeConfig(paths.configPath, { + routing: { + policy: [ + { match: { task_type: 'coding' }, route: { provider: 'openai', model: 'gpt-5.4' } }, + { match: { complexity_gte: 0.75 }, route: { provider: 'anthropic', model: 'claude-opus-4-8' } }, + ], + }, + }) + const result = readRoutingConfig(paths) + expect(result.policy).toHaveLength(2) + expect(result.policy[0].match.task_type).toBe('coding') + expect(result.policy[0].route.provider).toBe('openai') + expect(result.policy[1].match.complexity_gte).toBe(0.75) + }) + + it('skips policy rules missing a route provider', () => { + writeConfig(paths.configPath, { + routing: { policy: [{ match: { task_type: 'coding' }, route: { model: 'gpt-5.4' } }] }, + }) + expect(readRoutingConfig(paths).policy).toHaveLength(0) + }) + + it('returns independent copies so mutations do not affect defaults', () => { + const a = readRoutingConfig(paths) + a.escalation.opus_threshold = 0.0 + const b = readRoutingConfig(paths) + expect(b.escalation.opus_threshold).toBe(DEFAULT_ROUTING_CONFIG.escalation.opus_threshold) + }) +}) + +describe('applyHermesConfigPatch — set-routing-config', () => { + let dir: string + let paths: HermesConfigPaths + + beforeEach(() => { + const tmp = makeTmpHome() + dir = tmp.dir + paths = tmp.paths + }) + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }) + }) + + it('writes a routing block to a fresh config.yaml', () => { + const result = applyHermesConfigPatch(paths, { + action: 'set-routing-config', + routing: { enabled: true, default_model: 'claude-sonnet-4-6' }, + }) + expect(result.ok).toBe(true) + const written = YAML.parse(fs.readFileSync(paths.configPath, 'utf-8')) as Record + const routing = written.routing as Record + 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', () => { + writeConfig(paths.configPath, { routing: { enabled: false, default_provider: 'anthropic' } }) + applyHermesConfigPatch(paths, { + action: 'set-routing-config', + routing: { enabled: true }, + }) + const written = YAML.parse(fs.readFileSync(paths.configPath, 'utf-8')) as Record + const routing = written.routing as Record + expect(routing.enabled).toBe(true) + expect(routing.default_provider).toBe('anthropic') + }) + + it('preserves non-routing keys in config.yaml', () => { + writeConfig(paths.configPath, { provider: 'anthropic', model: 'claude-sonnet-4-6' }) + applyHermesConfigPatch(paths, { + action: 'set-routing-config', + routing: { enabled: true }, + }) + const written = YAML.parse(fs.readFileSync(paths.configPath, 'utf-8')) as Record + expect(written.provider).toBe('anthropic') + expect(written.model).toBe('claude-sonnet-4-6') + }) +}) diff --git a/src/server/hermes-config-store.ts b/src/server/hermes-config-store.ts index 3e1eb9eee1..1c12072dab 100644 --- a/src/server/hermes-config-store.ts +++ b/src/server/hermes-config-store.ts @@ -4,6 +4,7 @@ import path from 'node:path' import YAML from 'yaml' import type { HermesConfigPaths } from './hermes-config-migration' +import type { RoutingConfig, RouterPoolEntry, PolicyRule } from '../types/router-config' export type SetDefaultModelPatch = { action: 'set-default-model' @@ -37,12 +38,19 @@ export type RemoveCustomProviderPatch = { name: string } +export type SetRoutingConfigPatch = { + action: 'set-routing-config' + /** Shallow-merged over the existing routing block in config.yaml. */ + routing: Partial +} + export type HermesConfigPatch = | SetDefaultModelPatch | SetApiKeyPatch | RemoveApiKeyPatch | SetCustomProviderPatch | RemoveCustomProviderPatch + | SetRoutingConfigPatch export type HermesConfigPatchResult = { ok: boolean @@ -244,6 +252,129 @@ function applyRemoveCustomProvider( return { ok: true } } +// ─── Routing config ────────────────────────────────────────────────────────── + +export const DEFAULT_ROUTING_CONFIG: RoutingConfig = { + enabled: false, + default_provider: 'anthropic', + default_model: 'claude-sonnet-4-6', + escalation: { + opus_threshold: 0.75, + daily_opus_budget_usd: 5.0, + }, + pool: [], + policy: [], +} + +function readBool(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback +} + +function readStr(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim() ? value.trim() : fallback +} + +function readNum(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function parsePoolEntry(raw: unknown): RouterPoolEntry | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null + const r = raw as Record + const provider = readStr(r.provider, '') + if (!provider) return null + const models = Array.isArray(r.models) + ? r.models.filter((m): m is string => typeof m === 'string') + : [] + return { + provider, + models, + base_url: typeof r.base_url === 'string' ? r.base_url : undefined, + enabled: readBool(r.enabled, false), + } +} + +function parsePolicyRule(raw: unknown): PolicyRule | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null + const r = raw as Record + const match = r.match + const route = r.route + if (!match || typeof match !== 'object' || Array.isArray(match)) return null + if (!route || typeof route !== 'object' || Array.isArray(route)) return null + const routeR = route as Record + const provider = readStr(routeR.provider, '') + const model = readStr(routeR.model, '') + if (!provider) return null + const m = match as Record + return { + match: { + task_type: typeof m.task_type === 'string' + ? (m.task_type as PolicyRule['match']['task_type']) + : undefined, + complexity_gte: typeof m.complexity_gte === 'number' ? m.complexity_gte : undefined, + complexity_lt: typeof m.complexity_lt === 'number' ? m.complexity_lt : undefined, + context_len: typeof m.context_len === 'string' + ? (m.context_len as PolicyRule['match']['context_len']) + : undefined, + urgency: typeof m.urgency === 'string' + ? (m.urgency as PolicyRule['match']['urgency']) + : undefined, + }, + route: { provider, model }, + } +} + +/** + * Read the `routing:` block from ~/.hermes/config.yaml. + * Missing or malformed fields fall back to DEFAULT_ROUTING_CONFIG values. + * Returns a complete RoutingConfig — callers never see partial data. + */ +export function readRoutingConfig(paths?: HermesConfigPaths): RoutingConfig { + const resolvedPaths = paths ?? resolveHermesConfigPaths() + const config = readYamlConfig(resolvedPaths.configPath) + const raw = config.routing + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ...DEFAULT_ROUTING_CONFIG, escalation: { ...DEFAULT_ROUTING_CONFIG.escalation } } + } + const r = raw as Record + const escalationRaw = r.escalation && typeof r.escalation === 'object' && !Array.isArray(r.escalation) + ? (r.escalation as Record) + : {} + const pool: RouterPoolEntry[] = Array.isArray(r.pool) + ? r.pool.map(parsePoolEntry).filter((e): e is RouterPoolEntry => e !== null) + : [] + const policy: PolicyRule[] = Array.isArray(r.policy) + ? r.policy.map(parsePolicyRule).filter((e): e is PolicyRule => e !== null) + : [] + return { + enabled: readBool(r.enabled, DEFAULT_ROUTING_CONFIG.enabled), + default_provider: readStr(r.default_provider, DEFAULT_ROUTING_CONFIG.default_provider), + default_model: readStr(r.default_model, DEFAULT_ROUTING_CONFIG.default_model), + escalation: { + opus_threshold: readNum(escalationRaw.opus_threshold, DEFAULT_ROUTING_CONFIG.escalation.opus_threshold), + daily_opus_budget_usd: readNum(escalationRaw.daily_opus_budget_usd, DEFAULT_ROUTING_CONFIG.escalation.daily_opus_budget_usd), + }, + pool, + policy, + } +} + +function applySetRoutingConfig( + paths: HermesConfigPaths, + patch: SetRoutingConfigPatch, +): HermesConfigPatchResult { + const config = readYamlConfig(paths.configPath) + const existing = + config.routing && typeof config.routing === 'object' && !Array.isArray(config.routing) + ? (config.routing as Record) + : {} + config.routing = { ...existing, ...(patch.routing as Record) } + writeYamlConfig(paths.configPath, config) + return { ok: true } +} + +// ───────────────────────────────────────────────────────────────────────────── + export function applyHermesConfigPatch( paths: HermesConfigPaths, patch: HermesConfigPatch, @@ -259,6 +390,8 @@ export function applyHermesConfigPatch( return applySetCustomProvider(paths, patch) case 'remove-custom-provider': return applyRemoveCustomProvider(paths, patch) + case 'set-routing-config': + return applySetRoutingConfig(paths, patch) default: { const _exhaustive: never = patch void _exhaustive diff --git a/src/types/router-config.ts b/src/types/router-config.ts new file mode 100644 index 0000000000..fa28ffba32 --- /dev/null +++ b/src/types/router-config.ts @@ -0,0 +1,99 @@ +/** + * Type definitions for the Executive Router v1 configuration. + * + * These types describe the `routing:` block in ~/.hermes/config.yaml. + * No routing logic lives here — see src/server/executive-router.ts (Step 5). + */ + +export type TaskType = + | 'coding' + | 'research' + | 'writing' + | 'reasoning' + | 'summarisation' + | 'qa' + | 'creative' + | 'ops' + +export type ContextLen = 'short' | 'medium' | 'long' +export type Urgency = 'normal' | 'fast' + +/** Output of the task classifier — describes the incoming request. */ +export type TaskClassification = { + task_type: TaskType + /** Normalised 0.0–1.0 score derived from token count and keyword signals. */ + complexity: number + context_len: ContextLen + urgency: Urgency + has_attachments: boolean + estimated_tokens: number +} + +/** What the router decided — passed back to the dispatch layer. */ +export type RouteDecision = { + provider: string + model: string + /** Non-null for providers that bypass the Hermes gateway (Gemini, DeepSeek, OpenRouter, Ollama). */ + base_url?: string + /** Human-readable explanation, surfaced in the transparency UI. */ + reason: string +} + +/** One entry in routing.pool — a provider and its available models. */ +export type RouterPoolEntry = { + provider: string + /** Empty array means "use runtime discovery" (applicable to Ollama). */ + models: string[] + base_url?: string + enabled: boolean +} + +/** Conditions that must all hold for a policy rule to match. */ +export type PolicyMatch = { + task_type?: TaskType + /** Match when complexity >= this value. */ + complexity_gte?: number + /** Match when complexity < this value. */ + complexity_lt?: number + context_len?: ContextLen + urgency?: Urgency +} + +/** A single routing rule: when `match` conditions hold, send to `route`. */ +export type PolicyRule = { + match: PolicyMatch + route: { provider: string; model: string } +} + +export type RouterEscalationConfig = { + /** + * Complexity threshold above which the router will consider escalating + * to a more capable (and more expensive) model such as Claude Opus. + * Range: 0.0–1.0. Default: 0.75. + */ + opus_threshold: number + /** + * Hard daily USD cap for Opus (and any other high-cost escalation target). + * When the cap is reached the router substitutes the default model instead. + * Set to 0 to disable Opus entirely. + */ + daily_opus_budget_usd: number +} + +/** + * The full routing configuration block, matching the `routing:` key in + * ~/.hermes/config.yaml. Read via readRoutingConfig() in hermes-config-store.ts. + */ +export type RoutingConfig = { + /** Master switch. When false the router is bypassed entirely. Default: false. */ + enabled: boolean + default_provider: string + default_model: string + escalation: RouterEscalationConfig + pool: RouterPoolEntry[] + /** + * Ordered list of routing rules. First matching rule wins. + * Falls back to default_provider / default_model when no rule matches. + */ + policy: PolicyRule[] +} From 628ec2f59daa35711458ea2b5c440e1db5ec6ec0 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 28 Jun 2026 15:26:02 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(router):=20steps=204-5=20=E2=80=94=20t?= =?UTF-8?q?ask=20classifier=20and=20executive=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function pipeline for intelligent model routing: task-classifier.ts - parseManualOverride(): use:opus/sonnet/codex/gemini/deepseek/local/fast and model: (model: takes precedence over use:X) - classifyTask(): token estimation, keyword-based task_type detection, log-scaled complexity 0–1, urgency from last user message executive-router.ts - route(): 6-step decision tree — manual override → routing disabled → policy rules (first match, skip disabled providers) → complexity escalation to Opus → default; Opus budget cap enforced at every path Tests: 52 tests across task-classifier.test.ts and executive-router.test.ts No new tsc errors introduced. Co-Authored-By: Claude Sonnet 4.6 --- src/server/executive-router.test.ts | 273 ++++++++++++++++++++++++++++ src/server/executive-router.ts | 131 +++++++++++++ src/server/task-classifier.test.ts | 195 ++++++++++++++++++++ src/server/task-classifier.ts | 117 ++++++++++++ 4 files changed, 716 insertions(+) create mode 100644 src/server/executive-router.test.ts create mode 100644 src/server/executive-router.ts create mode 100644 src/server/task-classifier.test.ts create mode 100644 src/server/task-classifier.ts diff --git a/src/server/executive-router.test.ts b/src/server/executive-router.test.ts new file mode 100644 index 0000000000..bb6f12803d --- /dev/null +++ b/src/server/executive-router.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from 'vitest' +import { route } from './executive-router' +import type { RoutingConfig, TaskClassification } from '../types/router-config' + +// ── fixtures ────────────────────────────────────────────────────────────────── + +const BASE_CONFIG: RoutingConfig = { + enabled: true, + default_provider: 'anthropic', + default_model: 'claude-sonnet-4-6', + escalation: { opus_threshold: 0.75, daily_opus_budget_usd: 5.0 }, + pool: [], + policy: [], +} + +const SIMPLE_TASK: TaskClassification = { + task_type: 'qa', + complexity: 0.2, + context_len: 'short', + urgency: 'normal', + has_attachments: false, + estimated_tokens: 50, +} + +const COMPLEX_TASK: TaskClassification = { + task_type: 'coding', + complexity: 0.9, + context_len: 'long', + urgency: 'normal', + has_attachments: false, + estimated_tokens: 6000, +} + +// ── routing disabled ────────────────────────────────────────────────────────── + +describe('route — routing disabled', () => { + it('returns defaults when routing.enabled is false', () => { + const r = route({ + classification: SIMPLE_TASK, + config: { ...BASE_CONFIG, enabled: false }, + opusSpentToday: 0, + }) + expect(r.provider).toBe('anthropic') + expect(r.model).toBe('claude-sonnet-4-6') + expect(r.reason).toContain('routing disabled') + }) +}) + +// ── manual overrides ────────────────────────────────────────────────────────── + +describe('route — manual overrides', () => { + it('respects provider + model manual override (routing disabled)', () => { + const r = route({ + classification: SIMPLE_TASK, + config: { ...BASE_CONFIG, enabled: false }, + opusSpentToday: 0, + manualOverride: { provider: 'openai', model: 'gpt-5.4' }, + }) + expect(r.provider).toBe('openai') + expect(r.model).toBe('gpt-5.4') + expect(r.reason).toContain('manual override') + }) + + it('respects provider + model manual override (routing enabled)', () => { + const r = route({ + classification: COMPLEX_TASK, + config: BASE_CONFIG, + opusSpentToday: 0, + manualOverride: { provider: 'google', model: 'gemini-2.5-pro', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai' }, + }) + expect(r.provider).toBe('google') + expect(r.model).toBe('gemini-2.5-pro') + expect(r.base_url).toContain('googleapis') + }) + + it('model-only override uses default_provider', () => { + const r = route({ + classification: SIMPLE_TASK, + config: BASE_CONFIG, + opusSpentToday: 0, + manualOverride: { model: 'claude-opus-4-8' }, + }) + expect(r.provider).toBe('anthropic') + expect(r.model).toBe('claude-opus-4-8') + expect(r.reason).toContain('manual model override') + }) + + it('use:deepseek override passes base_url through', () => { + const r = route({ + classification: SIMPLE_TASK, + config: BASE_CONFIG, + opusSpentToday: 0, + manualOverride: { provider: 'deepseek', model: 'deepseek-chat', base_url: 'https://api.deepseek.com/v1' }, + }) + expect(r.base_url).toBe('https://api.deepseek.com/v1') + }) + + it('null override is ignored', () => { + const r = route({ + classification: SIMPLE_TASK, + config: { ...BASE_CONFIG, enabled: false }, + opusSpentToday: 0, + manualOverride: null, + }) + expect(r.reason).toContain('routing disabled') + }) +}) + +// ── policy rules ────────────────────────────────────────────────────────────── + +describe('route — policy rule matching', () => { + const configWithPolicy: RoutingConfig = { + ...BASE_CONFIG, + policy: [ + { match: { task_type: 'coding' }, route: { provider: 'openai', model: 'gpt-5.4' } }, + { match: { complexity_gte: 0.5 }, route: { provider: 'anthropic', model: 'claude-opus-4-8' } }, + ], + } + + it('matches first applicable rule', () => { + const r = route({ classification: { ...SIMPLE_TASK, task_type: 'coding' }, config: configWithPolicy, opusSpentToday: 0 }) + expect(r.provider).toBe('openai') + expect(r.model).toBe('gpt-5.4') + expect(r.reason).toContain('policy rule matched') + }) + + it('falls through to second rule when first does not match', () => { + const r = route({ classification: { ...SIMPLE_TASK, complexity: 0.6 }, config: configWithPolicy, opusSpentToday: 0 }) + expect(r.model).toBe('claude-opus-4-8') + }) + + it('falls through to default when no rule matches', () => { + const r = route({ classification: { ...SIMPLE_TASK, complexity: 0.1 }, config: configWithPolicy, opusSpentToday: 0 }) + expect(r.provider).toBe('anthropic') + expect(r.model).toBe('claude-sonnet-4-6') + expect(r.reason).toContain('no policy match') + }) + + it('matches complexity_gte boundary exactly', () => { + const config: RoutingConfig = { + ...BASE_CONFIG, + policy: [{ match: { complexity_gte: 0.5 }, route: { provider: 'openai', model: 'gpt-5.4' } }], + } + const r = route({ classification: { ...SIMPLE_TASK, complexity: 0.5 }, config, opusSpentToday: 0 }) + expect(r.model).toBe('gpt-5.4') + }) + + it('does not match complexity_lt at boundary', () => { + const config: RoutingConfig = { + ...BASE_CONFIG, + policy: [{ match: { complexity_lt: 0.5 }, route: { provider: 'openai', model: 'gpt-5.4' } }], + } + const r = route({ classification: { ...SIMPLE_TASK, complexity: 0.5 }, config, opusSpentToday: 0 }) + expect(r.model).toBe('claude-sonnet-4-6') // fell through + }) + + it('matches combined match conditions (all must hold)', () => { + const config: RoutingConfig = { + ...BASE_CONFIG, + policy: [ + { match: { task_type: 'coding', complexity_gte: 0.7 }, route: { provider: 'openai', model: 'o3' } }, + ], + } + const match = route({ classification: { ...SIMPLE_TASK, task_type: 'coding', complexity: 0.8 }, config, opusSpentToday: 0 }) + const noMatch = route({ classification: { ...SIMPLE_TASK, task_type: 'coding', complexity: 0.5 }, config, opusSpentToday: 0 }) + expect(match.model).toBe('o3') + expect(noMatch.model).toBe('claude-sonnet-4-6') + }) + + it('attaches pool base_url to matched provider when present', () => { + const config: RoutingConfig = { + ...BASE_CONFIG, + pool: [{ provider: 'deepseek', models: ['deepseek-chat'], base_url: 'https://api.deepseek.com/v1', enabled: true }], + policy: [{ match: { task_type: 'qa' }, route: { provider: 'deepseek', model: 'deepseek-chat' } }], + } + const r = route({ classification: SIMPLE_TASK, config, opusSpentToday: 0 }) + expect(r.base_url).toBe('https://api.deepseek.com/v1') + }) +}) + +// ── disabled providers ──────────────────────────────────────────────────────── + +describe('route — disabled provider skipping', () => { + it('skips a policy rule whose provider is disabled in pool', () => { + const config: RoutingConfig = { + ...BASE_CONFIG, + pool: [{ provider: 'openai', models: ['gpt-5.4'], enabled: false }], + policy: [ + { match: { task_type: 'coding' }, route: { provider: 'openai', model: 'gpt-5.4' } }, + { match: { task_type: 'coding' }, route: { provider: 'anthropic', model: 'claude-opus-4-8' } }, + ], + } + const r = route({ classification: { ...SIMPLE_TASK, task_type: 'coding' }, config, opusSpentToday: 0 }) + expect(r.provider).toBe('anthropic') + expect(r.model).toBe('claude-opus-4-8') + }) + + it('allows provider absent from pool (pool is opt-in restriction)', () => { + const config: RoutingConfig = { + ...BASE_CONFIG, + pool: [], // openai not restricted + policy: [{ match: { task_type: 'qa' }, route: { provider: 'openai', model: 'gpt-5.4' } }], + } + const r = route({ classification: SIMPLE_TASK, config, opusSpentToday: 0 }) + expect(r.provider).toBe('openai') + }) +}) + +// ── Opus budget cap ─────────────────────────────────────────────────────────── + +describe('route — Opus budget cap', () => { + it('escalates to Opus when complexity is high and budget available', () => { + const r = route({ classification: COMPLEX_TASK, config: BASE_CONFIG, opusSpentToday: 1.0 }) + expect(r.model).toBe('claude-opus-4-8') + expect(r.reason).toContain('escalating to Opus') + }) + + it('falls back when Opus budget is exhausted at escalation threshold', () => { + const r = route({ classification: COMPLEX_TASK, config: BASE_CONFIG, opusSpentToday: 5.0 }) + expect(r.model).toBe('claude-sonnet-4-6') + expect(r.reason).toContain('budget exhausted') + }) + + it('falls back when policy routes to Opus but budget exhausted', () => { + const config: RoutingConfig = { + ...BASE_CONFIG, + policy: [{ match: { task_type: 'coding' }, route: { provider: 'anthropic', model: 'claude-opus-4-8' } }], + } + const r = route({ classification: { ...SIMPLE_TASK, task_type: 'coding' }, config, opusSpentToday: 5.0 }) + expect(r.model).toBe('claude-sonnet-4-6') + expect(r.reason).toContain('budget exhausted') + }) + + it('does not escalate to Opus when complexity is below threshold', () => { + const r = route({ classification: SIMPLE_TASK, config: BASE_CONFIG, opusSpentToday: 0 }) + expect(r.model).not.toContain('opus') + }) + + it('respects opus_threshold boundary — exactly at threshold triggers escalation', () => { + const r = route({ + classification: { ...SIMPLE_TASK, complexity: 0.75 }, + config: BASE_CONFIG, + opusSpentToday: 0, + }) + expect(r.model).toBe('claude-opus-4-8') + }) + + it('budget of 0 disables Opus entirely', () => { + const config: RoutingConfig = { + ...BASE_CONFIG, + escalation: { opus_threshold: 0.5, daily_opus_budget_usd: 0 }, + } + const r = route({ classification: COMPLEX_TASK, config, opusSpentToday: 0 }) + expect(r.model).toBe('claude-sonnet-4-6') + expect(r.reason).toContain('budget exhausted') + }) +}) + +// ── default path ────────────────────────────────────────────────────────────── + +describe('route — default path', () => { + it('returns default provider and model with explanatory reason', () => { + const r = route({ classification: SIMPLE_TASK, config: BASE_CONFIG, opusSpentToday: 0 }) + expect(r.provider).toBe(BASE_CONFIG.default_provider) + expect(r.model).toBe(BASE_CONFIG.default_model) + expect(r.reason.length).toBeGreaterThan(0) + }) + + it('has no base_url on default anthropic route', () => { + const r = route({ classification: SIMPLE_TASK, config: BASE_CONFIG, opusSpentToday: 0 }) + expect(r.base_url).toBeUndefined() + }) +}) diff --git a/src/server/executive-router.ts b/src/server/executive-router.ts new file mode 100644 index 0000000000..7a7ed2bc65 --- /dev/null +++ b/src/server/executive-router.ts @@ -0,0 +1,131 @@ +import type { + TaskClassification, + RoutingConfig, + RouteDecision, + PolicyRule, + RouterPoolEntry, +} from '../types/router-config' +import type { ManualOverride } from './task-classifier' + +export type RouteOpts = { + classification: TaskClassification + config: RoutingConfig + /** Accumulated Opus spend today in USD — injected by caller to keep this function pure. */ + opusSpentToday: number + manualOverride?: ManualOverride | null +} + +const OPUS_FALLBACK_MODEL = 'claude-sonnet-4-6' + +function isOpusModel(model: string): boolean { + return model.toLowerCase().includes('opus') +} + +function findPoolEntry(config: RoutingConfig, provider: string): RouterPoolEntry | undefined { + return config.pool.find((p) => p.provider === provider) +} + +function isProviderEnabled(config: RoutingConfig, provider: string): boolean { + const entry = findPoolEntry(config, provider) + // Provider absent from pool → not explicitly restricted → allowed + if (!entry) return true + return entry.enabled +} + +function policyMatches(rule: PolicyRule, cls: TaskClassification): boolean { + const m = rule.match + if (m.task_type !== undefined && m.task_type !== cls.task_type) return false + if (m.complexity_gte !== undefined && cls.complexity < m.complexity_gte) return false + if (m.complexity_lt !== undefined && cls.complexity >= m.complexity_lt) return false + if (m.context_len !== undefined && m.context_len !== cls.context_len) return false + if (m.urgency !== undefined && m.urgency !== cls.urgency) return false + return true +} + +function budgetExceeded(opusSpentToday: number, config: RoutingConfig): boolean { + return opusSpentToday >= config.escalation.daily_opus_budget_usd +} + +/** + * Determine which provider/model to use for the given task. + * Pure function — no network, no file I/O, no env reads. + */ +export function route(opts: RouteOpts): RouteDecision { + const { classification, config, opusSpentToday, manualOverride } = opts + + // 1. Manual override with explicit provider + model + if (manualOverride?.provider && manualOverride.model) { + return { + provider: manualOverride.provider, + model: manualOverride.model, + base_url: manualOverride.base_url, + reason: `manual override — ${manualOverride.provider}/${manualOverride.model}`, + } + } + + // 2. Manual model-only override (model:) + if (manualOverride?.model) { + return { + provider: config.default_provider, + model: manualOverride.model, + reason: `manual model override — ${manualOverride.model}`, + } + } + + // 3. Routing disabled → defaults + if (!config.enabled) { + return { + provider: config.default_provider, + model: config.default_model, + reason: 'routing disabled — using default', + } + } + + // 4. Policy rules — first match with an enabled provider wins + for (const rule of config.policy) { + if (!policyMatches(rule, classification)) continue + const { provider, model } = rule.route + if (!isProviderEnabled(config, provider)) continue + + if (isOpusModel(model) && budgetExceeded(opusSpentToday, config)) { + return { + provider: config.default_provider, + model: OPUS_FALLBACK_MODEL, + reason: `policy matched ${provider}/${model} but daily Opus budget exhausted ($${opusSpentToday.toFixed(2)} >= $${config.escalation.daily_opus_budget_usd}) — falling back to ${OPUS_FALLBACK_MODEL}`, + } + } + + return { + provider, + model, + base_url: findPoolEntry(config, provider)?.base_url, + reason: `policy rule matched: task_type=${classification.task_type} complexity=${classification.complexity.toFixed(2)}`, + } + } + + // 5. Complexity escalation to Opus + if ( + classification.complexity >= config.escalation.opus_threshold && + isProviderEnabled(config, 'anthropic') + ) { + if (!budgetExceeded(opusSpentToday, config)) { + return { + provider: 'anthropic', + model: 'claude-opus-4-8', + reason: `complexity ${classification.complexity.toFixed(2)} >= threshold ${config.escalation.opus_threshold} — escalating to Opus`, + } + } + return { + provider: config.default_provider, + model: OPUS_FALLBACK_MODEL, + reason: `complexity ${classification.complexity.toFixed(2)} warrants Opus but daily budget exhausted ($${opusSpentToday.toFixed(2)}) — using ${OPUS_FALLBACK_MODEL}`, + } + } + + // 6. Default + return { + provider: config.default_provider, + model: config.default_model, + reason: 'no policy match — using default', + } +} diff --git a/src/server/task-classifier.test.ts b/src/server/task-classifier.test.ts new file mode 100644 index 0000000000..c712273560 --- /dev/null +++ b/src/server/task-classifier.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import { parseManualOverride, classifyTask } from './task-classifier' + +// ── parseManualOverride ─────────────────────────────────────────────────────── + +describe('parseManualOverride', () => { + it('returns null when no override keyword present', () => { + expect(parseManualOverride('Write me a poem about the sea')).toBeNull() + expect(parseManualOverride('')).toBeNull() + }) + + it('parses use:opus', () => { + const r = parseManualOverride('use:opus Please refactor this file') + expect(r).not.toBeNull() + expect(r?.provider).toBe('anthropic') + expect(r?.model).toBe('claude-opus-4-8') + }) + + it('parses use:sonnet', () => { + const r = parseManualOverride('use:sonnet explain this code') + expect(r?.provider).toBe('anthropic') + expect(r?.model).toBe('claude-sonnet-4-6') + }) + + it('parses use:codex', () => { + const r = parseManualOverride('use:codex generate a function') + expect(r?.provider).toBe('openai') + expect(r?.model).toBe('codex-mini-latest') + }) + + it('parses use:gemini', () => { + const r = parseManualOverride('use:gemini analyse this dataset') + expect(r?.provider).toBe('google') + expect(r?.model).toBe('gemini-2.5-pro') + }) + + it('parses use:deepseek with base_url', () => { + const r = parseManualOverride('use:deepseek translate this') + expect(r?.provider).toBe('deepseek') + expect(r?.model).toBe('deepseek-chat') + expect(r?.base_url).toContain('deepseek.com') + }) + + it('parses use:local with base_url', () => { + const r = parseManualOverride('use:local quick question') + expect(r?.provider).toBe('ollama') + expect(r?.base_url).toContain('localhost') + }) + + it('parses use:fast into urgency signal', () => { + const r = parseManualOverride('use:fast answer me') + expect(r?.urgency).toBe('fast') + expect(r?.model).toBeUndefined() + expect(r?.provider).toBeUndefined() + }) + + it('parses model: and takes precedence over use:X', () => { + const r = parseManualOverride('use:sonnet model:gpt-5.4 do the thing') + expect(r?.model).toBe('gpt-5.4') + expect(r?.provider).toBeUndefined() + }) + + it('parses model: alone', () => { + const r = parseManualOverride('model:claude-opus-4-8') + expect(r?.model).toBe('claude-opus-4-8') + }) + + it('is case-insensitive for use:X keywords', () => { + expect(parseManualOverride('USE:OPUS please')).not.toBeNull() + expect(parseManualOverride('Use:Gemini translate')).not.toBeNull() + }) +}) + +// ── classifyTask ────────────────────────────────────────────────────────────── + +describe('classifyTask — token estimation and context_len', () => { + it('classifies short context for tiny messages', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'Hello' }] }) + expect(r.context_len).toBe('short') + expect(r.estimated_tokens).toBeLessThan(1000) + }) + + it('classifies medium context for moderate messages', () => { + const content = 'word '.repeat(1200) // ~1200 words, ~4800 chars → ~1200 tokens + const r = classifyTask({ messages: [{ role: 'user', content }] }) + expect(r.context_len).toBe('medium') + }) + + it('classifies long context for large messages', () => { + const content = 'word '.repeat(4000) // ~4000 tokens + const r = classifyTask({ messages: [{ role: 'user', content }] }) + expect(r.context_len).toBe('long') + }) + + it('accumulates tokens across all messages', () => { + const msgs = [ + { role: 'system', content: 'x'.repeat(4000) }, + { role: 'user', content: 'x'.repeat(4000) }, + ] + const r = classifyTask({ messages: msgs }) + expect(r.estimated_tokens).toBeGreaterThan(1000) + }) +}) + +describe('classifyTask — task_type detection', () => { + it('detects coding from keywords', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'Refactor this TypeScript function to use async/await' }] }) + expect(r.task_type).toBe('coding') + }) + + it('detects summarisation', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'TL;DR this article for me please' }] }) + expect(r.task_type).toBe('summarisation') + }) + + it('detects creative writing', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'Write a short story about a robot' }] }) + expect(r.task_type).toBe('creative') + }) + + it('detects research', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'Research the latest techniques for neural architecture search' }] }) + expect(r.task_type).toBe('research') + }) + + it('detects ops keywords', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'How do I deploy this to Kubernetes?' }] }) + expect(r.task_type).toBe('ops') + }) + + it('falls back to qa for generic questions', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'What is the capital of France?' }] }) + expect(r.task_type).toBe('qa') + }) +}) + +describe('classifyTask — complexity scoring', () => { + it('returns complexity in [0, 1] range', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'Hi' }] }) + expect(r.complexity).toBeGreaterThanOrEqual(0) + expect(r.complexity).toBeLessThanOrEqual(1) + }) + + it('scores higher complexity for long sophisticated coding messages', () => { + const content = 'Implement a comprehensive architecture for a distributed system with performance optimization ' + 'x '.repeat(2000) + const high = classifyTask({ messages: [{ role: 'user', content }] }) + const low = classifyTask({ messages: [{ role: 'user', content: 'Fix this bug' }] }) + expect(high.complexity).toBeGreaterThan(low.complexity) + }) + + it('scores lower complexity for simple/quick messages', () => { + const simple = classifyTask({ messages: [{ role: 'user', content: 'Quick question: what is 2+2?' }] }) + const complex = classifyTask({ messages: [{ role: 'user', content: 'Implement an in-depth advanced security system with comprehensive architecture' }] }) + expect(simple.complexity).toBeLessThan(complex.complexity) + }) +}) + +describe('classifyTask — urgency detection', () => { + it('marks normal urgency by default', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'explain async/await' }] }) + expect(r.urgency).toBe('normal') + }) + + it('marks fast urgency when last user message contains urgency keyword', () => { + const r = classifyTask({ + messages: [ + { role: 'assistant', content: 'How can I help?' }, + { role: 'user', content: 'asap give me a summary' }, + ], + }) + expect(r.urgency).toBe('fast') + }) + + it('does not mark fast urgency based on non-user messages', () => { + const r = classifyTask({ + messages: [ + { role: 'system', content: 'Respond quickly always' }, + { role: 'user', content: 'Tell me about React' }, + ], + }) + expect(r.urgency).toBe('normal') + }) +}) + +describe('classifyTask — attachments', () => { + it('defaults has_attachments to false', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'hello' }] }) + expect(r.has_attachments).toBe(false) + }) + + it('passes through hasAttachments=true', () => { + const r = classifyTask({ messages: [{ role: 'user', content: 'hello' }], hasAttachments: true }) + expect(r.has_attachments).toBe(true) + }) +}) diff --git a/src/server/task-classifier.ts b/src/server/task-classifier.ts new file mode 100644 index 0000000000..cd29a44497 --- /dev/null +++ b/src/server/task-classifier.ts @@ -0,0 +1,117 @@ +import type { TaskClassification, TaskType, ContextLen, Urgency } from '../types/router-config' + +export type ManualOverride = { + provider?: string + model?: string + base_url?: string + urgency?: Urgency +} + +const OVERRIDE_MAP: Array<[string, ManualOverride]> = [ + ['use:opus', { provider: 'anthropic', model: 'claude-opus-4-8' }], + ['use:sonnet', { provider: 'anthropic', model: 'claude-sonnet-4-6' }], + ['use:codex', { provider: 'openai', model: 'codex-mini-latest' }], + ['use:gemini', { provider: 'google', model: 'gemini-2.5-pro' }], + ['use:deepseek', { provider: 'deepseek', model: 'deepseek-chat', base_url: 'https://api.deepseek.com/v1' }], + ['use:local', { provider: 'ollama', model: 'llama3.2', base_url: 'http://localhost:11434/v1' }], + ['use:fast', { urgency: 'fast' }], +] + +/** + * Parse a manual routing override from message text. + * model: takes precedence over use:X keywords. + * Returns null when no override keyword is present. + */ +export function parseManualOverride(text: string): ManualOverride | null { + const modelMatch = /\bmodel:(\S+)/.exec(text) + if (modelMatch) { + return { model: modelMatch[1] } + } + const lower = text.toLowerCase() + for (const [key, override] of OVERRIDE_MAP) { + if (lower.includes(key)) { + return override + } + } + return null +} + +// First-match wins; ordered from most specific to most general +const TASK_SIGNALS: Array<[TaskType, RegExp]> = [ + ['coding', /\b(code|function|class|bug|implement|refactor|typescript|javascript|python|sql|api\b|unit.?test|debug|pull.?request|\bpr\b|lint|compile|build(?:ing)?|ci\/cd|dockerfile|webpack|vite)\b/i], + ['summarisation', /\b(summar(?:ise|ize)|tl;?dr|recap|brief(?:ly)?|condense|shorten)\b/i], + ['creative', /\b(story|poem|fiction|imagin|creative|narrative|character|plot)\b/i], + ['research', /\b(research|investigat|survey|compar(?:e|ing)|analys[ei]s|study|literature.?review)\b/i], + ['reasoning', /\b(reason(?:ing)?|logic|argument|proof|deduc|infer|think.?through|explain.?why|how.?(?:would|could|should).+work)\b/i], + ['writing', /\b(write|draft|essay|article|letter|report|document|blog|copywrite|paragraph|compose)\b/i], + ['ops', /\b(deploy|configur|setup|install|monitor|kubernetes|docker|infrastructure|pipeline|terraform|serverless|cron|bash|shell.?script)\b/i], + ['qa', /\b(what.?(?:is|are|does)|how.?(?:do|does)|defin|explain.?what|describ|tell.?me.?about)\b/i], +] + +function detectTaskType(text: string): TaskType { + for (const [type, re] of TASK_SIGNALS) { + if (re.test(text)) return type + } + return 'qa' +} + +function estimateTokens(messages: Array<{ role: string; content: string }>): number { + let chars = 0 + for (const m of messages) chars += m.content.length + return Math.round(chars / 4) +} + +function classifyContextLen(tokens: number): ContextLen { + if (tokens < 1000) return 'short' + if (tokens < 4000) return 'medium' + return 'long' +} + +function classifyComplexity(text: string, tokens: number, taskType: TaskType): number { + // Base: log-scaled token count, capped at 0.5 + const tokenScore = Math.min(Math.log10(Math.max(tokens, 1)) / Math.log10(8000), 0.5) + + let score = tokenScore + + // Task-type priors + if (taskType === 'coding' || taskType === 'reasoning') score += 0.2 + else if (taskType === 'research') score += 0.15 + else if (taskType === 'creative' || taskType === 'writing') score += 0.05 + + // High-complexity signals + if (/\b(architect|system.?design|large.?scale|refactor|optimiz|performance|security|multi.?step|comprehensive|in.?depth|advanced)\b/i.test(text)) score += 0.15 + + // Low-complexity signals + if (/\b(quick|simple|brief|short|basic|just|only|tldr|tl;?dr|one.?line|one.?word)\b/i.test(text)) score -= 0.1 + + return Math.max(0, Math.min(1, score)) +} + +export type ClassifyOpts = { + messages: Array<{ role: string; content: string }> + hasAttachments?: boolean +} + +export function classifyTask(opts: ClassifyOpts): TaskClassification { + const { messages, hasAttachments = false } = opts + const fullText = messages.map((m) => m.content).join('\n') + const estimated_tokens = estimateTokens(messages) + const task_type = detectTaskType(fullText) + const context_len = classifyContextLen(estimated_tokens) + const complexity = classifyComplexity(fullText, estimated_tokens, task_type) + + const lastUser = [...messages].reverse().find((m) => m.role === 'user') + const urgency: Urgency = + lastUser && /\b(use:fast|quick(?:ly)?|asap|urgent(?:ly)?|fast(?:er)?|speedy)\b/i.test(lastUser.content) + ? 'fast' + : 'normal' + + return { + task_type, + complexity, + context_len, + urgency, + has_attachments: hasAttachments, + estimated_tokens, + } +} From e6e9767d350b5d603eb30de5f6660f0a62e910fe Mon Sep 17 00:00:00 2001 From: root Date: Sun, 28 Jun 2026 15:41:04 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat(router):=20step=206=20=E2=80=94=20prov?= =?UTF-8?q?ider-agnostic=20model=20usage=20tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persists per-request usage to ~/.hermes/model-usage.json. - ModelUsageRecord: id, ts, provider, model, input/output tokens, cost_usd, latency_ms, success, error - estimateCost(): pricing table for 13 models across Anthropic, OpenAI, Google, DeepSeek, Ollama; prefix-match for versioned IDs; zero for local - recordUsage(): computes cost, appends to JSON array; never throws - readUsageLog(): reads full log; returns [] on missing/corrupt file - sumCostToday(): aggregates today's cost with optional provider/model filter - getOpusSpendToday(): convenience wrapper for router budget enforcement All paths injectable for testing. 29/29 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/server/model-usage-tracker.test.ts | 325 +++++++++++++++++++++++++ src/server/model-usage-tracker.ts | 194 +++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 src/server/model-usage-tracker.test.ts create mode 100644 src/server/model-usage-tracker.ts diff --git a/src/server/model-usage-tracker.test.ts b/src/server/model-usage-tracker.test.ts new file mode 100644 index 0000000000..2bd65788a6 --- /dev/null +++ b/src/server/model-usage-tracker.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { + estimateCost, + recordUsage, + readUsageLog, + sumCostToday, + getOpusSpendToday, +} from './model-usage-tracker' + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function makeTmpPaths() { + const hermesHome = fs.mkdtempSync(path.join(os.tmpdir(), 'tracker-test-')) + return { hermesHome } +} + +function removeTmp(hermesHome: string) { + fs.rmSync(hermesHome, { recursive: true, force: true }) +} + +// ── estimateCost ────────────────────────────────────────────────────────────── + +describe('estimateCost', () => { + it('returns 0 for local models', () => { + expect(estimateCost('llama3.2', 1000, 500)).toBe(0) + }) + + it('calculates cost for claude-opus-4-8 (exact match)', () => { + // 1000 input @ $0.015/1k + 500 output @ $0.075/1k + const cost = estimateCost('claude-opus-4-8', 1000, 500) + expect(cost).toBeCloseTo(0.015 + 0.0375, 6) + }) + + it('calculates cost for claude-sonnet-4-6', () => { + const cost = estimateCost('claude-sonnet-4-6', 2000, 800) + // 2*0.003 + 0.8*0.015 + expect(cost).toBeCloseTo(0.006 + 0.012, 6) + }) + + it('calculates cost for deepseek-chat', () => { + const cost = estimateCost('deepseek-chat', 1000, 1000) + expect(cost).toBeCloseTo(0.00027 + 0.0011, 6) + }) + + it('matches on model prefix (versioned model IDs)', () => { + // 'claude-opus-4-8-20250514' should match 'claude-opus-4-8' + const exact = estimateCost('claude-opus-4-8', 1000, 0) + const versioned = estimateCost('claude-opus-4-8-20250514', 1000, 0) + expect(versioned).toBe(exact) + }) + + it('falls back to a non-zero rate for unknown models', () => { + expect(estimateCost('unknown-model-xyz', 1000, 1000)).toBeGreaterThan(0) + }) + + it('returns 0 cost when both token counts are 0', () => { + expect(estimateCost('claude-sonnet-4-6', 0, 0)).toBe(0) + }) +}) + +// ── recordUsage ─────────────────────────────────────────────────────────────── + +describe('recordUsage', () => { + let hermesHome: string + + beforeEach(() => { + ;({ hermesHome } = makeTmpPaths()) + }) + + afterEach(() => { + removeTmp(hermesHome) + }) + + it('returns a complete ModelUsageRecord', () => { + const r = recordUsage({ + provider: 'anthropic', + model: 'claude-sonnet-4-6', + input_tokens: 500, + output_tokens: 200, + latency_ms: 1234, + success: true, + paths: { hermesHome }, + }) + expect(r.id).toBeTruthy() + expect(r.provider).toBe('anthropic') + expect(r.model).toBe('claude-sonnet-4-6') + expect(r.input_tokens).toBe(500) + expect(r.output_tokens).toBe(200) + expect(r.latency_ms).toBe(1234) + expect(r.success).toBe(true) + expect(r.cost_usd).toBeGreaterThan(0) + expect(r.ts).toBeGreaterThan(0) + }) + + it('persists the record to model-usage.json', () => { + recordUsage({ + provider: 'anthropic', + model: 'claude-sonnet-4-6', + input_tokens: 100, + output_tokens: 50, + latency_ms: 800, + success: true, + paths: { hermesHome }, + }) + const filePath = path.join(hermesHome, 'model-usage.json') + expect(fs.existsSync(filePath)).toBe(true) + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as unknown[] + expect(Array.isArray(parsed)).toBe(true) + expect(parsed).toHaveLength(1) + }) + + it('accumulates multiple records without overwriting', () => { + const opts = { provider: 'openai', model: 'gpt-5.4', input_tokens: 100, output_tokens: 50, latency_ms: 500, success: true, paths: { hermesHome } } + recordUsage(opts) + recordUsage(opts) + recordUsage(opts) + expect(readUsageLog({ hermesHome })).toHaveLength(3) + }) + + it('stores error field when provided', () => { + const r = recordUsage({ + provider: 'anthropic', + model: 'claude-opus-4-8', + input_tokens: 0, + output_tokens: 0, + latency_ms: 100, + success: false, + error: 'rate limit exceeded', + paths: { hermesHome }, + }) + expect(r.success).toBe(false) + expect(r.error).toBe('rate limit exceeded') + const log = readUsageLog({ hermesHome }) + expect(log[0].error).toBe('rate limit exceeded') + }) + + it('omits error field when not provided', () => { + const r = recordUsage({ + provider: 'anthropic', + model: 'claude-sonnet-4-6', + input_tokens: 10, + output_tokens: 10, + latency_ms: 200, + success: true, + paths: { hermesHome }, + }) + expect('error' in r).toBe(false) + }) + + it('creates the hermesHome directory if it does not exist', () => { + const nested = path.join(hermesHome, 'deep', 'nested') + recordUsage({ + provider: 'google', + model: 'gemini-2.5-pro', + input_tokens: 10, + output_tokens: 10, + latency_ms: 300, + success: true, + paths: { hermesHome: nested }, + }) + expect(fs.existsSync(path.join(nested, 'model-usage.json'))).toBe(true) + }) + + it('each record gets a unique id', () => { + const opts = { provider: 'anthropic', model: 'claude-sonnet-4-6', input_tokens: 10, output_tokens: 10, latency_ms: 100, success: true, paths: { hermesHome } } + const a = recordUsage(opts) + const b = recordUsage(opts) + expect(a.id).not.toBe(b.id) + }) +}) + +// ── readUsageLog ────────────────────────────────────────────────────────────── + +describe('readUsageLog', () => { + let hermesHome: string + + beforeEach(() => { + ;({ hermesHome } = makeTmpPaths()) + }) + + afterEach(() => { + removeTmp(hermesHome) + }) + + it('returns empty array when file does not exist', () => { + expect(readUsageLog({ hermesHome })).toEqual([]) + }) + + it('returns empty array when file contains invalid JSON', () => { + const filePath = path.join(hermesHome, 'model-usage.json') + fs.mkdirSync(hermesHome, { recursive: true }) + fs.writeFileSync(filePath, 'not json', 'utf-8') + expect(readUsageLog({ hermesHome })).toEqual([]) + }) + + it('returns empty array when file contains non-array JSON', () => { + const filePath = path.join(hermesHome, 'model-usage.json') + fs.mkdirSync(hermesHome, { recursive: true }) + fs.writeFileSync(filePath, '{"oops": true}', 'utf-8') + expect(readUsageLog({ hermesHome })).toEqual([]) + }) + + it('reads back what was written', () => { + recordUsage({ provider: 'anthropic', model: 'claude-opus-4-8', input_tokens: 500, output_tokens: 200, latency_ms: 1000, success: true, paths: { hermesHome } }) + recordUsage({ provider: 'openai', model: 'gpt-5.4', input_tokens: 300, output_tokens: 100, latency_ms: 500, success: true, paths: { hermesHome } }) + const log = readUsageLog({ hermesHome }) + expect(log).toHaveLength(2) + expect(log[0].provider).toBe('anthropic') + expect(log[1].provider).toBe('openai') + }) +}) + +// ── sumCostToday ────────────────────────────────────────────────────────────── + +describe('sumCostToday', () => { + let hermesHome: string + + beforeEach(() => { + ;({ hermesHome } = makeTmpPaths()) + }) + + afterEach(() => { + removeTmp(hermesHome) + }) + + it('returns 0 when no records exist', () => { + expect(sumCostToday({}, { hermesHome })).toBe(0) + }) + + it('sums all costs when no filter applied', () => { + recordUsage({ provider: 'anthropic', model: 'claude-sonnet-4-6', input_tokens: 1000, output_tokens: 500, latency_ms: 800, success: true, paths: { hermesHome } }) + recordUsage({ provider: 'openai', model: 'gpt-5.4', input_tokens: 1000, output_tokens: 500, latency_ms: 600, success: true, paths: { hermesHome } }) + const total = sumCostToday({}, { hermesHome }) + expect(total).toBeGreaterThan(0) + const sonnetCost = estimateCost('claude-sonnet-4-6', 1000, 500) + const gptCost = estimateCost('gpt-5.4', 1000, 500) + expect(total).toBeCloseTo(sonnetCost + gptCost, 8) + }) + + it('filters by provider', () => { + recordUsage({ provider: 'anthropic', model: 'claude-sonnet-4-6', input_tokens: 1000, output_tokens: 500, latency_ms: 800, success: true, paths: { hermesHome } }) + recordUsage({ provider: 'openai', model: 'gpt-5.4', input_tokens: 1000, output_tokens: 500, latency_ms: 600, success: true, paths: { hermesHome } }) + const anthropicOnly = sumCostToday({ provider: 'anthropic' }, { hermesHome }) + expect(anthropicOnly).toBeCloseTo(estimateCost('claude-sonnet-4-6', 1000, 500), 8) + }) + + it('filters by model', () => { + recordUsage({ provider: 'anthropic', model: 'claude-opus-4-8', input_tokens: 1000, output_tokens: 200, latency_ms: 2000, success: true, paths: { hermesHome } }) + recordUsage({ provider: 'anthropic', model: 'claude-sonnet-4-6', input_tokens: 1000, output_tokens: 200, latency_ms: 800, success: true, paths: { hermesHome } }) + const opusOnly = sumCostToday({ model: 'claude-opus-4-8' }, { hermesHome }) + expect(opusOnly).toBeCloseTo(estimateCost('claude-opus-4-8', 1000, 200), 8) + }) + + it('excludes records from yesterday', () => { + const yesterday = Date.now() - 25 * 60 * 60 * 1000 + // Write a record directly with a past timestamp + const filePath = path.join(hermesHome, 'model-usage.json') + fs.mkdirSync(hermesHome, { recursive: true }) + fs.writeFileSync( + filePath, + JSON.stringify([{ id: 'old', ts: yesterday, provider: 'anthropic', model: 'claude-opus-4-8', input_tokens: 10000, output_tokens: 5000, cost_usd: 999, latency_ms: 100, success: true }]), + 'utf-8', + ) + expect(sumCostToday({}, { hermesHome })).toBe(0) + }) + + it('includes records from today', () => { + recordUsage({ provider: 'anthropic', model: 'claude-opus-4-8', input_tokens: 1000, output_tokens: 500, latency_ms: 1000, success: true, paths: { hermesHome } }) + expect(sumCostToday({}, { hermesHome })).toBeGreaterThan(0) + }) +}) + +// ── getOpusSpendToday ───────────────────────────────────────────────────────── + +describe('getOpusSpendToday', () => { + let hermesHome: string + + beforeEach(() => { + ;({ hermesHome } = makeTmpPaths()) + }) + + afterEach(() => { + removeTmp(hermesHome) + }) + + it('returns 0 when no records', () => { + expect(getOpusSpendToday({ hermesHome })).toBe(0) + }) + + it('sums only opus model records', () => { + recordUsage({ provider: 'anthropic', model: 'claude-opus-4-8', input_tokens: 1000, output_tokens: 500, latency_ms: 1000, success: true, paths: { hermesHome } }) + recordUsage({ provider: 'anthropic', model: 'claude-sonnet-4-6', input_tokens: 1000, output_tokens: 500, latency_ms: 800, success: true, paths: { hermesHome } }) + recordUsage({ provider: 'openai', model: 'gpt-5.4', input_tokens: 1000, output_tokens: 500, latency_ms: 600, success: true, paths: { hermesHome } }) + const opusSpend = getOpusSpendToday({ hermesHome }) + expect(opusSpend).toBeCloseTo(estimateCost('claude-opus-4-8', 1000, 500), 8) + }) + + it('accumulates multiple opus calls', () => { + recordUsage({ provider: 'anthropic', model: 'claude-opus-4-8', input_tokens: 1000, output_tokens: 500, latency_ms: 1000, success: true, paths: { hermesHome } }) + recordUsage({ provider: 'anthropic', model: 'claude-opus-4-8', input_tokens: 2000, output_tokens: 800, latency_ms: 1500, success: true, paths: { hermesHome } }) + const expected = estimateCost('claude-opus-4-8', 1000, 500) + estimateCost('claude-opus-4-8', 2000, 800) + expect(getOpusSpendToday({ hermesHome })).toBeCloseTo(expected, 8) + }) + + it('matches by opus substring — catches versioned model IDs', () => { + recordUsage({ provider: 'anthropic', model: 'claude-opus-4-8-20250514', input_tokens: 1000, output_tokens: 500, latency_ms: 1000, success: true, paths: { hermesHome } }) + expect(getOpusSpendToday({ hermesHome })).toBeGreaterThan(0) + }) + + it('excludes yesterday opus records', () => { + const yesterday = Date.now() - 25 * 60 * 60 * 1000 + const filePath = path.join(hermesHome, 'model-usage.json') + fs.mkdirSync(hermesHome, { recursive: true }) + fs.writeFileSync( + filePath, + JSON.stringify([{ id: 'x', ts: yesterday, provider: 'anthropic', model: 'claude-opus-4-8', input_tokens: 9999, output_tokens: 9999, cost_usd: 999, latency_ms: 100, success: true }]), + 'utf-8', + ) + expect(getOpusSpendToday({ hermesHome })).toBe(0) + }) +}) diff --git a/src/server/model-usage-tracker.ts b/src/server/model-usage-tracker.ts new file mode 100644 index 0000000000..86881be1ba --- /dev/null +++ b/src/server/model-usage-tracker.ts @@ -0,0 +1,194 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import type { HermesConfigPaths } from './hermes-config-migration' + +// ── public types ────────────────────────────────────────────────────────────── + +export type ModelUsageRecord = { + id: string + ts: number // unix ms + provider: string + model: string + input_tokens: number + output_tokens: number + cost_usd: number + latency_ms: number + success: boolean + error?: string +} + +export type RecordUsageOpts = { + provider: string + model: string + input_tokens: number + output_tokens: number + latency_ms: number + success: boolean + error?: string + /** Injected for testing — omit to use the default ~/.hermes path. */ + paths?: Pick +} + +// ── cost table (USD per 1 000 tokens) ──────────────────────────────────────── + +type TokenCost = { input: number; output: number } + +const COST_TABLE: Record = { + // Anthropic + 'claude-opus-4-8': { input: 0.015, output: 0.075 }, + 'claude-opus-4-7': { input: 0.015, output: 0.075 }, + 'claude-sonnet-4-6': { input: 0.003, output: 0.015 }, + 'claude-haiku-4-5': { input: 0.00025, output: 0.00125 }, + // OpenAI + 'gpt-5.4': { input: 0.002, output: 0.008 }, + 'gpt-4o': { input: 0.0025, output: 0.01 }, + 'codex-mini-latest': { input: 0.0015, output: 0.006 }, + // Google + 'gemini-2.5-pro': { input: 0.00125, output: 0.005 }, + 'gemini-2.5-flash': { input: 0.000075, output: 0.0003 }, + // DeepSeek + 'deepseek-chat': { input: 0.00027, output: 0.0011 }, + 'deepseek-reasoner': { input: 0.00055, output: 0.00219 }, + // Local — zero cost + 'llama3.2': { input: 0, output: 0 }, + 'llama3.1': { input: 0, output: 0 }, +} + +const FALLBACK_COST: TokenCost = { input: 0.002, output: 0.008 } + +function lookupCost(model: string): TokenCost { + if (COST_TABLE[model]) return COST_TABLE[model] + for (const key of Object.keys(COST_TABLE)) { + if (model.startsWith(key)) return COST_TABLE[key] + } + return FALLBACK_COST +} + +export function estimateCost( + model: string, + inputTokens: number, + outputTokens: number, +): number { + const { input, output } = lookupCost(model) + return (inputTokens / 1000) * input + (outputTokens / 1000) * output +} + +// ── persistence ─────────────────────────────────────────────────────────────── + +function defaultHermesHome(): string { + return ( + process.env.HERMES_HOME ?? + process.env.CLAUDE_HOME ?? + path.join(os.homedir(), '.hermes') + ) +} + +function resolveHermesHome(paths?: Pick): string { + return paths?.hermesHome ?? defaultHermesHome() +} + +function usageFilePath(hermesHome: string): string { + return path.join(hermesHome, 'model-usage.json') +} + +function readRecords(filePath: string): ModelUsageRecord[] { + try { + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? (parsed as ModelUsageRecord[]) : [] + } catch { + return [] + } +} + +function writeRecords(filePath: string, records: ModelUsageRecord[]): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify(records, null, 2), 'utf-8') +} + +// ── public API ──────────────────────────────────────────────────────────────── + +/** + * Append a usage record to ~/.hermes/model-usage.json. + * Returns the completed record. Never throws — a tracker failure must not + * disrupt the response path. + */ +export function recordUsage(opts: RecordUsageOpts): ModelUsageRecord { + const record: ModelUsageRecord = { + id: crypto.randomUUID(), + ts: Date.now(), + provider: opts.provider, + model: opts.model, + input_tokens: opts.input_tokens, + output_tokens: opts.output_tokens, + cost_usd: estimateCost(opts.model, opts.input_tokens, opts.output_tokens), + latency_ms: opts.latency_ms, + success: opts.success, + ...(opts.error !== undefined ? { error: opts.error } : {}), + } + + try { + const filePath = usageFilePath(resolveHermesHome(opts.paths)) + const existing = readRecords(filePath) + writeRecords(filePath, [...existing, record]) + } catch { + // Silently swallowed — usage tracking must never fail a chat response + } + + return record +} + +/** + * Read all usage records from disk, oldest first. + * Returns an empty array when the file is absent or unreadable. + */ +export function readUsageLog( + paths?: Pick, +): ModelUsageRecord[] { + return readRecords(usageFilePath(resolveHermesHome(paths))) +} + +function startOfTodayMs(): number { + const d = new Date() + d.setHours(0, 0, 0, 0) + return d.getTime() +} + +/** + * Sum estimated cost (USD) for records from today, optionally filtered + * by provider and/or model. + */ +export function sumCostToday( + filter: { provider?: string; model?: string } = {}, + paths?: Pick, +): number { + const cutoff = startOfTodayMs() + let total = 0 + for (const r of readUsageLog(paths)) { + if (r.ts < cutoff) continue + if (filter.provider !== undefined && r.provider !== filter.provider) continue + if (filter.model !== undefined && r.model !== filter.model) continue + total += r.cost_usd + } + return total +} + +/** + * USD spent today on any model whose name contains "opus". + * Convenience wrapper consumed by the executive router budget check. + */ +export function getOpusSpendToday( + paths?: Pick, +): number { + const cutoff = startOfTodayMs() + let total = 0 + for (const r of readUsageLog(paths)) { + if (r.ts < cutoff) continue + if (!r.model.toLowerCase().includes('opus')) continue + total += r.cost_usd + } + return total +} From 6634b31fc0cd3d2009bbce36fd9fbbc308ba805b Mon Sep 17 00:00:00 2001 From: root Date: Sun, 28 Jun 2026 15:50:28 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat(router):=20step=207=20=E2=80=94=20wire?= =?UTF-8?q?=20executive=20router=20into=20send-stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the executive router pipeline into the hot chat path. Router is opt-in (routing.enabled: false by default) and never overrides an explicit user-selected model. - Import readRoutingConfig, classifyTask, parseManualOverride, route, getOpusSpendToday, recordUsage, RouteDecision - After local-model detection: classify task, call route(), apply RouteDecision — sets chatMode/localBaseUrl for non-Anthropic providers - Thread routerDecision?.model into Responses API, openaiChat, and streamChat dispatch points - Record usage (tokens, cost, latency) on openaiChat success and failure - Router block is wrapped in try/catch — failure always falls through to existing defaults; no change in behaviour when routing disabled Co-Authored-By: Claude Sonnet 4.6 --- src/routes/api/send-stream.ts | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/routes/api/send-stream.ts b/src/routes/api/send-stream.ts index 005aec37a8..8051e3ee69 100644 --- a/src/routes/api/send-stream.ts +++ b/src/routes/api/send-stream.ts @@ -37,6 +37,11 @@ import { createSyntheticLiveToolTracker, } from './-send-stream-live-tools' import type {OpenAICompatContentPart, OpenAICompatMessage} from '../../server/openai-compat-api'; +import { readRoutingConfig } from '../../server/hermes-config-store' +import { classifyTask, parseManualOverride } from '../../server/task-classifier' +import { route } from '../../server/executive-router' +import { getOpusSpendToday, recordUsage } from '../../server/model-usage-tracker' +import type { RouteDecision } from '../../types/router-config' // Claude agent runs can take 5+ minutes with complex tool chains const SEND_STREAM_RUN_TIMEOUT_MS = 600_000 const SESSION_BOOTSTRAP_KEYS = new Set(['main', 'new']) @@ -365,6 +370,34 @@ export const Route = createFileRoute('/api/send-stream')({ } } } + // ── Executive Router — engage only when enabled and user gave no explicit model ── + let routerDecision: RouteDecision | null = null + if (!requestModel) { + try { + const _rCfg = readRoutingConfig() + if (_rCfg.enabled) { + const _lastMsg = typeof body.message === 'string' ? body.message : '' + routerDecision = route({ + classification: classifyTask({ + messages: [{ role: 'user', content: _lastMsg }], + hasAttachments: Array.isArray(attachments) && attachments.length > 0, + }), + config: _rCfg, + opusSpentToday: getOpusSpendToday(), + manualOverride: parseManualOverride(_lastMsg), + }) + if (routerDecision.base_url) { + chatMode = 'portable' + localBaseUrl = routerDecision.base_url + } else if (routerDecision.provider !== 'anthropic') { + chatMode = 'portable' + } + } + } catch { + // Router failure must never block chat — fall through to defaults + } + } + if (chatMode === 'portable' && sessionKey === 'new') { sessionKey = crypto.randomUUID() resolvedFriendlyId = sessionKey @@ -551,6 +584,7 @@ export const Route = createFileRoute('/api/send-stream')({ friendlyId: portableFriendlyId, }) lastActivity = 'Processing your message...' + let _streamStart = 0 try { const userContent = buildMultimodalContent( @@ -622,7 +656,7 @@ export const Route = createFileRoute('/api/send-stream')({ input: scopedMessage, conversationHistory: effectiveHistory, model: - typeof body.model === 'string' ? body.model : undefined, + routerDecision?.model ?? (typeof body.model === 'string' ? body.model : undefined), sessionId: portableSessionKey, signal: abortController.signal, }) @@ -757,8 +791,9 @@ export const Route = createFileRoute('/api/send-stream')({ } } + _streamStart = Date.now() const stream = await openaiChat(portableMessages, { - model: localBaseUrl ? bareModel : (typeof body.model === 'string' ? body.model : undefined), + model: routerDecision?.model ?? (localBaseUrl ? bareModel : (typeof body.model === 'string' ? body.model : undefined)), temperature: typeof body.temperature === 'number' ? body.temperature @@ -842,6 +877,22 @@ export const Route = createFileRoute('/api/send-stream')({ }) touchLocalSession(portableSessionKey) + if (routerDecision) { + recordUsage({ + provider: routerDecision.provider, + model: routerDecision.model, + input_tokens: Math.round( + portableMessages.reduce( + (s, m) => s + (typeof m.content === 'string' ? m.content.length : 0), + 0, + ) / 4, + ), + output_tokens: Math.round(accumulated.length / 4), + latency_ms: Date.now() - _streamStart, + success: true, + }) + } + persistActiveRun((runSessionKey, activeId) => markRunStatus(runSessionKey, activeId, 'complete'), ) @@ -859,6 +910,17 @@ export const Route = createFileRoute('/api/send-stream')({ }) closeStream() } catch (err) { + if (routerDecision) { + recordUsage({ + provider: routerDecision.provider, + model: routerDecision.model, + input_tokens: 0, + output_tokens: 0, + latency_ms: Date.now() - _streamStart, + success: false, + error: err instanceof Error ? err.message : String(err), + }) + } if (!streamClosed) { const errorMessage = normalizeClaudeErrorMessage(err) persistActiveRun((runSessionKey, activeId) => @@ -1011,7 +1073,7 @@ export const Route = createFileRoute('/api/send-stream')({ { message: scopedMessage, model: - typeof body.model === 'string' ? body.model : undefined, + routerDecision?.model ?? (typeof body.model === 'string' ? body.model : undefined), system_message: thinking, attachments: attachments || undefined, }, From 19f18f7262958b64b3b41cf28a22fa90535b65b3 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 28 Jun 2026 16:24:54 +0000 Subject: [PATCH 5/5] =?UTF-8?q?feat(router):=20step=208=20=E2=80=94=20add?= =?UTF-8?q?=20routing=20settings=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Smart Routing settings panel at /settings?section=routing. settings-sidebar.tsx - Add 'routing' to SettingsNavId (fixes pre-existing tsc error at line 360) - Add 'Smart Routing' nav item between Agent Behavior and Voice hermes-config-route.ts - Import readRoutingConfig; expose routingConfig field in GET response - Add set-routing-config to PatchActionSchema (zod-validated) and ACTION_MESSAGES settings/index.tsx - Add RoutingConfigSnapshot type and routingConfig? to ClaudeConfigData - Add 4 routing state vars; sync from fetchConfig via syncInputsFromData - Add saveRoutingConfig() — sends action: set-routing-config PATCH - Replace renderSmartRouting() with renderExecutiveRouting(): - Executive Router section: enabled toggle, default provider, default model - Opus Escalation section: complexity threshold, daily USD budget - Pool & Policy section: read-only entry count when config has entries src/routes/api/-routing-config.test.ts (new, 7 tests) - GET returns routingConfig with defaults - GET reflects persisted routing block - PATCH writes fresh config, merges without losing keys, saves escalation - PATCH 400 on missing routing field, 503 when capability unavailable Co-Authored-By: Claude Sonnet 4.6 --- src/components/settings/settings-sidebar.tsx | 2 + src/routes/api/-routing-config.test.ts | 181 ++++++++++++ src/routes/settings/index.tsx | 285 +++++++++++++------ src/server/hermes-config-route.ts | 7 + 4 files changed, 395 insertions(+), 80 deletions(-) create mode 100644 src/routes/api/-routing-config.test.ts diff --git a/src/components/settings/settings-sidebar.tsx b/src/components/settings/settings-sidebar.tsx index 13df1b68a8..8f86feb501 100644 --- a/src/components/settings/settings-sidebar.tsx +++ b/src/components/settings/settings-sidebar.tsx @@ -5,6 +5,7 @@ export type SettingsNavId = | 'connection' | 'claude' | 'agent' + | 'routing' | 'voice' | 'display' | 'appearance' @@ -18,6 +19,7 @@ export const SETTINGS_NAV_ITEMS: Array = [ { 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' }, diff --git a/src/routes/api/-routing-config.test.ts b/src/routes/api/-routing-config.test.ts new file mode 100644 index 0000000000..390634c906 --- /dev/null +++ b/src/routes/api/-routing-config.test.ts @@ -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 = {} + +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 Promise> } } }).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 + + expect(body.routingConfig).toBeDefined() + const rc = body.routingConfig as Record + 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 + 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 + const rc = body.routingConfig as Record + + 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 + 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 + const routing = onDisk.routing as Record + 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 + const routing = onDisk.routing as Record + + 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 + const esc = (onDisk.routing as Record).escalation as Record + 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') + }) +}) diff --git a/src/routes/settings/index.tsx b/src/routes/settings/index.tsx index ace7457800..fb3f937717 100644 --- a/src/routes/settings/index.tsx +++ b/src/routes/settings/index.tsx @@ -1046,12 +1046,22 @@ type ClaudeProvider = { maskedKeys: Record } +type RoutingConfigSnapshot = { + enabled: boolean + default_provider: string + default_model: string + escalation: { opus_threshold: number; daily_opus_budget_usd: number } + pool: unknown[] + policy: unknown[] +} + type ClaudeConfigData = { config: Record providers: Array activeProvider: string activeModel: string claudeHome: string + routingConfig?: RoutingConfigSnapshot } const CLAUDE_API = @@ -1263,6 +1273,11 @@ function ClaudeConfigSection({ const [fallbackBaseUrlInput, setFallbackBaseUrlInput] = useState('') const [showFallbackRow, setShowFallbackRow] = useState(false) + const [routingDefaultProvider, setRoutingDefaultProvider] = useState('anthropic') + const [routingDefaultModel, setRoutingDefaultModel] = useState('claude-sonnet-4-6') + const [routingOpusThreshold, setRoutingOpusThreshold] = useState('0.75') + const [routingOpusBudget, setRoutingOpusBudget] = useState('5.0') + const [availableProviders, setAvailableProviders] = useState< Array<{ id: string; label: string; authenticated: boolean }> >([]) @@ -1283,6 +1298,14 @@ function ClaudeConfigSection({ setShowFallbackRow(Boolean(fb.provider || fb.model || fb.baseUrl)) setCustomBaseUrl(readManifestBlockBaseUrl(cfg)) + + const rc = configData.routingConfig + if (rc) { + setRoutingDefaultProvider(rc.default_provider) + setRoutingDefaultModel(rc.default_model) + setRoutingOpusThreshold(String(rc.escalation.opus_threshold)) + setRoutingOpusBudget(String(rc.escalation.daily_opus_budget_usd)) + } }, []) const fetchConfig = useCallback(async () => { @@ -1376,6 +1399,25 @@ function ClaudeConfigSection({ void saveConfig({ config: { [section]: { [field]: value } } }) } + const saveRoutingConfig = async (patch: Record) => { + setSaving(true) + setSaveMessage(null) + try { + const res = await fetch('/api/hermes-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'set-routing-config', routing: patch }), + }) + const result = (await res.json()) as { message?: string } + setSaveMessage(result.message || 'Saved') + await fetchConfig() + setTimeout(() => setSaveMessage(null), 3000) + } catch { + setSaveMessage('Failed to save') + } + setSaving(false) + } + if (loading) { return ( ) - const renderSmartRouting = () => ( - - - - void saveConfig({ - config: { smart_model_routing: { enabled: checked } }, - }) - } - /> - - - - - - - saveNumberField( - 'smart_model_routing', - 'max_simple_chars', - e.target.value, - 500, - ) - } - className="md:w-32" - /> - - - - saveNumberField( - 'smart_model_routing', - 'max_simple_words', - e.target.value, - 80, - ) - } - className="md:w-32" - /> - - - ) + + + void saveRoutingConfig({ enabled: checked }) + } + disabled={saving} + /> + + + +
+ setRoutingDefaultProvider(e.target.value)} + placeholder="anthropic" + className="md:w-40" + disabled={saving} + /> + +
+
+ + +
+ setRoutingDefaultModel(e.target.value)} + placeholder="claude-sonnet-4-6" + className="md:w-56" + disabled={saving} + /> + +
+
+
+ + + +
+ setRoutingOpusThreshold(e.target.value)} + className="md:w-24" + disabled={saving} + /> + +
+
+ + +
+ setRoutingOpusBudget(e.target.value)} + className="md:w-24" + disabled={saving} + /> + +
+
+
+ + {rc && (rc.pool.length > 0 || rc.policy.length > 0) && ( + + {rc.pool.length > 0 && ( + + + {rc.pool.length} provider{rc.pool.length !== 1 ? 's' : ''} + + + )} + {rc.policy.length > 0 && ( + + + {rc.policy.length} rule{rc.policy.length !== 1 ? 's' : ''} + + + )} + + )} + + ) + } const renderVoice = () => (
@@ -2802,7 +2927,7 @@ function ClaudeConfigSection({ const sectionContent = { claude: renderClaudeOverview(), agent: renderAgentBehavior(), - routing: renderSmartRouting(), + routing: renderExecutiveRouting(), voice: renderVoice(), display: renderDisplay(), } as const diff --git a/src/server/hermes-config-route.ts b/src/server/hermes-config-route.ts index 7113b8e476..e9c360f685 100644 --- a/src/server/hermes-config-route.ts +++ b/src/server/hermes-config-route.ts @@ -15,6 +15,7 @@ import { applyHermesConfigPatch, parseEnvFile, readHermesConfigFiles, + readRoutingConfig, resolveHermesConfigPaths, stringifyEnv, } from './hermes-config-store' @@ -32,6 +33,7 @@ const ACTION_MESSAGES: Record = { 'remove-api-key': 'API key removed.', 'set-custom-provider': 'Custom provider saved.', 'remove-custom-provider': 'Custom provider removed.', + 'set-routing-config': 'Routing configuration saved.', } const LEGACY_SAVE_MESSAGE = 'Saved.' @@ -64,6 +66,10 @@ const PatchActionSchema = z.discriminatedUnion('action', [ action: z.literal('remove-custom-provider'), name: z.string().min(1), }), + z.object({ + action: z.literal('set-routing-config'), + routing: z.record(z.string(), z.unknown()), + }), ]) const LegacyPatchSchema = z.object({ @@ -124,6 +130,7 @@ export async function handleHermesConfigGet({ ...state, providers, claudeHome: paths.hermesHome, + routingConfig: readRoutingConfig(paths), }) }