feat(gateway): global tier provider/model defaults with catalog-backed options
This commit is contained in:
+17
-1
@@ -5694,10 +5694,26 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm typecheck passing"
|
"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.<tier>.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": {
|
"overall_progress": {
|
||||||
"total_test_count": 1930,
|
"total_test_count": 1932,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||||
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||||
import { MODEL_PROVIDERS, type Config, type ModelProvider } from '../../config/index.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 {
|
export interface ConfigHandlerDeps {
|
||||||
config: Config;
|
config: Config;
|
||||||
persistConfig?: (nextConfig: Config) => Promise<void> | void;
|
persistConfig?: (nextConfig: Config) => Promise<void> | void;
|
||||||
|
modelRouter?: ModelRouter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -313,6 +316,52 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean
|
|||||||
config.agents.background_models.complex_reasoning.fallback_tier = value;
|
config.agents.background_models.complex_reasoning.fallback_tier = value;
|
||||||
return true;
|
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) => {
|
'automation.delivery_mode': (config, value) => {
|
||||||
if (value !== 'shared_session' && value !== 'isolated_job' && value !== 'announce') {return false;}
|
if (value !== 'shared_session' && value !== 'isolated_job' && value !== 'announce') {return false;}
|
||||||
config.automation ??= {} as Config['automation'];
|
config.automation ??= {} as Config['automation'];
|
||||||
@@ -406,6 +455,36 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createConfigHandlers(deps: ConfigHandlerDeps) {
|
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 {
|
return {
|
||||||
'config.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
'config.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
return makeResponse(request.id, redactConfig(deps.config));
|
return makeResponse(request.id, redactConfig(deps.config));
|
||||||
@@ -457,6 +536,7 @@ export function createConfigHandlers(deps: ConfigHandlerDeps) {
|
|||||||
delete (deps.config as Record<string, unknown>)[key];
|
delete (deps.config as Record<string, unknown>)[key];
|
||||||
}
|
}
|
||||||
Object.assign(deps.config as Record<string, unknown>, draft as Record<string, unknown>);
|
Object.assign(deps.config as Record<string, unknown>, draft as Record<string, unknown>);
|
||||||
|
syncModelRouterFromConfig(draft);
|
||||||
|
|
||||||
return makeResponse(request.id, { applied, rejected, persisted: Boolean(deps.persistConfig) });
|
return makeResponse(request.id, { applied, rejected, persisted: Boolean(deps.persistConfig) });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 () => {
|
it('system.presence returns empty result when getPresence is not provided', async () => {
|
||||||
const req: GatewayRequest = { id: 4, method: 'system.presence' };
|
const req: GatewayRequest = { id: 4, method: 'system.presence' };
|
||||||
const result = await handlers['system.presence'](req) as GatewayResponse;
|
const result = await handlers['system.presence'](req) as GatewayResponse;
|
||||||
@@ -1302,6 +1338,49 @@ describe('config handlers', () => {
|
|||||||
|
|
||||||
expect(result.error.code).toBe(ErrorCode.InvalidRequest);
|
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<Parameters<typeof createConfigHandlers>[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', () => {
|
describe('redactConfig – comprehensive credential redaction', () => {
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ export interface SystemHandlerDeps {
|
|||||||
getNodeLocations?: (opts?: { role?: string; nodeId?: string; limit?: number }) => NodeLocationEntry[];
|
getNodeLocations?: (opts?: { role?: string; nodeId?: string; limit?: number }) => NodeLocationEntry[];
|
||||||
/** Optional callback to retrieve registered node connection snapshots. */
|
/** Optional callback to retrieve registered node connection snapshots. */
|
||||||
getNodes?: (opts?: { role?: string; platform?: string; limit?: number }) => NodeEntry[];
|
getNodes?: (opts?: { role?: string; platform?: string; limit?: number }) => NodeEntry[];
|
||||||
|
/** Optional callback to retrieve provider model catalogs. */
|
||||||
|
getModelCatalog?: (opts?: { provider?: string; forceRefresh?: boolean }) => Promise<Array<{
|
||||||
|
provider: string;
|
||||||
|
models: string[];
|
||||||
|
source: 'api' | 'config' | 'unavailable';
|
||||||
|
error?: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
}>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||||
@@ -265,5 +273,17 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
|||||||
}
|
}
|
||||||
return makeResponse(request.id, { requests: deps.getActiveRequests() });
|
return makeResponse(request.id, { requests: deps.getActiveRequests() });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'system.modelCatalog': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
|
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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { MODEL_PROVIDERS, type Config, type ModelConfig, type ModelProvider } from '../config/index.js';
|
||||||
|
|
||||||
|
const SUPPORTED_PROVIDER_APIS = new Set<ModelProvider>(['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<ProviderModelCatalog[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<ModelProvider, ModelConfig>> {
|
||||||
|
const providerConfigs: Partial<Record<ModelProvider, ModelConfig>> = {};
|
||||||
|
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<string, string>): Promise<unknown> {
|
||||||
|
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<string[]> {
|
||||||
|
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<ModelProvider, CacheEntry>();
|
||||||
|
|
||||||
|
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<ProviderModelCatalog> => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
createNodeHandlers,
|
createNodeHandlers,
|
||||||
} from './handlers/index.js';
|
} from './handlers/index.js';
|
||||||
import { discoverServices } from './handlers/services.js';
|
import { discoverServices } from './handlers/services.js';
|
||||||
|
import { createModelCatalogFetcher } from './modelCatalog.js';
|
||||||
import type { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js';
|
import type { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js';
|
||||||
import type { NodeConnectionState } from './handlers/node.js';
|
import type { NodeConnectionState } from './handlers/node.js';
|
||||||
import type { SessionManager } from '../session/manager.js';
|
import type { SessionManager } from '../session/manager.js';
|
||||||
@@ -206,6 +207,7 @@ export class GatewayServer {
|
|||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
const channelRegistry = this.config.channelRegistry;
|
const channelRegistry = this.config.channelRegistry;
|
||||||
const runtimeConfig = this.config.config;
|
const runtimeConfig = this.config.config;
|
||||||
|
const modelCatalogFetcher = runtimeConfig ? createModelCatalogFetcher(runtimeConfig) : undefined;
|
||||||
const systemHandlers = createSystemHandlers({
|
const systemHandlers = createSystemHandlers({
|
||||||
startTime: this.startTime,
|
startTime: this.startTime,
|
||||||
version: this.config.version ?? '0.1.0',
|
version: this.config.version ?? '0.1.0',
|
||||||
@@ -225,6 +227,9 @@ export class GatewayServer {
|
|||||||
getServices: runtimeConfig && channelRegistry
|
getServices: runtimeConfig && channelRegistry
|
||||||
? () => discoverServices(runtimeConfig, channelRegistry)
|
? () => discoverServices(runtimeConfig, channelRegistry)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
getModelCatalog: modelCatalogFetcher
|
||||||
|
? (opts) => modelCatalogFetcher(opts)
|
||||||
|
: undefined,
|
||||||
getPresence: channelRegistry
|
getPresence: channelRegistry
|
||||||
? (opts) => channelRegistry.getPresence(opts)
|
? (opts) => channelRegistry.getPresence(opts)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -478,6 +483,7 @@ export class GatewayServer {
|
|||||||
const configHandlers = createConfigHandlers({
|
const configHandlers = createConfigHandlers({
|
||||||
config: this.config.config,
|
config: this.config.config,
|
||||||
persistConfig: this.config.persistConfig,
|
persistConfig: this.config.persistConfig,
|
||||||
|
modelRouter: 'setClient' in this.config.modelClient ? this.config.modelClient : undefined,
|
||||||
});
|
});
|
||||||
for (const [method, handler] of Object.entries(configHandlers)) {
|
for (const [method, handler] of Object.entries(configHandlers)) {
|
||||||
this.router.register(method, handler);
|
this.router.register(method, handler);
|
||||||
|
|||||||
@@ -695,6 +695,12 @@ function updateAssistantHealth(configData) {
|
|||||||
const modelTier = configData?.agents?.primary_tier ?? 'default';
|
const modelTier = configData?.agents?.primary_tier ?? 'default';
|
||||||
const delegation = configData?.agents?.delegation ?? {};
|
const delegation = configData?.agents?.delegation ?? {};
|
||||||
const backgroundModels = configData?.agents?.background_models ?? {};
|
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 = [
|
const checklistRows = [
|
||||||
{ label: 'Set briefing output channel + peer', done: Boolean(briefingOutput?.channel && briefingOutput?.peer) },
|
{ label: 'Set briefing output channel + peer', done: Boolean(briefingOutput?.channel && briefingOutput?.peer) },
|
||||||
{ label: 'Enable assistant behavior profile', done: playbookLikeReady },
|
{ label: 'Enable assistant behavior profile', done: playbookLikeReady },
|
||||||
@@ -710,6 +716,18 @@ function updateAssistantHealth(configData) {
|
|||||||
const tierOption = (selected) => ['fast', 'default', 'complex', 'local']
|
const tierOption = (selected) => ['fast', 'default', 'complex', 'local']
|
||||||
.map((tier) => `<option value="${tier}" ${selected === tier ? 'selected' : ''}>${tier}</option>`)
|
.map((tier) => `<option value="${tier}" ${selected === tier ? 'selected' : ''}>${tier}</option>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
const providerOption = (selected) => providerList
|
||||||
|
.map((provider) => `<option value="${provider}" ${selected === provider ? 'selected' : ''}>${provider}</option>`)
|
||||||
|
.join('');
|
||||||
|
const modelDataList = (id, provider, selected) => {
|
||||||
|
const options = modelOptionsByProvider[provider] ?? [];
|
||||||
|
return `
|
||||||
|
<input id="${id}" list="${id}-list" value="${escapeHtml(selected ?? '')}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" />
|
||||||
|
<datalist id="${id}-list">
|
||||||
|
${options.map((model) => `<option value="${escapeHtml(model)}"></option>`).join('')}
|
||||||
|
</datalist>
|
||||||
|
`;
|
||||||
|
};
|
||||||
const taskRows = [
|
const taskRows = [
|
||||||
{ key: 'compaction', label: 'Compaction' },
|
{ key: 'compaction', label: 'Compaction' },
|
||||||
{ key: 'memory_extraction', label: 'Memory extraction' },
|
{ key: 'memory_extraction', label: 'Memory extraction' },
|
||||||
@@ -767,6 +785,31 @@ function updateAssistantHealth(configData) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
|
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
|
||||||
<div class="text-sm font-semibold text-zinc-50 mb-3">Model Tier Defaults</div>
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Model Tier Defaults</div>
|
||||||
|
<div class="text-sm text-zinc-500 mb-3">Tier provider/model definitions</div>
|
||||||
|
<div class="space-y-3 mb-4">
|
||||||
|
${['default', 'fast', 'complex', 'local'].map((tier) => {
|
||||||
|
const cfg = tiers?.[tier] ?? {};
|
||||||
|
const provider = cfg.provider ?? tiers?.default?.provider ?? 'openai';
|
||||||
|
const model = cfg.model ?? '';
|
||||||
|
return `
|
||||||
|
<div class="p-3 border border-zinc-800 rounded-md bg-zinc-950/60">
|
||||||
|
<div class="text-sm text-zinc-50 mb-2">${escapeHtml(tier)} tier</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-zinc-500">Provider</span>
|
||||||
|
<select id="assist-tier-${tier}-provider" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none">
|
||||||
|
${providerOption(provider)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-zinc-500">Model</span>
|
||||||
|
${modelDataList(`assist-tier-${tier}-model`, provider, model)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||||
<label class="flex flex-col gap-1.5">
|
<label class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm text-zinc-400">Primary tier</span>
|
<span class="text-sm text-zinc-400">Primary tier</span>
|
||||||
@@ -797,11 +840,13 @@ function updateAssistantHealth(configData) {
|
|||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-xs text-zinc-500">Provider</span>
|
<span class="text-xs text-zinc-500">Provider</span>
|
||||||
<input id="assist-bg-${task.key}-provider" type="text" value="${escapeHtml(background?.provider ?? '')}" placeholder="openai" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" />
|
<select id="assist-bg-${task.key}-provider" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none">
|
||||||
|
${providerOption(background?.provider ?? tiers?.default?.provider ?? 'openai')}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-xs text-zinc-500">Model</span>
|
<span class="text-xs text-zinc-500">Model</span>
|
||||||
<input id="assist-bg-${task.key}-model" type="text" value="${escapeHtml(background?.model ?? '')}" placeholder="gpt-4o-mini" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" />
|
${modelDataList(`assist-bg-${task.key}-model`, background?.provider ?? tiers?.default?.provider ?? 'openai', background?.model ?? '')}
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-xs text-zinc-500">Fallback tier</span>
|
<span class="text-xs text-zinc-500">Fallback tier</span>
|
||||||
@@ -866,6 +911,32 @@ function updateAssistantHealth(configData) {
|
|||||||
${renderAssistantSaveState()}
|
${renderAssistantSaveState()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const updateModelOptions = (inputId, provider) => {
|
||||||
|
const input = el.querySelector(`#${inputId}`);
|
||||||
|
const list = el.querySelector(`#${inputId}-list`);
|
||||||
|
if (!input || !list) {return;}
|
||||||
|
const options = modelOptionsByProvider[provider] ?? [];
|
||||||
|
list.innerHTML = options.map((model) => `<option value="${escapeHtml(model)}"></option>`).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierRows = ['default', 'fast', 'complex', 'local'];
|
||||||
|
for (const tier of tierRows) {
|
||||||
|
const providerSelect = el.querySelector(`#assist-tier-${tier}-provider`);
|
||||||
|
if (!providerSelect) {continue;}
|
||||||
|
providerSelect.addEventListener('change', () => {
|
||||||
|
updateModelOptions(`assist-tier-${tier}-model`, providerSelect.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskRowsForModels = ['compaction', 'memory_extraction', 'classification', 'tool_summarisation', 'complex_reasoning'];
|
||||||
|
for (const task of taskRowsForModels) {
|
||||||
|
const providerSelect = el.querySelector(`#assist-bg-${task}-provider`);
|
||||||
|
if (!providerSelect) {continue;}
|
||||||
|
providerSelect.addEventListener('change', () => {
|
||||||
|
updateModelOptions(`assist-bg-${task}-model`, providerSelect.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const statusEl = el.querySelector('#ops-assistant-status');
|
const statusEl = el.querySelector('#ops-assistant-status');
|
||||||
const buttons = el.querySelectorAll('.assistant-action-btn');
|
const buttons = el.querySelectorAll('.assistant-action-btn');
|
||||||
buttons.forEach((button) => {
|
buttons.forEach((button) => {
|
||||||
@@ -929,6 +1000,20 @@ function updateAssistantHealth(configData) {
|
|||||||
patches = {
|
patches = {
|
||||||
'agents.primary_tier': (el.querySelector('#assist-primary-tier')?.value ?? 'default'),
|
'agents.primary_tier': (el.querySelector('#assist-primary-tier')?.value ?? 'default'),
|
||||||
};
|
};
|
||||||
|
const tiers = ['default', 'fast', 'complex', 'local'];
|
||||||
|
for (const tier of tiers) {
|
||||||
|
const provider = (el.querySelector(`#assist-tier-${tier}-provider`)?.value ?? '').trim();
|
||||||
|
const model = (el.querySelector(`#assist-tier-${tier}-model`)?.value ?? '').trim();
|
||||||
|
if (!provider || (!model && tier !== 'default')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (provider) {
|
||||||
|
patches[`models.${tier}.provider`] = provider;
|
||||||
|
}
|
||||||
|
if (model) {
|
||||||
|
patches[`models.${tier}.model`] = model;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const delegationTier = el.querySelector(`#assist-delegation-${task}`)?.value ?? 'fast';
|
const delegationTier = el.querySelector(`#assist-delegation-${task}`)?.value ?? 'fast';
|
||||||
const enabled = Boolean(el.querySelector(`#assist-bg-${task}-enabled`)?.checked);
|
const enabled = Boolean(el.querySelector(`#assist-bg-${task}-enabled`)?.checked);
|
||||||
@@ -1042,22 +1127,29 @@ async function fetchFast(client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSlow(client) {
|
async function fetchSlow(client) {
|
||||||
const [health, services, sessionAnalytics, contextUsage, config] = await Promise.allSettled([
|
const [health, services, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([
|
||||||
client.call('system.health'),
|
client.call('system.health'),
|
||||||
client.call('system.services'),
|
client.call('system.services'),
|
||||||
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
||||||
client.call('system.contextUsage'),
|
client.call('system.contextUsage'),
|
||||||
client.call('config.get'),
|
client.call('config.get'),
|
||||||
|
client.call('system.modelCatalog'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const unwrap = (result) => (result.status === 'fulfilled' ? result.value : null);
|
const unwrap = (result) => (result.status === 'fulfilled' ? result.value : null);
|
||||||
|
|
||||||
|
const configValue = unwrap(config);
|
||||||
|
const modelCatalogValue = unwrap(modelCatalog);
|
||||||
|
if (configValue && typeof configValue === 'object') {
|
||||||
|
configValue.__modelCatalog = Array.isArray(modelCatalogValue?.providers) ? modelCatalogValue.providers : [];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
health: unwrap(health),
|
health: unwrap(health),
|
||||||
services: unwrap(services),
|
services: unwrap(services),
|
||||||
sessionAnalytics: unwrap(sessionAnalytics),
|
sessionAnalytics: unwrap(sessionAnalytics),
|
||||||
contextUsage: unwrap(contextUsage),
|
contextUsage: unwrap(contextUsage),
|
||||||
config: unwrap(config),
|
config: configValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user