titan-agent 5.4.0 → 5.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,14 +37,15 @@ function loadConfig() {
37
37
  }
38
38
  }
39
39
  try {
40
- const schemaShape = TitanConfigSchema._def.shape();
41
- const knownKeys = new Set(Object.keys(schemaShape));
42
- const unknownKeys = Object.keys(rawConfig).filter((k) => !knownKeys.has(k));
43
- if (unknownKeys.length > 0) {
44
- logger.warn(
45
- COMPONENT,
46
- `Config contains unknown top-level keys that will be stripped: ${unknownKeys.join(", ")}. If these are intentional, add them to TitanConfigSchema in src/config/schema.ts.`
47
- );
40
+ const parsed = TitanConfigSchema.safeParse(rawConfig);
41
+ if (parsed.success) {
42
+ const droppedPaths = findDroppedKeys(rawConfig, parsed.data, "");
43
+ for (const path of droppedPaths) {
44
+ logger.warn(
45
+ COMPONENT,
46
+ `Unknown config key: ${path}. Will be ignored. If intentional, extend TitanConfigSchema in src/config/schema.ts.`
47
+ );
48
+ }
48
49
  }
49
50
  } catch {
50
51
  }
@@ -197,6 +198,27 @@ function applyEnvOverrides(config) {
197
198
  }
198
199
  }
199
200
  }
