diff --git a/docs/plans/state.json b/docs/plans/state.json index 549c5cd..250c344 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5694,10 +5694,26 @@ "docs/plans/state.json" ], "test_status": "pnpm typecheck passing" + }, + "dashboard-global-tier-provider-model-catalog": { + "status": "completed", + "date": "2026-02-19", + "updated": "2026-02-19", + "summary": "Extended Assistant Health dashboard model defaults with global per-tier provider/model selectors (default/fast/complex/local), added gateway system.modelCatalog to populate provider model options from official provider APIs with config fallback/caching, and wired runtime config.patch support for models..provider/model with live model-router refresh.", + "files_modified": [ + "src/gateway/modelCatalog.ts", + "src/gateway/handlers/system.ts", + "src/gateway/server.ts", + "src/gateway/handlers/config.ts", + "src/gateway/ui/pages/dashboard.js", + "src/gateway/handlers/handlers.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 1930, + "total_test_count": 1932, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/gateway/handlers/config.ts b/src/gateway/handlers/config.ts index 34c5cdb..b4d5aad 100644 --- a/src/gateway/handlers/config.ts +++ b/src/gateway/handlers/config.ts @@ -1,10 +1,13 @@ import type { GatewayRequest, OutboundMessage } from '../protocol.js'; import { makeResponse, makeError, ErrorCode } from '../protocol.js'; import { MODEL_PROVIDERS, type Config, type ModelProvider } from '../../config/index.js'; +import type { ModelRouter, ModelTier } from '../../models/router.js'; +import { createClientFromConfig } from '../../daemon/models.js'; export interface ConfigHandlerDeps { config: Config; persistConfig?: (nextConfig: Config) => Promise | void; + modelRouter?: ModelRouter; } /** @@ -313,6 +316,52 @@ const PATCHABLE_KEYS: Record boolean config.agents.background_models.complex_reasoning.fallback_tier = value; return true; }, + 'models.default.provider': (config, value) => { + if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;} + config.models.default.provider = value as ModelProvider; + return true; + }, + 'models.default.model': (config, value) => { + if (typeof value !== 'string' || value.trim().length === 0) {return false;} + config.models.default.model = value.trim(); + return true; + }, + 'models.fast.provider': (config, value) => { + if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;} + config.models.fast ??= { ...config.models.default }; + config.models.fast.provider = value as ModelProvider; + return true; + }, + 'models.fast.model': (config, value) => { + if (typeof value !== 'string' || value.trim().length === 0) {return false;} + config.models.fast ??= { ...config.models.default }; + config.models.fast.model = value.trim(); + return true; + }, + 'models.complex.provider': (config, value) => { + if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;} + config.models.complex ??= { ...config.models.default }; + config.models.complex.provider = value as ModelProvider; + return true; + }, + 'models.complex.model': (config, value) => { + if (typeof value !== 'string' || value.trim().length === 0) {return false;} + config.models.complex ??= { ...config.models.default }; + config.models.complex.model = value.trim(); + return true; + }, + 'models.local.provider': (config, value) => { + if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;} + config.models.local ??= { ...config.models.default }; + config.models.local.provider = value as ModelProvider; + return true; + }, + 'models.local.model': (config, value) => { + if (typeof value !== 'string' || value.trim().length === 0) {return false;} + config.models.local ??= { ...config.models.default }; + config.models.local.model = value.trim(); + return true; + }, 'automation.delivery_mode': (config, value) => { if (value !== 'shared_session' && value !== 'isolated_job' && value !== 'announce') {return false;} config.automation ??= {} as Config['automation']; @@ -406,6 +455,36 @@ const PATCHABLE_KEYS: Record boolean }; export function createConfigHandlers(deps: ConfigHandlerDeps) { + const syncModelRouterFromConfig = (nextConfig: Config): void => { + const modelRouter = deps.modelRouter; + if (!modelRouter) { + return; + } + + const updateTier = (tier: ModelTier, cfg: Config['models']['default'] | undefined) => { + if (!cfg) { + return; + } + try { + const client = createClientFromConfig(cfg); + modelRouter.setClient(tier, client, `${cfg.provider}/${cfg.model}`); + modelRouter.setTierStrict(tier, false); + } catch (error) { + // Keep config.patch successful even when runtime credentials are unavailable for a selected provider. + console.warn( + `[gateway.config] Skipping runtime model router update for tier "${tier}": ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }; + + updateTier('default', nextConfig.models.default); + updateTier('fast', nextConfig.models.fast); + updateTier('complex', nextConfig.models.complex); + updateTier('local', nextConfig.models.local); + }; + return { 'config.get': async (request: GatewayRequest): Promise => { return makeResponse(request.id, redactConfig(deps.config)); @@ -457,6 +536,7 @@ export function createConfigHandlers(deps: ConfigHandlerDeps) { delete (deps.config as Record)[key]; } Object.assign(deps.config as Record, draft as Record); + syncModelRouterFromConfig(draft); return makeResponse(request.id, { applied, rejected, persisted: Boolean(deps.persistConfig) }); }, diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index adc76cb..6f90c0a 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -106,6 +106,42 @@ describe('system handlers', () => { ]); }); + it('system.modelCatalog returns empty providers when callback is not provided', async () => { + const req: GatewayRequest = { id: 33, method: 'system.modelCatalog' }; + const result = await handlers['system.modelCatalog'](req) as GatewayResponse; + expect(getPath(result.result, 'providers')).toEqual([]); + }); + + it('system.modelCatalog returns provider models from callback', async () => { + const getModelCatalog = vi.fn(async () => [ + { + provider: 'openai', + models: ['gpt-4o-mini', 'gpt-4.1'], + source: 'api' as const, + fetchedAt: 123, + }, + ]); + const handlers = createSystemHandlers({ + ...deps, + getModelCatalog, + }); + const req: GatewayRequest = { + id: 34, + method: 'system.modelCatalog', + params: { provider: 'openai', forceRefresh: true }, + }; + const result = await handlers['system.modelCatalog'](req) as GatewayResponse; + expect(getModelCatalog).toHaveBeenCalledWith({ provider: 'openai', forceRefresh: true }); + expect(getPath(result.result, 'providers')).toEqual([ + { + provider: 'openai', + models: ['gpt-4o-mini', 'gpt-4.1'], + source: 'api', + fetchedAt: 123, + }, + ]); + }); + it('system.presence returns empty result when getPresence is not provided', async () => { const req: GatewayRequest = { id: 4, method: 'system.presence' }; const result = await handlers['system.presence'](req) as GatewayResponse; @@ -1302,6 +1338,49 @@ describe('config handlers', () => { expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); + + it('config.patch updates tier provider/model defaults and syncs runtime model router', async () => { + const config = makeConfig(); + const modelRouter = { + setClient: vi.fn(), + setTierStrict: vi.fn(), + } as unknown as NonNullable[0]['modelRouter']>; + const handlers = createConfigHandlers({ + config: asConfigValue(config), + modelRouter, + }); + const req: GatewayRequest = { + id: 8, + method: 'config.patch', + params: { + patches: { + 'models.default.provider': 'synthetic', + 'models.default.model': 'synthetic-default', + 'models.fast.provider': 'synthetic', + 'models.fast.model': 'synthetic-fast', + }, + }, + }; + const result = await handlers['config.patch'](req) as GatewayResponse; + const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; + + expect(r.applied).toEqual([ + 'models.default.provider', + 'models.default.model', + 'models.fast.provider', + 'models.fast.model', + ]); + expect(r.rejected).toEqual([]); + expect(r.persisted).toBe(false); + expect(getPath(config, 'models', 'default', 'provider')).toBe('synthetic'); + expect(getPath(config, 'models', 'default', 'model')).toBe('synthetic-default'); + expect(getPath(config, 'models', 'fast', 'provider')).toBe('synthetic'); + expect(getPath(config, 'models', 'fast', 'model')).toBe('synthetic-fast'); + expect(modelRouter.setClient).toHaveBeenCalledWith('default', expect.any(Object), 'synthetic/synthetic-default'); + expect(modelRouter.setClient).toHaveBeenCalledWith('fast', expect.any(Object), 'synthetic/synthetic-fast'); + expect(modelRouter.setTierStrict).toHaveBeenCalledWith('default', false); + expect(modelRouter.setTierStrict).toHaveBeenCalledWith('fast', false); + }); }); describe('redactConfig – comprehensive credential redaction', () => { diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 2132c73..12ea7ac 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -95,6 +95,14 @@ export interface SystemHandlerDeps { getNodeLocations?: (opts?: { role?: string; nodeId?: string; limit?: number }) => NodeLocationEntry[]; /** Optional callback to retrieve registered node connection snapshots. */ getNodes?: (opts?: { role?: string; platform?: string; limit?: number }) => NodeEntry[]; + /** Optional callback to retrieve provider model catalogs. */ + getModelCatalog?: (opts?: { provider?: string; forceRefresh?: boolean }) => Promise>; } export function createSystemHandlers(deps: SystemHandlerDeps) { @@ -265,5 +273,17 @@ export function createSystemHandlers(deps: SystemHandlerDeps) { } return makeResponse(request.id, { requests: deps.getActiveRequests() }); }, + + 'system.modelCatalog': async (request: GatewayRequest): Promise => { + if (!deps.getModelCatalog) { + return makeResponse(request.id, { providers: [] }); + } + const params = request.params as { provider?: string; forceRefresh?: boolean } | undefined; + const providers = await deps.getModelCatalog({ + provider: params?.provider, + forceRefresh: params?.forceRefresh === true, + }); + return makeResponse(request.id, { providers }); + }, }; } diff --git a/src/gateway/modelCatalog.ts b/src/gateway/modelCatalog.ts new file mode 100644 index 0000000..80007a0 --- /dev/null +++ b/src/gateway/modelCatalog.ts @@ -0,0 +1,197 @@ +import { MODEL_PROVIDERS, type Config, type ModelConfig, type ModelProvider } from '../config/index.js'; + +const SUPPORTED_PROVIDER_APIS = new Set(['openai', 'anthropic', 'gemini', 'openrouter', 'xai']); +const CACHE_TTL_MS = 5 * 60_000; +const REQUEST_TIMEOUT_MS = 10_000; + +export interface ProviderModelCatalog { + provider: ModelProvider; + models: string[]; + source: 'api' | 'config' | 'unavailable'; + error?: string; + fetchedAt: number; +} + +export interface ModelCatalogFetcher { + (opts?: { provider?: string; forceRefresh?: boolean }): Promise; +} + +interface CacheEntry { + expiresAt: number; + value: ProviderModelCatalog; +} + +function uniqueSorted(values: string[]): string[] { + return Array.from(new Set(values.filter(Boolean))).sort((a, b) => a.localeCompare(b)); +} + +function getProviderConfigMap(config: Config): Partial> { + const providerConfigs: Partial> = {}; + const modelConfigs: ModelConfig[] = [ + config.models.default, + ...(config.models.fast ? [config.models.fast] : []), + ...(config.models.complex ? [config.models.complex] : []), + ...(config.models.local ? [config.models.local] : []), + ...Object.values(config.models.local_providers ?? {}), + ]; + + for (const modelConfig of modelConfigs) { + providerConfigs[modelConfig.provider] = modelConfig; + if (modelConfig.fallback) { + providerConfigs[modelConfig.fallback.provider] = modelConfig.fallback; + } + } + return providerConfigs; +} + +function getConfiguredModelsForProvider(config: Config, provider: ModelProvider): string[] { + const values: string[] = []; + const all = [ + config.models.default, + ...(config.models.fast ? [config.models.fast] : []), + ...(config.models.complex ? [config.models.complex] : []), + ...(config.models.local ? [config.models.local] : []), + ...Object.values(config.models.local_providers ?? {}), + ]; + for (const entry of all) { + if (entry.provider === provider) { + values.push(entry.model); + } + if (entry.fallback?.provider === provider) { + values.push(entry.fallback.model); + } + } + return uniqueSorted(values); +} + +function envVarForProvider(provider: ModelProvider): string | undefined { + if (provider === 'openai') {return 'OPENAI_API_KEY';} + if (provider === 'anthropic') {return 'ANTHROPIC_API_KEY';} + if (provider === 'gemini') {return 'GEMINI_API_KEY';} + if (provider === 'openrouter') {return 'OPENROUTER_API_KEY';} + if (provider === 'xai') {return 'XAI_API_KEY';} + return undefined; +} + +function resolveApiKey(provider: ModelProvider, cfg?: ModelConfig): string | undefined { + return cfg?.api_key ?? (envVarForProvider(provider) ? process.env[envVarForProvider(provider)!] : undefined); +} + +async function fetchJson(url: string, headers?: Record): Promise { + const response = await fetch(url, { + method: 'GET', + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return await response.json(); +} + +async function fetchProviderModels(provider: ModelProvider, cfg?: ModelConfig): Promise { + if (provider === 'openai') { + const apiKey = resolveApiKey(provider, cfg); + if (!apiKey) {throw new Error('Missing API key');} + const base = cfg?.endpoint ?? 'https://api.openai.com/v1'; + const json = await fetchJson(`${base.replace(/\/$/, '')}/models`, { Authorization: `Bearer ${apiKey}` }); + const data = (json as { data?: Array<{ id?: string }> }).data ?? []; + return uniqueSorted(data.map((entry) => entry.id ?? '')); + } + + if (provider === 'anthropic') { + const apiKey = resolveApiKey(provider, cfg); + if (!apiKey) {throw new Error('Missing API key');} + const json = await fetchJson('https://api.anthropic.com/v1/models', { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }); + const data = (json as { data?: Array<{ id?: string }> }).data ?? []; + return uniqueSorted(data.map((entry) => entry.id ?? '')); + } + + if (provider === 'gemini') { + const apiKey = resolveApiKey(provider, cfg); + if (!apiKey) {throw new Error('Missing API key');} + const json = await fetchJson(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`); + const models = (json as { models?: Array<{ name?: string }> }).models ?? []; + return uniqueSorted(models.map((entry) => (entry.name ?? '').replace(/^models\//, ''))); + } + + if (provider === 'openrouter') { + const apiKey = resolveApiKey(provider, cfg); + const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined; + const json = await fetchJson('https://openrouter.ai/api/v1/models', headers); + const data = (json as { data?: Array<{ id?: string }> }).data ?? []; + return uniqueSorted(data.map((entry) => entry.id ?? '')); + } + + if (provider === 'xai') { + const apiKey = resolveApiKey(provider, cfg); + if (!apiKey) {throw new Error('Missing API key');} + const json = await fetchJson('https://api.x.ai/v1/models', { Authorization: `Bearer ${apiKey}` }); + const data = (json as { data?: Array<{ id?: string }> }).data ?? []; + return uniqueSorted(data.map((entry) => entry.id ?? '')); + } + + return []; +} + +export function createModelCatalogFetcher(config: Config): ModelCatalogFetcher { + const cache = new Map(); + + return async (opts) => { + const provider = opts?.provider?.trim().toLowerCase(); + const providers = provider && MODEL_PROVIDERS.includes(provider as ModelProvider) + ? [provider as ModelProvider] + : [...MODEL_PROVIDERS]; + const providerConfigMap = getProviderConfigMap(config); + const now = Date.now(); + + const result = await Promise.all(providers.map(async (name): Promise => { + if (!opts?.forceRefresh) { + const cached = cache.get(name); + if (cached && cached.expiresAt > now) { + return cached.value; + } + } + + const configured = getConfiguredModelsForProvider(config, name); + if (!SUPPORTED_PROVIDER_APIS.has(name)) { + const value: ProviderModelCatalog = { + provider: name, + models: configured, + source: configured.length > 0 ? 'config' : 'unavailable', + fetchedAt: now, + }; + cache.set(name, { expiresAt: now + CACHE_TTL_MS, value }); + return value; + } + + try { + const remoteModels = await fetchProviderModels(name, providerConfigMap[name]); + const merged = uniqueSorted([...remoteModels, ...configured]); + const value: ProviderModelCatalog = { + provider: name, + models: merged, + source: remoteModels.length > 0 ? 'api' : (configured.length > 0 ? 'config' : 'unavailable'), + fetchedAt: now, + }; + cache.set(name, { expiresAt: now + CACHE_TTL_MS, value }); + return value; + } catch (error) { + const value: ProviderModelCatalog = { + provider: name, + models: configured, + source: configured.length > 0 ? 'config' : 'unavailable', + error: error instanceof Error ? error.message : String(error), + fetchedAt: now, + }; + cache.set(name, { expiresAt: now + CACHE_TTL_MS, value }); + return value; + } + })); + + return result; + }; +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5a1eda1..effed14 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -33,6 +33,7 @@ import { createNodeHandlers, } from './handlers/index.js'; import { discoverServices } from './handlers/services.js'; +import { createModelCatalogFetcher } from './modelCatalog.js'; import type { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js'; import type { NodeConnectionState } from './handlers/node.js'; import type { SessionManager } from '../session/manager.js'; @@ -206,6 +207,7 @@ export class GatewayServer { private registerHandlers(): void { const channelRegistry = this.config.channelRegistry; const runtimeConfig = this.config.config; + const modelCatalogFetcher = runtimeConfig ? createModelCatalogFetcher(runtimeConfig) : undefined; const systemHandlers = createSystemHandlers({ startTime: this.startTime, version: this.config.version ?? '0.1.0', @@ -225,6 +227,9 @@ export class GatewayServer { getServices: runtimeConfig && channelRegistry ? () => discoverServices(runtimeConfig, channelRegistry) : undefined, + getModelCatalog: modelCatalogFetcher + ? (opts) => modelCatalogFetcher(opts) + : undefined, getPresence: channelRegistry ? (opts) => channelRegistry.getPresence(opts) : undefined, @@ -478,6 +483,7 @@ export class GatewayServer { const configHandlers = createConfigHandlers({ config: this.config.config, persistConfig: this.config.persistConfig, + modelRouter: 'setClient' in this.config.modelClient ? this.config.modelClient : undefined, }); for (const [method, handler] of Object.entries(configHandlers)) { this.router.register(method, handler); diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js index 0edbeb4..10d5822 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -695,6 +695,12 @@ function updateAssistantHealth(configData) { const modelTier = configData?.agents?.primary_tier ?? 'default'; const delegation = configData?.agents?.delegation ?? {}; const backgroundModels = configData?.agents?.background_models ?? {}; + const tiers = configData?.models ?? {}; + const modelCatalog = configData?.__modelCatalog ?? []; + const providerList = modelCatalog.length > 0 + ? modelCatalog.map((entry) => entry.provider) + : ['anthropic', 'openai', 'gemini', 'openrouter', 'github', 'xai', 'ollama', 'llamacpp', 'bedrock', 'zhipuai', 'minimax', 'moonshot', 'synthetic']; + const modelOptionsByProvider = Object.fromEntries(modelCatalog.map((entry) => [entry.provider, entry.models ?? []])); const checklistRows = [ { label: 'Set briefing output channel + peer', done: Boolean(briefingOutput?.channel && briefingOutput?.peer) }, { label: 'Enable assistant behavior profile', done: playbookLikeReady }, @@ -710,6 +716,18 @@ function updateAssistantHealth(configData) { const tierOption = (selected) => ['fast', 'default', 'complex', 'local'] .map((tier) => ``) .join(''); + const providerOption = (selected) => providerList + .map((provider) => ``) + .join(''); + const modelDataList = (id, provider, selected) => { + const options = modelOptionsByProvider[provider] ?? []; + return ` + + + ${options.map((model) => ``).join('')} + + `; + }; const taskRows = [ { key: 'compaction', label: 'Compaction' }, { key: 'memory_extraction', label: 'Memory extraction' }, @@ -767,6 +785,31 @@ function updateAssistantHealth(configData) {
Model Tier Defaults
+
Tier provider/model definitions
+
+ ${['default', 'fast', 'complex', 'local'].map((tier) => { + const cfg = tiers?.[tier] ?? {}; + const provider = cfg.provider ?? tiers?.default?.provider ?? 'openai'; + const model = cfg.model ?? ''; + return ` +
+
${escapeHtml(tier)} tier
+
+ + +
+
+ `; + }).join('')} +