/** * Auto-configuration and self-healing for LiteLLM proxy. * * Runs on every startup to ensure xcsh has a working configuration: * * 1. Missing config → auto-generate from LITELLM env vars * 2. Corrupt config → backup and regenerate * 3. Empty config → regenerate * 4. Drifted config → detect and auto-fix (URL changed in env) * 5. Incomplete config → fill missing fields * 6. Outdated config → detect missing/old configVersion, backup and regenerate * * All fixes create .bak backups before overwriting. * All operations are idempotent and safe to run repeatedly. */ import * as fs from "node:fs"; import * as path from "node:path"; import { $env, isEnoent, logger, readProviderFromModelsYml } from "@f5-sales-demo/pi-utils"; import { hardenAgentConfigFileSync, writeAgentConfigFileSync } from "./agent-config-file"; import { DEFAULT_MODEL_ROLE } from "./settings-schema"; /** Current config schema version. Bump when the generated format changes. */ export const CURRENT_CONFIG_VERSION = 4; const LITELLM_CONFIG_DIR_MODE = 0o700; const LITELLM_MODELS_FILE_MODE = 0o600; // --------------------------------------------------------------------------- // Detection // --------------------------------------------------------------------------- /** Check if LiteLLM env vars are set and usable. */ export function hasLiteLLMEnv(): boolean { const baseUrl = $env.LITELLM_BASE_URL?.trim(); const apiKey = $env.LITELLM_API_KEY?.trim(); return !!baseUrl && !!apiKey && (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")); } /** Get the LiteLLM base URL from env, normalized (no trailing slash). */ function getLiteLLMBaseUrl(): string | undefined { const raw = $env.LITELLM_BASE_URL?.trim(); if (!raw) return undefined; return raw.replace(/\/+$/, ""); } // --------------------------------------------------------------------------- // Generation // --------------------------------------------------------------------------- export interface GenerateModelsYmlOptions { /** API base path for the litellm provider (e.g. "/v1" or "/api/v1"). Defaults to "/v1". */ apiBasePath?: string; /** When provided, write this literal API key value instead of the LITELLM_API_KEY env var reference. */ apiKeyLiteral?: string; } /** Generate models.yml content for LiteLLM proxy. */ export function generateModelsYml(baseUrl: string, options?: GenerateModelsYmlOptions): string { const apiBase = options?.apiBasePath ?? "/v1"; // When a literal API key is provided, double-quote it to handle YAML-significant // characters (: ! # { } [ ] ' " etc.). Escape backslashes and double quotes first. const apiKeyValue = options?.apiKeyLiteral ? `"${options.apiKeyLiteral.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : "LITELLM_API_KEY"; const keyComment = options?.apiKeyLiteral ? "# API key stored as literal value" : "# API key resolved from LITELLM_API_KEY env var at runtime"; const lines = [ "# Auto-generated by xcsh for LiteLLM proxy", keyComment, `configVersion: ${CURRENT_CONFIG_VERSION}`, "providers:", " anthropic:", ` baseUrl: "${baseUrl}/anthropic"`, ` apiKey: ${apiKeyValue}`, " litellm:", ` baseUrl: "${baseUrl}${apiBase}"`, ` apiKey: ${apiKeyValue}`, " api: openai-completions", " discovery:", " type: openai-compat", " modelOverrides:", " gpt-5.6-sol:", " reasoning: true", " input:", " - text", " - image", " thinking:", " mode: effort", " minLevel: low", " maxLevel: xhigh", " contextWindow: 1050000", " maxTokens: 128000", " compat:", " supportsTemperature: false", ]; lines.push(""); return lines.join("\n"); } /** Persist models.yml with owner-only permissions because it may contain a literal proxy credential. */ export async function writeLiteLLMModelsYml(filePath: string, content: string): Promise { await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: LITELLM_CONFIG_DIR_MODE }); try { await fs.promises.chmod(filePath, LITELLM_MODELS_FILE_MODE); } catch (error) { if (!isEnoent(error)) throw error; } await fs.promises.writeFile(filePath, content, { encoding: "utf-8", mode: LITELLM_MODELS_FILE_MODE }); } export interface LiteLLMConfig { baseUrl: string; apiKey: string; apiBasePath?: string; } /** * Read existing models.yml and extract the anthropic provider's base URL and API key. * * - baseUrl: strips `/anthropic` suffix to recover the root proxy URL * - apiKey: env var name → process.env lookup; shell-backed → fall back to env; * otherwise literal * - Falls back to getLiteLLMBaseUrl() and $env.LITELLM_API_KEY when the file is * missing or the anthropic block is incomplete */ export function readLiteLLMConfig(modelsPath: string): LiteLLMConfig | undefined { if (!fs.existsSync(modelsPath)) return undefined; const block = readProviderFromModelsYml("anthropic", modelsPath); const litellmBlock = readProviderFromModelsYml("litellm", modelsPath); const resolvedBaseUrl = block?.baseUrl ? block.baseUrl.replace(/\/anthropic\/?$/, "") : getLiteLLMBaseUrl(); let resolvedApiKey: string | undefined; const apiKey = block?.apiKey; if (!apiKey) { resolvedApiKey = $env.LITELLM_API_KEY; } else if (apiKey.kind === "envVar") { resolvedApiKey = process.env[apiKey.name] ?? $env.LITELLM_API_KEY; } else if (apiKey.kind === "shellSecret") { resolvedApiKey = $env.LITELLM_API_KEY; } else { resolvedApiKey = apiKey.value; } if (!resolvedBaseUrl || !resolvedApiKey) return undefined; const apiBasePath = litellmBlock?.baseUrl?.startsWith(resolvedBaseUrl) ? litellmBlock.baseUrl.slice(resolvedBaseUrl.length) || undefined : undefined; return { baseUrl: resolvedBaseUrl, apiKey: resolvedApiKey, apiBasePath }; } /** * The default model role is baked into the binary (settings-schema `modelRoles` * default) so a fresh install needs NO config.yml. Re-exported here for the * healer and tests; there is a single source of truth in settings-schema. */ export const DEFAULT_MODEL_ROLE_VALUE = DEFAULT_MODEL_ROLE; /** * Generate config.yml for the LiteLLM proxy. * * The default model role is NOT written here — it ships in the binary * (settings-schema), so we never persist a model id that can go stale (the * failure mode behind the invalid-model / catalog-walk bugs). This only carries * settings that differ from the built-in schema defaults. */ export function generateConfigYml(): string { return [ "# Auto-generated by xcsh for LiteLLM proxy", "# The default model role ships in the binary — none is written here.", "providers:", " image: openai", " webSearch: anthropic", "", ].join("\n"); } /** * Provider prefixes that are never valid as a persisted runtime default. * `bench-instant` is the TTFT benchmark stand-in provider — it only registers * when XCSH_BENCH_EXTENSION is set, so a `bench-instant/*` default in a normal * worker is unresolvable and drops xcsh into the "first available model" * fallback (which can pick a model the F5 proxy can't serve). */ const UNRESOLVABLE_DEFAULT_PROVIDER_PREFIXES = ["bench-instant/"]; /** * Repair a config.yml whose `modelRoles.default` can never resolve at runtime * (e.g. `bench-instant/bench-instant` leaked from a benchmark run) by rewriting * it to the binary default. Left unhealed, such a default triggers the * catalog-wide fallback that surfaced as the AWS-SSO / invalid-model errors. * * A config.yml with NO `modelRoles:` needs no healing — the binary provides the * default. We deliberately do not add one, to avoid persisting a stale id. */ export function healConfigYmlModelRoles(configPath: string): void { try { hardenAgentConfigFileSync(configPath); const content = fs.readFileSync(configPath, "utf-8"); if (!content.includes("modelRoles:")) return; // binary default applies const defaultLine = /^(\s*)default:\s*(\S+)\s*$/m; const match = content.match(defaultLine); if (match) { const [, indent, value] = match; if (UNRESOLVABLE_DEFAULT_PROVIDER_PREFIXES.some(prefix => value.startsWith(prefix))) { const healed = content.replace(defaultLine, `${indent}default: ${DEFAULT_MODEL_ROLE_VALUE}`); writeAgentConfigFileSync(configPath, healed); logger.debug("Healed config.yml: replaced unresolvable default modelRole", { configPath, previous: value, replacement: DEFAULT_MODEL_ROLE_VALUE, }); } } } catch { // Best-effort — don't block startup } } // --------------------------------------------------------------------------- // Backup // --------------------------------------------------------------------------- /** * Extract a double-quoted literal API key from an existing models.yml file. * Checks the anthropic and litellm provider blocks; returns undefined if * neither has a literal (e.g., both use env var references) or the file is * missing. */ export function readApiKeyLiteral(modelsPath: string): string | undefined { for (const name of ["anthropic", "litellm"]) { const block = readProviderFromModelsYml(name, modelsPath); if (block?.apiKey?.kind === "literal" && block.apiKey.wasQuoted) { return block.apiKey.value; } } return undefined; } /** Create an owner-only .bak backup of models.yml. Returns true if backed up. */ function backupModelsConfigIfExists(filePath: string): boolean { const backupPath = `${filePath}.bak`; try { return safeWriteModelsConfig(backupPath, fs.readFileSync(filePath)); } catch (err) { if (!isEnoent(err)) logger.warn("Failed to create backup", { filePath, err }); } return false; } /** Safely write a file, creating parent directories. */ function safeWrite(filePath: string, content: string): boolean { try { writeAgentConfigFileSync(filePath, content); return true; } catch (err) { logger.warn("Failed to write config file", { filePath, err }); return false; } } /** Write models.yml content without ever exposing it through group/world-readable permissions. */ function safeWriteModelsConfig(filePath: string, content: string | Uint8Array): boolean { try { fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: LITELLM_CONFIG_DIR_MODE }); try { fs.chmodSync(filePath, LITELLM_MODELS_FILE_MODE); } catch (err) { if (!isEnoent(err)) throw err; } fs.writeFileSync(filePath, content, { encoding: "utf-8", mode: LITELLM_MODELS_FILE_MODE }); return true; } catch (err) { logger.warn("Failed to write LiteLLM models config", { filePath, err }); return false; } } /** Remove the model cache database so discovery re-runs fresh. */ function clearModelCache(modelsPath: string): void { const cacheDbPath = path.join(path.dirname(modelsPath), "models.db"); try { if (fs.existsSync(cacheDbPath)) { fs.unlinkSync(cacheDbPath); logger.debug("Cleared stale model cache", { cacheDbPath }); } // Also remove WAL/SHM files if present for (const suffix of ["-wal", "-shm"]) { const walPath = `${cacheDbPath}${suffix}`; if (fs.existsSync(walPath)) { fs.unlinkSync(walPath); } } } catch { // Best-effort — don't block config repair } } // --------------------------------------------------------------------------- // Proxy connection probing // --------------------------------------------------------------------------- export interface ProbeResult { reachable: boolean; models: string[]; error?: string; /** The API base path that worked (e.g. "/v1" or "/api/v1"). */ apiBasePath?: string; } /** Candidate OpenAI-compatible API base paths, tried in order. */ const API_BASE_PATHS = ["/v1", "/api", "/api/v1"]; /** * A route probe deliberately omits the required model and messages. OpenAI-compatible * servers reject that payload before inference with 400 or 422. A 2xx response is also * accepted for permissive proxies; 404/405 and server/auth failures reject the route. */ function acceptsChatCompletionPost(status: number): boolean { return (status >= 200 && status < 300) || status === 400 || status === 422; } /** * Probe a LiteLLM proxy to validate connectivity and discover available models. * * A models GET alone is not enough: Open WebUI also exposes management endpoints such * as /api/v1/models that do not share an inference route. Each candidate must return an * OpenAI-shaped model catalog and accept POST at the matching /chat/completions path. * The POST carries an empty JSON object, so validation happens before model inference. * * Returns the list of model IDs on success, or an error on failure. * Uses a 3-second timeout per endpoint to avoid blocking startup. */ export async function probeLiteLLMConnection( baseUrl: string, apiKey: string, options?: { signal?: AbortSignal; fetch?: typeof globalThis.fetch }, ): Promise { const fetchImpl = options?.fetch ?? globalThis.fetch; const normalizedUrl = baseUrl.replace(/\/+$/, ""); let lastError = ""; for (const apiBasePath of API_BASE_PATHS) { const modelsUrl = `${normalizedUrl}${apiBasePath}/models`; let response: Response; try { response = await fetchImpl(modelsUrl, { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}`, }, signal: options?.signal ?? AbortSignal.timeout(3000), }); } catch (err) { lastError = err instanceof Error ? err.message : String(err); continue; } if (!response.ok) { lastError = `HTTP ${response.status} ${response.statusText} from ${modelsUrl}`; continue; } let payload: unknown; try { payload = await response.json(); } catch { lastError = `Non-JSON response from ${modelsUrl}`; continue; } // OpenAI-compatible /v1/models returns { data: [{ id: "model-name", ... }] } const models: string[] = []; if ( payload && typeof payload === "object" && "data" in payload && Array.isArray((payload as { data: unknown }).data) ) { for (const entry of (payload as { data: Array<{ id?: string }> }).data) { if (typeof entry.id === "string" && entry.id.length > 0) { models.push(entry.id); } } } if (models.length === 0) { lastError = `No models in response from ${modelsUrl}`; continue; } const chatUrl = `${normalizedUrl}${apiBasePath}/chat/completions`; let chatResponse: Response; try { chatResponse = await fetchImpl(chatUrl, { method: "POST", headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: "{}", signal: options?.signal ?? AbortSignal.timeout(3000), }); } catch (err) { lastError = err instanceof Error ? err.message : String(err); continue; } await chatResponse.body?.cancel(); if (!acceptsChatCompletionPost(chatResponse.status)) { lastError = `HTTP ${chatResponse.status} ${chatResponse.statusText} from ${chatUrl}`; continue; } return { reachable: true, models, apiBasePath }; } return { reachable: false, models: [], error: lastError }; } // --------------------------------------------------------------------------- // Auto-config on startup (handles missing file) // --------------------------------------------------------------------------- /** * Try to auto-generate models.yml and config.yml for LiteLLM. * Called when models.yml is not found and LITELLM env vars are set. * Returns true if config was generated. */ export function tryAutoConfigLiteLLM(modelsPath: string): boolean { if (!hasLiteLLMEnv()) return false; const baseUrl = getLiteLLMBaseUrl(); if (!baseUrl) return false; if (!safeWriteModelsConfig(modelsPath, generateModelsYml(baseUrl))) return false; logger.debug("Auto-configured LiteLLM proxy", { modelsPath, baseUrl }); // Write config.yml if it doesn't exist, or heal it if it's missing modelRoles const configPath = path.join(path.dirname(modelsPath), "config.yml"); if (!fs.existsSync(configPath)) { safeWrite(configPath, generateConfigYml()); logger.debug("Auto-generated default config", { configPath }); } else { healConfigYmlModelRoles(configPath); } return true; } // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- export interface ValidationResult { valid: boolean; errors: string[]; warnings: string[]; fixable: boolean; } /** * Validate existing models.yml against environment. * * Checks: * - File exists and is readable * - File is not empty or whitespace-only * - Contains expected providers section * - configVersion matches CURRENT_CONFIG_VERSION * - Anthropic baseUrl matches LITELLM_BASE_URL env var * - apiKey env var reference resolves to a set variable */ export function validateModelsConfig(modelsPath: string): ValidationResult { const result: ValidationResult = { valid: true, errors: [], warnings: [], fixable: false }; // Check file exists if (!fs.existsSync(modelsPath)) { result.valid = false; result.errors.push("models.yml not found"); result.fixable = hasLiteLLMEnv(); return result; } // Check file is readable let content: string; try { content = fs.readFileSync(modelsPath, "utf-8"); } catch { result.valid = false; result.errors.push("models.yml is not readable"); return result; } // Check not empty if (content.trim().length === 0) { result.valid = false; result.errors.push("models.yml is empty"); result.fixable = hasLiteLLMEnv(); return result; } // Check for providers section (basic structural check) if (!content.includes("providers:")) { result.valid = false; result.errors.push("models.yml missing 'providers:' section"); result.fixable = hasLiteLLMEnv(); return result; } // Check config version if (!content.includes(`configVersion: ${CURRENT_CONFIG_VERSION}`)) { result.warnings.push( `models.yml missing or outdated configVersion (expected ${CURRENT_CONFIG_VERSION}). Run 'xcsh setup litellm' to upgrade.`, ); result.fixable = true; } // Check env var alignment const envBaseUrl = getLiteLLMBaseUrl(); if (envBaseUrl) { const expectedAnthropicUrl = `${envBaseUrl}/anthropic`; if (!content.includes(expectedAnthropicUrl)) { result.warnings.push( `Anthropic baseUrl in models.yml does not match LITELLM_BASE_URL. Expected: ${expectedAnthropicUrl}`, ); result.fixable = true; } } // Check apiKey references a set env var if (content.includes("apiKey: LITELLM_API_KEY") && !$env.LITELLM_API_KEY) { result.warnings.push("models.yml references LITELLM_API_KEY but the env var is not set"); } return result; } // --------------------------------------------------------------------------- // Auto-fix (handles corrupt, drifted, incomplete configs) // --------------------------------------------------------------------------- export interface FixResult { fixed: boolean; changes: string[]; } /** * Auto-fix models.yml issues. * * Handles: * - Corrupt YAML → backup and regenerate * - Empty file → regenerate * - URL drift → backup and regenerate with new URL * - Missing providers section → regenerate * * Always creates .bak backup before overwriting. */ export function autoFixModelsConfig(modelsPath: string): FixResult { const existing = readLiteLLMConfig(modelsPath); const baseUrl = getLiteLLMBaseUrl() ?? existing?.baseUrl; const existingLiteralKey = readApiKeyLiteral(modelsPath); if (!baseUrl || (!hasLiteLLMEnv() && !existingLiteralKey)) { return { fixed: false, changes: ["Cannot fix: no usable LiteLLM base URL and API key in models.yml or the environment"], }; } backupModelsConfigIfExists(modelsPath); if ( !safeWriteModelsConfig( modelsPath, generateModelsYml(baseUrl, { ...(existing?.apiBasePath ? { apiBasePath: existing.apiBasePath } : {}), ...(existingLiteralKey ? { apiKeyLiteral: existingLiteralKey } : {}), }), ) ) { return { fixed: false, changes: [`Write failed: could not write to ${modelsPath}`] }; } // Clear stale model cache so discovery re-runs with the new config clearModelCache(modelsPath); logger.debug("Auto-fixed LiteLLM config", { modelsPath, baseUrl }); return { fixed: true, changes: [`Regenerated models.yml with baseUrl: ${baseUrl}/anthropic`] }; } // --------------------------------------------------------------------------- // Startup self-healing (called from ModelRegistry on every load) // --------------------------------------------------------------------------- /** * Comprehensive startup health check for models.yml. * * Called from ModelRegistry.#loadCustomModels() on every startup. * Handles all error conditions: * * - "not-found" → tryAutoConfigLiteLLM (generate from env) * - "error" (corrupt/invalid) → autoFixModelsConfig (backup + regenerate) * - "ok" but drifted → autoFixModelsConfig (backup + update URL) * - "ok" but outdated configVersion → autoFixModelsConfig (backup + upgrade) * * Returns true if any repair was performed (caller should invalidate cache and reload). */ export function startupHealthCheck( status: "ok" | "not-found" | "error", modelsPath: string, loadedProviders?: Record, ): boolean { // Case 1: No config file — generate from env if (status === "not-found") { return tryAutoConfigLiteLLM(modelsPath); } // Case 2: Config file exists but failed to parse — backup and regenerate if (status === "error") { if (!hasLiteLLMEnv()) return false; const fix = autoFixModelsConfig(modelsPath); return fix.fixed; } // Case 3: Config loaded OK — repair generated structure even when credentials are // stored only as literals in models.yml and no LiteLLM environment variables exist. if (status === "ok" && loadedProviders) { try { const content = fs.readFileSync(modelsPath, "utf-8"); const isAutoGenerated = content.includes("Auto-generated by xcsh") || content.includes("apiKey: LITELLM_API_KEY"); if ( isAutoGenerated && (!content.includes(`configVersion: ${CURRENT_CONFIG_VERSION}`) || !content.includes("type: openai-compat")) ) { logger.debug("Upgrading generated models.yml structure", { version: CURRENT_CONFIG_VERSION }); return autoFixModelsConfig(modelsPath).fixed; } } catch { // File read failed — skip structural checks, don't block startup. } // Environment credentials, when present, remain authoritative for URL drift. const envBaseUrl = getLiteLLMBaseUrl(); const anthropicConfig = loadedProviders.anthropic; const expectedUrl = envBaseUrl ? `${envBaseUrl}/anthropic` : undefined; if (expectedUrl && anthropicConfig?.baseUrl && anthropicConfig.baseUrl !== expectedUrl) { // Only auto-fix configs that were generated by xcsh. User-written configs // with a custom proxy URL must never be silently overwritten. // Recognize both env-var-ref configs and literal-key configs as auto-generated. let isAutoGenerated = false; try { const content = fs.readFileSync(modelsPath, "utf-8"); isAutoGenerated = content.includes("Auto-generated by xcsh") || content.includes("apiKey: LITELLM_API_KEY"); } catch { // File unreadable — skip, don't block startup } if (!isAutoGenerated) return false; logger.warn("LiteLLM config drift detected — auto-fixing", { configured: anthropicConfig.baseUrl, expected: expectedUrl, }); const fix = autoFixModelsConfig(modelsPath); return fix.fixed; } } // Always heal config.yml model roles (regardless of models.yml state) if (hasLiteLLMEnv()) { const configPath = path.join(path.dirname(modelsPath), "config.yml"); if (fs.existsSync(configPath)) { healConfigYmlModelRoles(configPath); } } return false; } // --------------------------------------------------------------------------- // Async probe-and-upgrade (called from ModelRegistry.refresh on first run) // --------------------------------------------------------------------------- /** * Probe the LiteLLM proxy and upgrade config with the correct API base path. * * This is an async operation that runs during the first ModelRegistry.refresh(). * It validates proxy connectivity and, if successful, ensures the config uses * the correct API base path (e.g. /api/v1 for Open WebUI deployments). * * Returns true if the config was upgraded (caller should reload). */ export async function probeAndUpgradeLiteLLMConfig( modelsPath: string, options?: { fetch?: typeof globalThis.fetch }, ): Promise { // Try env vars first; fall back to literal key stored in the config let baseUrl = getLiteLLMBaseUrl(); let apiKey = $env.LITELLM_API_KEY?.trim(); if (!baseUrl || !apiKey) { const existing = readLiteLLMConfig(modelsPath); if (existing) { baseUrl = baseUrl || existing.baseUrl; apiKey = apiKey || existing.apiKey; } } if (!baseUrl || !apiKey) return false; let content: string; try { content = fs.readFileSync(modelsPath, "utf-8"); } catch { // File doesn't exist or is unreadable — nothing to upgrade return false; } // Only upgrade configs that were auto-generated by xcsh. User-written configs // with custom proxy URLs must never be silently overwritten by LiteLLM probing. // Recognize both env-var-ref configs and literal-key configs as auto-generated. if (!content.includes("Auto-generated by xcsh") && !content.includes("apiKey: LITELLM_API_KEY")) { return false; } // Probe the proxy to find the working API base path const probe = await probeLiteLLMConnection(baseUrl, apiKey, { fetch: options?.fetch }); if (!probe.reachable) { logger.warn("LiteLLM proxy unreachable during upgrade probe — keeping existing config", { baseUrl, error: probe.error, }); return false; } if (probe.models.length === 0) { logger.warn("LiteLLM proxy returned no models — keeping existing config", { baseUrl }); return false; } // Check if the config already has the correct discovery base path const hasDiscovery = content.includes("type: openai-compat"); const correctBase = `${baseUrl}${probe.apiBasePath}`; if (hasDiscovery && content.includes(correctBase)) { return false; // Already correct } // Upgrade: backup and regenerate with correct base path, preserving literal keys const existingLiteralKey = readApiKeyLiteral(modelsPath); backupModelsConfigIfExists(modelsPath); const newContent = generateModelsYml(baseUrl, { apiBasePath: probe.apiBasePath, ...(existingLiteralKey ? { apiKeyLiteral: existingLiteralKey } : {}), }); if (!safeWriteModelsConfig(modelsPath, newContent)) { return false; } logger.debug("Upgraded LiteLLM config", { modelsPath, baseUrl, apiBasePath: probe.apiBasePath, models: probe.models.length, hadDiscovery: hasDiscovery, }); return true; } /** * Validate config against env and warn if drifted. * Informational only — does not fix. Use startupHealthCheck for auto-fix. */ export function warnIfConfigDrifted(providers: Record | undefined): void { if (!providers) return; const envBaseUrl = getLiteLLMBaseUrl(); if (!envBaseUrl) return; const anthropicConfig = providers.anthropic; if (!anthropicConfig?.baseUrl) return; const expectedUrl = `${envBaseUrl}/anthropic`; if (anthropicConfig.baseUrl !== expectedUrl) { logger.warn("LiteLLM config drift detected", { configured: anthropicConfig.baseUrl, expected: expectedUrl, hint: "Run 'xcsh setup litellm' to update", }); } }