201
+ function findDroppedKeys(raw, parsed, pathPrefix, depth = 0) {
202
+ if (depth > 8) return [];
203
+ if (!isPlainObject(raw)) return [];
204
+ const dropped = [];
205
+ for (const key of Object.keys(raw)) {
206
+ const fullPath = pathPrefix ? `${pathPrefix}.${key}` : key;
207
+ const rawVal = raw[key];
208
+ const parsedVal = isPlainObject(parsed) ? parsed[key] : void 0;
209
+ if (parsedVal === void 0) {
210
+ if (rawVal !== void 0) dropped.push(fullPath);
211
+ continue;
212
+ }
213
+ if (isPlainObject(rawVal) && isPlainObject(parsedVal)) {
214
+ dropped.push(...findDroppedKeys(rawVal, parsedVal, fullPath, depth + 1));
215
+ }
216
+ }
217
+ return dropped;
218
+ }
219
+ function isPlainObject(value) {
220
+ return value !== null && typeof value === "object" && !Array.isArray(value);
221
+ }
200
222
  function setNested(obj, path, value) {
201
223
  const parts = path.split(".");
202
224
  let current = obj;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/config/config.ts"],"sourcesContent":["/**\n * TITAN Configuration Manager\n * Loads, validates, and persists configuration from ~/.titan/titan.json\n */\nimport { existsSync } from 'fs';\nimport { TITAN_CONFIG_PATH, TITAN_HOME } from '../utils/constants.js';\nimport { readJsonFile, writeJsonFile, ensureDir, deepMerge } from '../utils/helpers.js';\nimport { TitanConfigSchema, type TitanConfig } from './schema.js';\nimport logger from '../utils/logger.js';\n\nconst COMPONENT = 'Config';\n\nlet cachedConfig: TitanConfig | null = null;\n\n/** Get the default configuration */\nexport function getDefaultConfig(): TitanConfig {\n return TitanConfigSchema.parse({});\n}\n\n/** Load configuration from disk, merging with defaults */\nexport function loadConfig(): TitanConfig {\n if (cachedConfig) return cachedConfig;\n\n ensureDir(TITAN_HOME);\n\n let rawConfig: Record<string, unknown> = {};\n\n if (existsSync(TITAN_CONFIG_PATH)) {\n const loaded = readJsonFile<Record<string, unknown>>(TITAN_CONFIG_PATH);\n if (loaded) {\n rawConfig = loaded;\n logger.debug(COMPONENT, `Loaded config from ${TITAN_CONFIG_PATH}`);\n } else {\n logger.warn(COMPONENT, `Failed to parse config at ${TITAN_CONFIG_PATH}, using defaults`);\n }\n } else {\n logger.info(COMPONENT, 'No config file found, using defaults');\n }\n\n // Apply environment variables\n applyEnvOverrides(rawConfig);\n\n // v4.8.4: migrate a top-level `auth` block to `gateway.auth`. The\n // documented path has always been `gateway.auth`, but users (and\n // Claude) naturally try `auth` at the root. Rather than strip it\n // silently and warn, move it to the canonical location and continue.\n if (rawConfig && typeof rawConfig === 'object' && 'auth' in rawConfig) {\n const raw = rawConfig as Record<string, unknown>;\n const topAuth = raw.auth as Record<string, unknown> | undefined;\n if (topAuth && typeof topAuth === 'object') {\n const gateway = (raw.gateway as Record<string, unknown> | undefined) ?? {};\n const gatewayAuth = (gateway.auth as Record<string, unknown> | undefined) ?? {};\n // gateway.auth wins if both are set — explicit nested wins\n // over migrated top-level.\n raw.gateway = { ...gateway, auth: { ...topAuth, ...gatewayAuth } };\n delete raw.auth;\n logger.info(COMPONENT, 'Migrated top-level `auth` → `gateway.auth`. Update titan.json to nest it under `gateway` to silence this notice.');\n }\n }\n\n // Detect unknown top-level keys BEFORE Zod strips them.\n // Hunt Finding #1 (2026-04-14): `facebook: {...}` was silently stripped because\n // the key wasn't in TitanConfigSchema. Users editing their config saw no effect.\n // Now we warn loudly when a key is about to be dropped, so bugs of this class\n // are caught immediately instead of being debugged days later.\n try {\n const schemaShape = (TitanConfigSchema as unknown as { _def: { shape: () => Record<string, unknown> } })._def.shape();\n const knownKeys = new Set(Object.keys(schemaShape));\n const unknownKeys = Object.keys(rawConfig).filter(k => !knownKeys.has(k));\n if (unknownKeys.length > 0) {\n logger.warn(\n COMPONENT,\n `Config contains unknown top-level keys that will be stripped: ${unknownKeys.join(', ')}. ` +\n `If these are intentional, add them to TitanConfigSchema in src/config/schema.ts.`,\n );\n }\n } catch {\n // If the schema shape introspection fails, skip the warning (shouldn't block load).\n }\n\n // Validate and merge with defaults via Zod.\n // CRITICAL: On validation failure, deep-merge raw config over defaults\n // so that valid user settings (daemon.enabled, autonomy.mode, etc.) survive.\n // Previously this fell back to pure defaults, wiping ALL user config on any error.\n const result = TitanConfigSchema.safeParse(rawConfig);\n if (result.success) {\n cachedConfig = result.data;\n } else {\n const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');\n logger.warn(COMPONENT, `Config validation issues (${issues}) — merging valid fields over defaults`);\n // Deep-merge raw config over defaults so valid sections survive\n const defaults = getDefaultConfig();\n const merged = deepMerge(defaults as Record<string, unknown>, rawConfig) as TitanConfig;\n // Try parsing the merged result — if it still fails, use defaults but log loudly\n const reparse = TitanConfigSchema.safeParse(merged);\n if (reparse.success) {\n cachedConfig = reparse.data;\n } else {\n logger.error(COMPONENT, `Config still invalid after merge — falling back to defaults. Fix your titan.json.`);\n cachedConfig = defaults;\n }\n }\n\n return cachedConfig;\n}\n\n/** Save current configuration to disk */\nexport function saveConfig(config: TitanConfig): void {\n ensureDir(TITAN_HOME);\n writeJsonFile(TITAN_CONFIG_PATH, config);\n cachedConfig = config;\n logger.info(COMPONENT, `Config saved to ${TITAN_CONFIG_PATH}`);\n}\n\n/** Update specific fields in the config */\nexport function updateConfig(partial: Partial<TitanConfig>): TitanConfig {\n const current = loadConfig();\n const updated = deepMerge(current as Record<string, unknown>, partial as Record<string, unknown>) as TitanConfig;\n const validated = TitanConfigSchema.parse(updated);\n saveConfig(validated);\n return validated;\n}\n\n/** Reset config cache (useful for testing) */\nexport function resetConfigCache(): void {\n cachedConfig = null;\n}\n\n/** Check if the configuration file exists */\nexport function configExists(): boolean {\n return existsSync(TITAN_CONFIG_PATH);\n}\n\n/**\n * Check if at least one usable AI provider is configured.\n *\n * \"Usable\" means one of:\n * - Any cloud provider has a non-empty `apiKey` set in config\n * - Any *_API_KEY env var is set (Anthropic, OpenAI, Google, Groq, etc.)\n * - Ollama is reachable at the configured baseUrl (returns at least one model)\n *\n * Used by the gateway boot guard and CLI to refuse to start with empty config\n * instead of letting the user hit \"Internal Server Error\" later.\n *\n * Note: Ollama check is async and is the slowest part of this function (~3s timeout).\n * Callers should `await` and only call once at boot.\n */\nexport async function hasUsableProvider(): Promise<{ ok: boolean; details: string }> {\n const config = loadConfig();\n\n // 1. Check config-file API keys (cloud providers)\n const providers = (config.providers as Record<string, unknown> | undefined) || {};\n const cloudProviderNames = [\n 'anthropic', 'openai', 'google', 'groq', 'mistral', 'openrouter',\n 'fireworks', 'xai', 'together', 'deepseek', 'cerebras', 'cohere',\n 'perplexity', 'venice', 'bedrock', 'litellm', 'azure', 'deepinfra',\n 'sambanova', 'kimi', 'huggingface', 'ai21', 'cohere-v2', 'reka',\n 'zhipu', 'yi', 'inflection', 'novita', 'replicate', 'lepton',\n 'anyscale', 'octo', 'nous', 'minimax', 'nvidia',\n ];\n for (const name of cloudProviderNames) {\n const p = providers[name] as { apiKey?: string } | undefined;\n if (p?.apiKey && p.apiKey.trim().length > 0) {\n return { ok: true, details: `${name} has an API key configured` };\n }\n }\n\n // 2. Check env-var API keys (in case config wasn't reloaded after env var change)\n const envKeys = [\n 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY', 'GROQ_API_KEY',\n 'MISTRAL_API_KEY', 'OPENROUTER_API_KEY', 'FIREWORKS_API_KEY', 'XAI_API_KEY',\n 'TOGETHER_API_KEY', 'DEEPSEEK_API_KEY', 'CEREBRAS_API_KEY', 'COHERE_API_KEY',\n 'PERPLEXITY_API_KEY', 'AZURE_OPENAI_API_KEY',\n ];\n for (const key of envKeys) {\n if (process.env[key] && process.env[key]!.trim().length > 0) {\n return { ok: true, details: `${key} is set in environment` };\n }\n }\n\n // 3. Check Ollama reachability (last resort — slow)\n const ollamaUrl = (providers.ollama as { baseUrl?: string } | undefined)?.baseUrl\n || process.env.OLLAMA_BASE_URL\n || 'http://localhost:11434';\n try {\n const res = await fetch(`${ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });\n if (res.ok) {\n const json = await res.json() as { models?: { name: string }[] };\n const count = (json.models || []).length;\n if (count > 0) {\n return { ok: true, details: `Ollama at ${ollamaUrl} is reachable (${count} models)` };\n }\n return { ok: false, details: `Ollama at ${ollamaUrl} is reachable but has 0 models — run \"ollama pull qwen3.5:4b\"` };\n }\n } catch {\n // Ollama unreachable, fall through to \"no providers\"\n }\n\n return { ok: false, details: 'No API keys configured and Ollama is not running' };\n}\n\n/** Apply environment variable overrides to raw config */\nfunction applyEnvOverrides(config: Record<string, unknown>): void {\n const envMap: Record<string, (val: string) => void> = {\n TITAN_MODEL: (val) => setNested(config, 'agent.model', val),\n TITAN_GATEWAY_PORT: (val) => setNested(config, 'gateway.port', parseInt(val, 10)),\n TITAN_GATEWAY_HOST: (val) => setNested(config, 'gateway.host', val),\n TITAN_LOG_LEVEL: (val) => setNested(config, 'logging.level', val),\n ANTHROPIC_API_KEY: (val) => setNested(config, 'providers.anthropic.apiKey', val),\n OPENAI_API_KEY: (val) => setNested(config, 'providers.openai.apiKey', val),\n GOOGLE_API_KEY: (val) => setNested(config, 'providers.google.apiKey', val),\n OLLAMA_BASE_URL: (val) => setNested(config, 'providers.ollama.baseUrl', val),\n DISCORD_TOKEN: (val) => setNested(config, 'channels.discord.token', val),\n TELEGRAM_TOKEN: (val) => setNested(config, 'channels.telegram.token', val),\n SLACK_TOKEN: (val) => setNested(config, 'channels.slack.token', val),\n GOOGLE_OAUTH_CLIENT_ID: (val) => setNested(config, 'oauth.google.clientId', val),\n GOOGLE_OAUTH_CLIENT_SECRET: (val) => setNested(config, 'oauth.google.clientSecret', val),\n OPENROUTER_API_KEY: (val) => setNested(config, 'providers.openrouter.apiKey', val),\n };\n\n // Cloud mode: auto-configure OpenRouter to point at SaaS gateway\n if (process.env.TITAN_CLOUD_MODE === 'true' && process.env.TITAN_CLOUD_API) {\n const cloudApi = process.env.TITAN_CLOUD_API;\n setNested(config, 'providers.openrouter.baseUrl', cloudApi + '/api/v1');\n logger.debug(COMPONENT, `Cloud mode: OpenRouter base URL set to ${cloudApi}/api/v1`);\n }\n\n for (const [envKey, setter] of Object.entries(envMap)) {\n const val = process.env[envKey];\n if (val) {\n setter(val);\n logger.debug(COMPONENT, `Applied env override: ${envKey}`);\n }\n }\n}\n\n/** Set a nested property by dot-notation path */\nfunction setNested(obj: Record<string, unknown>, path: string, value: unknown): void {\n const parts = path.split('.');\n let current: Record<string, unknown> = obj;\n for (let i = 0; i < parts.length - 1; i++) {\n if (!current[parts[i]] || typeof current[parts[i]] !== 'object') {\n current[parts[i]] = {};\n }\n current = current[parts[i]] as Record<string, unknown>;\n }\n current[parts[parts.length - 1]] = value;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/config/config.ts"],"sourcesContent":["/**\n * TITAN Configuration Manager\n * Loads, validates, and persists configuration from ~/.titan/titan.json\n */\nimport { existsSync } from 'fs';\nimport { TITAN_CONFIG_PATH, TITAN_HOME } from '../utils/constants.js';\nimport { readJsonFile, writeJsonFile, ensureDir, deepMerge } from '../utils/helpers.js';\nimport { TitanConfigSchema, type TitanConfig } from './schema.js';\nimport logger from '../utils/logger.js';\n\nconst COMPONENT = 'Config';\n\nlet cachedConfig: TitanConfig | null = null;\n\n/** Get the default configuration */\nexport function getDefaultConfig(): TitanConfig {\n return TitanConfigSchema.parse({});\n}\n\n/** Load configuration from disk, merging with defaults */\nexport function loadConfig(): TitanConfig {\n if (cachedConfig) return cachedConfig;\n\n ensureDir(TITAN_HOME);\n\n let rawConfig: Record<string, unknown> = {};\n\n if (existsSync(TITAN_CONFIG_PATH)) {\n const loaded = readJsonFile<Record<string, unknown>>(TITAN_CONFIG_PATH);\n if (loaded) {\n rawConfig = loaded;\n logger.debug(COMPONENT, `Loaded config from ${TITAN_CONFIG_PATH}`);\n } else {\n logger.warn(COMPONENT, `Failed to parse config at ${TITAN_CONFIG_PATH}, using defaults`);\n }\n } else {\n logger.info(COMPONENT, 'No config file found, using defaults');\n }\n\n // Apply environment variables\n applyEnvOverrides(rawConfig);\n\n // v4.8.4: migrate a top-level `auth` block to `gateway.auth`. The\n // documented path has always been `gateway.auth`, but users (and\n // Claude) naturally try `auth` at the root. Rather than strip it\n // silently and warn, move it to the canonical location and continue.\n if (rawConfig && typeof rawConfig === 'object' && 'auth' in rawConfig) {\n const raw = rawConfig as Record<string, unknown>;\n const topAuth = raw.auth as Record<string, unknown> | undefined;\n if (topAuth && typeof topAuth === 'object') {\n const gateway = (raw.gateway as Record<string, unknown> | undefined) ?? {};\n const gatewayAuth = (gateway.auth as Record<string, unknown> | undefined) ?? {};\n // gateway.auth wins if both are set — explicit nested wins\n // over migrated top-level.\n raw.gateway = { ...gateway, auth: { ...topAuth, ...gatewayAuth } };\n delete raw.auth;\n logger.info(COMPONENT, 'Migrated top-level `auth` → `gateway.auth`. Update titan.json to nest it under `gateway` to silence this notice.');\n }\n }\n\n // Detect unknown keys at every nesting depth BEFORE Zod silently strips\n // them. Hunt Finding #1 (2026-04-14) covered the top-level case\n // (`facebook: {...}` ignored). This expanded check also catches nested\n // typos like `providers.anthropic.unknownField` so users learn about\n // dropped keys immediately rather than chasing \"why doesn't my setting\n // do anything\" days later.\n //\n // The diff is purely informational — invalid keys never block startup\n // (we still feed `rawConfig` through Zod's permissive parse).\n try {\n const parsed = TitanConfigSchema.safeParse(rawConfig);\n if (parsed.success) {\n const droppedPaths = findDroppedKeys(rawConfig, parsed.data as Record<string, unknown>, '');\n for (const path of droppedPaths) {\n logger.warn(\n COMPONENT,\n `Unknown config key: ${path}. Will be ignored. ` +\n `If intentional, extend TitanConfigSchema in src/config/schema.ts.`,\n );\n }\n }\n } catch {\n // Introspection failed — never let a diagnostic warning block load.\n }\n\n // Validate and merge with defaults via Zod.\n // CRITICAL: On validation failure, deep-merge raw config over defaults\n // so that valid user settings (daemon.enabled, autonomy.mode, etc.) survive.\n // Previously this fell back to pure defaults, wiping ALL user config on any error.\n const result = TitanConfigSchema.safeParse(rawConfig);\n if (result.success) {\n cachedConfig = result.data;\n } else {\n const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');\n logger.warn(COMPONENT, `Config validation issues (${issues}) — merging valid fields over defaults`);\n // Deep-merge raw config over defaults so valid sections survive\n const defaults = getDefaultConfig();\n const merged = deepMerge(defaults as Record<string, unknown>, rawConfig) as TitanConfig;\n // Try parsing the merged result — if it still fails, use defaults but log loudly\n const reparse = TitanConfigSchema.safeParse(merged);\n if (reparse.success) {\n cachedConfig = reparse.data;\n } else {\n logger.error(COMPONENT, `Config still invalid after merge — falling back to defaults. Fix your titan.json.`);\n cachedConfig = defaults;\n }\n }\n\n return cachedConfig;\n}\n\n/** Save current configuration to disk */\nexport function saveConfig(config: TitanConfig): void {\n ensureDir(TITAN_HOME);\n writeJsonFile(TITAN_CONFIG_PATH, config);\n cachedConfig = config;\n logger.info(COMPONENT, `Config saved to ${TITAN_CONFIG_PATH}`);\n}\n\n/** Update specific fields in the config */\nexport function updateConfig(partial: Partial<TitanConfig>): TitanConfig {\n const current = loadConfig();\n const updated = deepMerge(current as Record<string, unknown>, partial as Record<string, unknown>) as TitanConfig;\n const validated = TitanConfigSchema.parse(updated);\n saveConfig(validated);\n return validated;\n}\n\n/** Reset config cache (useful for testing) */\nexport function resetConfigCache(): void {\n cachedConfig = null;\n}\n\n/** Check if the configuration file exists */\nexport function configExists(): boolean {\n return existsSync(TITAN_CONFIG_PATH);\n}\n\n/**\n * Check if at least one usable AI provider is configured.\n *\n * \"Usable\" means one of:\n * - Any cloud provider has a non-empty `apiKey` set in config\n * - Any *_API_KEY env var is set (Anthropic, OpenAI, Google, Groq, etc.)\n * - Ollama is reachable at the configured baseUrl (returns at least one model)\n *\n * Used by the gateway boot guard and CLI to refuse to start with empty config\n * instead of letting the user hit \"Internal Server Error\" later.\n *\n * Note: Ollama check is async and is the slowest part of this function (~3s timeout).\n * Callers should `await` and only call once at boot.\n */\nexport async function hasUsableProvider(): Promise<{ ok: boolean; details: string }> {\n const config = loadConfig();\n\n // 1. Check config-file API keys (cloud providers)\n const providers = (config.providers as Record<string, unknown> | undefined) || {};\n const cloudProviderNames = [\n 'anthropic', 'openai', 'google', 'groq', 'mistral', 'openrouter',\n 'fireworks', 'xai', 'together', 'deepseek', 'cerebras', 'cohere',\n 'perplexity', 'venice', 'bedrock', 'litellm', 'azure', 'deepinfra',\n 'sambanova', 'kimi', 'huggingface', 'ai21', 'cohere-v2', 'reka',\n 'zhipu', 'yi', 'inflection', 'novita', 'replicate', 'lepton',\n 'anyscale', 'octo', 'nous', 'minimax', 'nvidia',\n ];\n for (const name of cloudProviderNames) {\n const p = providers[name] as { apiKey?: string } | undefined;\n if (p?.apiKey && p.apiKey.trim().length > 0) {\n return { ok: true, details: `${name} has an API key configured` };\n }\n }\n\n // 2. Check env-var API keys (in case config wasn't reloaded after env var change)\n const envKeys = [\n 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY', 'GROQ_API_KEY',\n 'MISTRAL_API_KEY', 'OPENROUTER_API_KEY', 'FIREWORKS_API_KEY', 'XAI_API_KEY',\n 'TOGETHER_API_KEY', 'DEEPSEEK_API_KEY', 'CEREBRAS_API_KEY', 'COHERE_API_KEY',\n 'PERPLEXITY_API_KEY', 'AZURE_OPENAI_API_KEY',\n ];\n for (const key of envKeys) {\n if (process.env[key] && process.env[key]!.trim().length > 0) {\n return { ok: true, details: `${key} is set in environment` };\n }\n }\n\n // 3. Check Ollama reachability (last resort — slow)\n const ollamaUrl = (providers.ollama as { baseUrl?: string } | undefined)?.baseUrl\n || process.env.OLLAMA_BASE_URL\n || 'http://localhost:11434';\n try {\n const res = await fetch(`${ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });\n if (res.ok) {\n const json = await res.json() as { models?: { name: string }[] };\n const count = (json.models || []).length;\n if (count > 0) {\n return { ok: true, details: `Ollama at ${ollamaUrl} is reachable (${count} models)` };\n }\n return { ok: false, details: `Ollama at ${ollamaUrl} is reachable but has 0 models — run \"ollama pull qwen3.5:4b\"` };\n }\n } catch {\n // Ollama unreachable, fall through to \"no providers\"\n }\n\n return { ok: false, details: 'No API keys configured and Ollama is not running' };\n}\n\n/** Apply environment variable overrides to raw config */\nfunction applyEnvOverrides(config: Record<string, unknown>): void {\n const envMap: Record<string, (val: string) => void> = {\n TITAN_MODEL: (val) => setNested(config, 'agent.model', val),\n TITAN_GATEWAY_PORT: (val) => setNested(config, 'gateway.port', parseInt(val, 10)),\n TITAN_GATEWAY_HOST: (val) => setNested(config, 'gateway.host', val),\n TITAN_LOG_LEVEL: (val) => setNested(config, 'logging.level', val),\n ANTHROPIC_API_KEY: (val) => setNested(config, 'providers.anthropic.apiKey', val),\n OPENAI_API_KEY: (val) => setNested(config, 'providers.openai.apiKey', val),\n GOOGLE_API_KEY: (val) => setNested(config, 'providers.google.apiKey', val),\n OLLAMA_BASE_URL: (val) => setNested(config, 'providers.ollama.baseUrl', val),\n DISCORD_TOKEN: (val) => setNested(config, 'channels.discord.token', val),\n TELEGRAM_TOKEN: (val) => setNested(config, 'channels.telegram.token', val),\n SLACK_TOKEN: (val) => setNested(config, 'channels.slack.token', val),\n GOOGLE_OAUTH_CLIENT_ID: (val) => setNested(config, 'oauth.google.clientId', val),\n GOOGLE_OAUTH_CLIENT_SECRET: (val) => setNested(config, 'oauth.google.clientSecret', val),\n OPENROUTER_API_KEY: (val) => setNested(config, 'providers.openrouter.apiKey', val),\n };\n\n // Cloud mode: auto-configure OpenRouter to point at SaaS gateway\n if (process.env.TITAN_CLOUD_MODE === 'true' && process.env.TITAN_CLOUD_API) {\n const cloudApi = process.env.TITAN_CLOUD_API;\n setNested(config, 'providers.openrouter.baseUrl', cloudApi + '/api/v1');\n logger.debug(COMPONENT, `Cloud mode: OpenRouter base URL set to ${cloudApi}/api/v1`);\n }\n\n for (const [envKey, setter] of Object.entries(envMap)) {\n const val = process.env[envKey];\n if (val) {\n setter(val);\n logger.debug(COMPONENT, `Applied env override: ${envKey}`);\n }\n }\n}\n\n/**\n * Recursively diff `raw` against the Zod-parsed `parsed` and return the set\n * of dot-notation paths that exist in raw but were stripped by the schema.\n *\n * Behaviour:\n * - Only walks plain objects (Records). Arrays and primitives are leaf\n * comparisons — if the raw value is an object at a key the schema parsed\n * as something else, we treat the whole subtree as dropped at that path.\n * - Recurses into objects that survive parsing so we catch keys deep inside\n * untyped/permissive subtrees (e.g. `providers.<name>.unknownField`).\n * - Cap recursion depth to defend against pathological self-referential\n * configs; 8 levels covers every legitimate path in TitanConfigSchema.\n */\nfunction findDroppedKeys(\n raw: unknown,\n parsed: unknown,\n pathPrefix: string,\n depth: number = 0,\n): string[] {\n if (depth > 8) return [];\n if (!isPlainObject(raw)) return [];\n const dropped: string[] = [];\n for (const key of Object.keys(raw)) {\n const fullPath = pathPrefix ? `${pathPrefix}.${key}` : key;\n const rawVal = raw[key];\n const parsedVal = isPlainObject(parsed) ? (parsed as Record<string, unknown>)[key] : undefined;\n\n if (parsedVal === undefined) {\n // Skip explicit `undefined` in raw (treats `key: undefined` as not-set\n // rather than a dropped key — JSON config files can't express this\n // anyway, but in-memory configs sometimes do via env merging).\n if (rawVal !== undefined) dropped.push(fullPath);\n continue;\n }\n\n // Recurse into matching subtrees so nested unknown keys are also surfaced.\n if (isPlainObject(rawVal) && isPlainObject(parsedVal)) {\n dropped.push(...findDroppedKeys(rawVal, parsedVal, fullPath, depth + 1));\n }\n }\n return dropped;\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n\n/** Set a nested property by dot-notation path */\nfunction setNested(obj: Record<string, unknown>, path: string, value: unknown): void {\n const parts = path.split('.');\n let current: Record<string, unknown> = obj;\n for (let i = 0; i < parts.length - 1; i++) {\n if (!current[parts[i]] || typeof current[parts[i]] !== 'object') {\n current[parts[i]] = {};\n }\n current = current[parts[i]] as Record<string, unknown>;\n }\n current[parts[parts.length - 1]] = value;\n}\n"],"mappings":"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","names":[]}
@@ -504,6 +504,13 @@ const TitanConfigSchema = z.object({
504
504
  selfMod: SelfModConfigSchema.default({}),
505
505
  homelab: HomelabConfigSchema.default({}),
506
506
  providers: z.object({
507
+ /** v5.4.1: Per-model output-token caps override. Keys are provider/model IDs.
508
+ * Values override the built-in static table + family heuristics. */
509
+ modelCapabilities: z.record(z.string(), z.object({
510
+ contextWindow: z.number(),
511
+ maxOutput: z.number(),
512
+ supportsThinking: z.boolean().optional()
513
+ })).optional(),
507
514
  anthropic: ProviderConfigSchema.default({}),
508
515
  openai: ProviderConfigSchema.default({}),
509
516
  google: ProviderConfigSchema.default({}),