Files
flynn/src/gateway/handlers/config.ts
T

793 lines
35 KiB
TypeScript

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;
}
function ensureCouncilsConfig(config: Config): NonNullable<Config['councils']> {
config.councils ??= {
enabled: false,
defaults: {
max_rounds: 2,
ideas_per_round: 6,
top_ideas_for_bridge: 3,
bridge_packet_max_chars: 2500,
bridge_field_max_bullets: 6,
bridge_entry_max_chars: 300,
novelty_delta_threshold: 10,
repetition_threshold: 70,
},
strict_grounding: false,
strict_meta_validation: true,
groups: {
D: {
arbiter_agent: 'council_d_arbiter',
freethinker_agent: 'council_d_freethinker',
model_tier: 'complex',
group_prompt_prefix: 'Optimize for feasibility and speed-to-test. Prefer boring-but-true.',
novelty_bias: 'low',
risk_tolerance: 'low',
forbidden_approaches: ['moonshots', 'handwavy AI claims', 'unverified assumptions'],
},
P: {
arbiter_agent: 'council_p_arbiter',
freethinker_agent: 'council_p_freethinker',
model_tier: 'complex',
group_prompt_prefix: 'Optimize for reframing and non-obvious leverage. Weird is fine; label speculation.',
novelty_bias: 'high',
risk_tolerance: 'high',
forbidden_approaches: ['incremental tweaks', 'obvious best practices', 'purely conventional solutions'],
},
},
meta_arbiter_agent: 'council_meta_arbiter',
meta_model_tier: 'complex',
};
return config.councils;
}
/**
* Redact sensitive values from config before returning.
* Replaces API keys, tokens, passwords, and other credentials with "***".
*
* Covers: telegram, discord, slack, matrix, mattermost, server, models (tiers + fallbacks + local_providers),
* web_search, audio, memory.embedding, automation (webhooks + gmail), and mcp server env vars.
*/
export function redactConfig(config: Config): Record<string, unknown> {
const raw = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
// Helper: redact specified keys on an object if they exist and are non-nullish
const redact = (obj: Record<string, unknown> | undefined, ...keys: string[]) => {
if (!obj) {return;}
for (const key of keys) {
if (obj[key] !== undefined && obj[key] !== null) {obj[key] = '***';}
}
};
// Telegram
redact(raw.telegram as Record<string, unknown>, 'bot_token');
// Discord
redact(raw.discord as Record<string, unknown>, 'bot_token');
// Slack
redact(raw.slack as Record<string, unknown>, 'bot_token', 'app_token', 'signing_secret');
// Matrix
redact(raw.matrix as Record<string, unknown>, 'access_token');
// Mattermost
redact(raw.mattermost as Record<string, unknown>, 'bot_token');
// Server (gateway bearer token)
redact(raw.server as Record<string, unknown>, 'token');
// Models — tiers, their fallbacks, and local_providers (+ their fallbacks)
const models = raw.models as Record<string, unknown> | undefined;
if (models) {
for (const tier of ['default', 'fast', 'complex', 'local']) {
const m = models[tier] as Record<string, unknown> | undefined;
redact(m, 'api_key', 'auth_token');
const fb = m?.fallback as Record<string, unknown> | undefined;
redact(fb, 'api_key', 'auth_token');
}
const localProviders = models.local_providers as Record<string, Record<string, unknown>> | undefined;
if (localProviders) {
for (const provider of Object.values(localProviders)) {
redact(provider, 'api_key', 'auth_token');
const fb = provider.fallback as Record<string, unknown> | undefined;
redact(fb, 'api_key', 'auth_token');
}
}
}
// Web search
redact(raw.web_search as Record<string, unknown>, 'api_key');
// Audio
redact(raw.audio as Record<string, unknown>, 'transcription_api_key');
// Memory → embedding
const memory = raw.memory as Record<string, unknown> | undefined;
if (memory) {
redact(memory.embedding as Record<string, unknown>, 'api_key');
}
// Automation — webhook HMAC secrets and gmail credential paths
const automation = raw.automation as Record<string, unknown> | undefined;
if (automation) {
const webhooks = automation.webhooks as Record<string, unknown>[] | undefined;
if (webhooks) {
for (const wh of webhooks) {
redact(wh, 'secret');
}
}
const gmail = automation.gmail as Record<string, unknown> | undefined;
redact(gmail, 'credentials_file', 'token_file');
}
// MCP server env vars (may contain API keys or other secrets)
const mcp = raw.mcp as Record<string, unknown> | undefined;
if (mcp) {
const servers = mcp.servers as Record<string, unknown>[] | undefined;
if (servers) {
for (const srv of servers) {
if (srv.env && typeof srv.env === 'object') {
const env = srv.env as Record<string, unknown>;
for (const key of Object.keys(env)) {
env[key] = '***';
}
}
}
}
}
return raw;
}
/** Keys that are safe to update at runtime via config.patch. */
const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean> = {
'hooks.confirm': (config, value) => {
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) {return false;}
config.hooks.confirm = value as string[];
return true;
},
'hooks.log': (config, value) => {
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) {return false;}
config.hooks.log = value as string[];
return true;
},
'hooks.silent': (config, value) => {
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) {return false;}
config.hooks.silent = value as string[];
return true;
},
'server.localhost': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.server.localhost = value;
return true;
},
'server.queue.mode': (config, value) => {
if (!['collect', 'followup', 'steer', 'steer_backlog', 'interrupt'].includes(String(value))) {return false;}
config.server.queue.mode = value as typeof config.server.queue.mode;
return true;
},
'server.queue.cap': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 1000) {return false;}
config.server.queue.cap = Math.floor(value);
return true;
},
'server.queue.overflow': (config, value) => {
if (value !== 'drop_old' && value !== 'drop_new') {return false;}
config.server.queue.overflow = value;
return true;
},
'server.queue.debounce_ms': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 60_000) {return false;}
config.server.queue.debounce_ms = Math.floor(value);
return true;
},
'server.queue.summarize_overflow': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.server.queue.summarize_overflow = value;
return true;
},
'server.nodes.location.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.server.nodes.location.enabled = value;
return true;
},
'server.nodes.push.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.server.nodes.push.enabled = value;
return true;
},
'agents.primary_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.primary_tier = value;
return true;
},
'agents.delegation.compaction': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.delegation.compaction = value;
return true;
},
'agents.delegation.memory_extraction': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.delegation.memory_extraction = value;
return true;
},
'agents.delegation.classification': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.delegation.classification = value;
return true;
},
'agents.delegation.tool_summarisation': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.delegation.tool_summarisation = value;
return true;
},
'agents.delegation.complex_reasoning': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.delegation.complex_reasoning = value;
return true;
},
'agents.background_models.compaction.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.agents.background_models.compaction ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.compaction.enabled = value;
return true;
},
'agents.background_models.compaction.provider': (config, value) => {
if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;}
config.agents.background_models.compaction ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.compaction.provider = value as ModelProvider;
return true;
},
'agents.background_models.compaction.model': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.agents.background_models.compaction ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.compaction.model = value.trim();
return true;
},
'agents.background_models.compaction.fallback_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.background_models.compaction ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.compaction.fallback_tier = value;
return true;
},
'agents.background_models.memory_extraction.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.agents.background_models.memory_extraction ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.memory_extraction.enabled = value;
return true;
},
'agents.background_models.memory_extraction.provider': (config, value) => {
if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;}
config.agents.background_models.memory_extraction ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.memory_extraction.provider = value as ModelProvider;
return true;
},
'agents.background_models.memory_extraction.model': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.agents.background_models.memory_extraction ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.memory_extraction.model = value.trim();
return true;
},
'agents.background_models.memory_extraction.fallback_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.background_models.memory_extraction ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.memory_extraction.fallback_tier = value;
return true;
},
'agents.background_models.classification.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.agents.background_models.classification ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.classification.enabled = value;
return true;
},
'agents.background_models.classification.provider': (config, value) => {
if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;}
config.agents.background_models.classification ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.classification.provider = value as ModelProvider;
return true;
},
'agents.background_models.classification.model': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.agents.background_models.classification ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.classification.model = value.trim();
return true;
},
'agents.background_models.classification.fallback_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.background_models.classification ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.classification.fallback_tier = value;
return true;
},
'agents.background_models.tool_summarisation.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.agents.background_models.tool_summarisation ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.tool_summarisation.enabled = value;
return true;
},
'agents.background_models.tool_summarisation.provider': (config, value) => {
if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;}
config.agents.background_models.tool_summarisation ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.tool_summarisation.provider = value as ModelProvider;
return true;
},
'agents.background_models.tool_summarisation.model': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.agents.background_models.tool_summarisation ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.tool_summarisation.model = value.trim();
return true;
},
'agents.background_models.tool_summarisation.fallback_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.background_models.tool_summarisation ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.tool_summarisation.fallback_tier = value;
return true;
},
'agents.background_models.complex_reasoning.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.agents.background_models.complex_reasoning ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.complex_reasoning.enabled = value;
return true;
},
'agents.background_models.complex_reasoning.provider': (config, value) => {
if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;}
config.agents.background_models.complex_reasoning ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.complex_reasoning.provider = value as ModelProvider;
return true;
},
'agents.background_models.complex_reasoning.model': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.agents.background_models.complex_reasoning ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.complex_reasoning.model = value.trim();
return true;
},
'agents.background_models.complex_reasoning.fallback_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.agents.background_models.complex_reasoning ??= { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' };
config.agents.background_models.complex_reasoning.fallback_tier = value;
return true;
},
'councils.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
const councils = ensureCouncilsConfig(config);
councils.enabled = value;
return true;
},
'councils.defaults.max_rounds': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 6) {return false;}
const councils = ensureCouncilsConfig(config);
councils.defaults.max_rounds = Math.floor(value);
return true;
},
'councils.groups.D.model_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.D.model_tier = value;
return true;
},
'councils.groups.P.model_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.P.model_tier = value;
return true;
},
'councils.meta_model_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
const councils = ensureCouncilsConfig(config);
councils.meta_model_tier = value;
return true;
},
'councils.groups.D.arbiter_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.D.arbiter_agent = value.trim();
return true;
},
'councils.groups.D.freethinker_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.D.freethinker_agent = value.trim();
return true;
},
'councils.groups.D.grounder_agent': (config, value) => {
if (typeof value !== 'string') {return false;}
const next = value.trim();
const councils = ensureCouncilsConfig(config);
councils.groups.D.grounder_agent = next.length > 0 ? next : undefined;
return true;
},
'councils.groups.P.arbiter_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.P.arbiter_agent = value.trim();
return true;
},
'councils.groups.P.freethinker_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.P.freethinker_agent = value.trim();
return true;
},
'councils.groups.P.grounder_agent': (config, value) => {
if (typeof value !== 'string') {return false;}
const next = value.trim();
const councils = ensureCouncilsConfig(config);
councils.groups.P.grounder_agent = next.length > 0 ? next : undefined;
return true;
},
'councils.meta_arbiter_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.meta_arbiter_agent = value.trim();
return true;
},
'councils.scaffold_path': (config, value) => {
if (typeof value !== 'string') {return false;}
const next = value.trim();
const councils = ensureCouncilsConfig(config);
councils.scaffold_path = next.length > 0 ? next : undefined;
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'];
config.automation.delivery_mode = value;
return true;
},
'automation.daily_briefing.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.automation ??= {} as Config['automation'];
config.automation.daily_briefing ??= {} as Config['automation']['daily_briefing'];
config.automation.daily_briefing.enabled = value;
return true;
},
'automation.daily_briefing.output.channel': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.daily_briefing ??= {} as Config['automation']['daily_briefing'];
config.automation.daily_briefing.output ??= { channel: 'webchat', peer: 'assistant' };
config.automation.daily_briefing.output.channel = value.trim();
return true;
},
'automation.daily_briefing.output.peer': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.daily_briefing ??= {} as Config['automation']['daily_briefing'];
config.automation.daily_briefing.output ??= { channel: 'webchat', peer: 'assistant' };
config.automation.daily_briefing.output.peer = value.trim();
return true;
},
'automation.daily_briefing.prompt': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.daily_briefing ??= {} as Config['automation']['daily_briefing'];
config.automation.daily_briefing.prompt = value;
return true;
},
'automation.daily_briefing.schedule': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.daily_briefing ??= {} as Config['automation']['daily_briefing'];
config.automation.daily_briefing.schedule = value.trim();
return true;
},
'automation.daily_briefing.timezone': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.daily_briefing ??= {} as Config['automation']['daily_briefing'];
config.automation.daily_briefing.timezone = value.trim();
return true;
},
'automation.daily_briefing.model_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
config.automation ??= {} as Config['automation'];
config.automation.daily_briefing ??= {} as Config['automation']['daily_briefing'];
config.automation.daily_briefing.model_tier = value;
return true;
},
'automation.gmail.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.automation ??= {} as Config['automation'];
config.automation.gmail ??= {} as NonNullable<Config['automation']['gmail']>;
config.automation.gmail.enabled = value;
return true;
},
'automation.gcal.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.automation ??= {} as Config['automation'];
config.automation.gcal ??= {} as NonNullable<Config['automation']['gcal']>;
config.automation.gcal.enabled = value;
return true;
},
'automation.gdocs.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.automation ??= {} as Config['automation'];
config.automation.gdocs ??= {} as NonNullable<Config['automation']['gdocs']>;
config.automation.gdocs.enabled = value;
return true;
},
'automation.gdrive.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.automation ??= {} as Config['automation'];
config.automation.gdrive ??= {} as NonNullable<Config['automation']['gdrive']>;
config.automation.gdrive.enabled = value;
return true;
},
'automation.gtasks.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.automation ??= {} as Config['automation'];
config.automation.gtasks ??= {} as NonNullable<Config['automation']['gtasks']>;
config.automation.gtasks.enabled = value;
return true;
},
'automation.heartbeat.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.enabled = value;
return true;
},
'automation.heartbeat.interval': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.interval = value.trim();
return true;
},
'automation.heartbeat.notify_cooldown': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.notify_cooldown = value.trim();
return true;
},
'automation.heartbeat.failure_threshold': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 10) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.failure_threshold = Math.floor(value);
return true;
},
'automation.heartbeat.disk_threshold_mb': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 10) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.disk_threshold_mb = Math.floor(value);
return true;
},
'automation.heartbeat.process_memory_threshold_mb': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 64) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.process_memory_threshold_mb = Math.floor(value);
return true;
},
'automation.heartbeat.backup_failure_threshold': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 10) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.backup_failure_threshold = Math.floor(value);
return true;
},
'automation.heartbeat.provider_error_rate_threshold': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.provider_error_rate_threshold = value;
return true;
},
'automation.heartbeat.provider_error_min_calls': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {return false;}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.provider_error_min_calls = Math.floor(value);
return true;
},
'automation.heartbeat.checks': (config, value) => {
const allowed = ['gateway', 'model', 'channels', 'memory', 'disk', 'process_memory', 'backup', 'provider_errors'];
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string' && allowed.includes(entry))) {
return false;
}
config.automation ??= {} as Config['automation'];
config.automation.heartbeat ??= {} as Config['automation']['heartbeat'];
config.automation.heartbeat.checks = value as Config['automation']['heartbeat']['checks'];
return true;
},
'backup.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.backup ??= {} as Config['backup'];
config.backup.enabled = value;
return true;
},
'audio.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.audio ??= {} as Config['audio'];
config.audio.enabled = value;
return true;
},
'sandbox.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.sandbox ??= {} as Config['sandbox'];
config.sandbox.enabled = value;
return true;
},
'memory.daily_log.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.memory ??= {} as Config['memory'];
config.memory.daily_log ??= {} as Config['memory']['daily_log'];
config.memory.daily_log.enabled = value;
return true;
},
'memory.proactive_extract.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.memory ??= {} as Config['memory'];
config.memory.proactive_extract ??= {} as Config['memory']['proactive_extract'];
config.memory.proactive_extract.enabled = value;
return true;
},
'memory.proactive_extract.min_tool_calls': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 50) {return false;}
config.memory ??= {} as Config['memory'];
config.memory.proactive_extract ??= {} as Config['memory']['proactive_extract'];
config.memory.proactive_extract.min_tool_calls = Math.floor(value);
return true;
},
'tts.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.tts ??= {} as Config['tts'];
config.tts.enabled = value;
return true;
},
'tts.enabled_channels': (config, value) => {
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string' && v.trim().length > 0)) {return false;}
config.tts ??= {} as Config['tts'];
config.tts.enabled_channels = value as string[];
return true;
},
};
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));
},
'config.patch': async (request: GatewayRequest): Promise<OutboundMessage> => {
const patches = request.params?.patches;
if (!patches || typeof patches !== 'object' || Array.isArray(patches)) {
return makeError(request.id, ErrorCode.InvalidRequest, 'params.patches must be an object of { key: value } pairs');
}
const applied: string[] = [];
const rejected: string[] = [];
const draft = JSON.parse(JSON.stringify(deps.config)) as Config;
for (const [key, value] of Object.entries(patches as Record<string, unknown>)) {
const patcher = PATCHABLE_KEYS[key];
if (!patcher) {
rejected.push(key);
continue;
}
const ok = patcher(draft, value);
if (ok) {
applied.push(key);
} else {
rejected.push(key);
}
}
if (applied.length === 0) {
return makeResponse(request.id, { applied, rejected, persisted: false });
}
if (deps.persistConfig) {
try {
await deps.persistConfig(draft);
} catch (err) {
return makeResponse(request.id, {
applied: [],
rejected,
persisted: false,
persistError: err instanceof Error ? err.message : String(err),
});
}
}
// Update in-memory runtime config only after a successful persist (or when persistence is not configured).
for (const key of Object.keys(deps.config)) {
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) });
},
};
}