feat(gateway): global tier provider/model defaults with catalog-backed options
This commit is contained in:
@@ -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> | 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;
|
||||
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<string, (config: Config, value: unknown) => 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<OutboundMessage> => {
|
||||
return makeResponse(request.id, redactConfig(deps.config));
|
||||
@@ -457,6 +536,7 @@ export function createConfigHandlers(deps: ConfigHandlerDeps) {
|
||||
delete (deps.config as Record<string, unknown>)[key];
|
||||
}
|
||||
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) });
|
||||
},
|
||||
|
||||
@@ -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<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', () => {
|
||||
|
||||
@@ -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<Array<{
|
||||
provider: string;
|
||||
models: string[];
|
||||
source: 'api' | 'config' | 'unavailable';
|
||||
error?: string;
|
||||
fetchedAt: number;
|
||||
}>>;
|
||||
}
|
||||
|
||||
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<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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user