793 lines
35 KiB
TypeScript
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) });
|
|
},
|
|
};
|
|
}
|