/** * ModelConfigLoader - Loads model configuration from YAML files * * Implements DD ยง12.2. * Loads global ~/.air/models.yaml + project config. * * @module packages/llm/src/ModelConfigLoader */ import { readFileSync, existsSync } from 'fs' import { resolve, join } from 'path' import { homedir } from 'os' export interface ModelConfig { provider: string model: string /** @deprecated Use auth_ref instead for security */ api_key?: string /** Reference to external credential store (e.g., env:ANTHROPIC_API_KEY) */ auth_ref?: string base_url?: string max_tokens?: number temperature?: number top_p?: number } export interface ModelConfigSet { global?: Record project?: Record } export interface ConfigLoadResult { ok: boolean configs?: ModelConfigSet error?: string } const DEFAULT_CONFIG_PATH = join(homedir(), '.air', 'models.yaml') export class ModelConfigLoader { private global_config_path: string private project_config_path?: string constructor(global_path?: string, project_path?: string) { this.global_config_path = global_path || DEFAULT_CONFIG_PATH this.project_config_path = project_path } /** * Load all model configurations. */ load(): ConfigLoadResult { const configs: ModelConfigSet = {} // Load global config if (existsSync(this.global_config_path)) { try { const content = readFileSync(this.global_config_path, 'utf-8') configs.global = this.parse_yaml(content) } catch (error) { return { ok: false, error: `Failed to load global config: ${error instanceof Error ? error.message : String(error)}` } } } // Load project config if specified if (this.project_config_path && existsSync(this.project_config_path)) { try { const content = readFileSync(this.project_config_path, 'utf-8') configs.project = this.parse_yaml(content) } catch (error) { return { ok: false, error: `Failed to load project config: ${error instanceof Error ? error.message : String(error)}` } } } return { ok: true, configs } } /** * Get config for a specific model by name. */ get_model(name: string): ModelConfig | undefined { const result = this.load() if (!result.ok || !result.configs) return undefined // Project config takes precedence over global if (result.configs.project?.[name]) { return result.configs.project[name] } return result.configs.global?.[name] } /** * Validate required fields in a model config. */ validate(config: ModelConfig): { valid: boolean; error?: string } { if (!config.provider) { return { valid: false, error: 'provider is required' } } if (!config.model) { return { valid: false, error: 'model is required' } } // Provider-specific validation - require auth_ref for security if (config.provider === 'anthropic') { if (config.api_key) { return { valid: false, error: 'Direct api_key is deprecated; use auth_ref instead (e.g., env:ANTHROPIC_API_KEY)' } } if (!config.auth_ref) { const env_key = this.resolve_auth_ref(config.auth_ref) if (!env_key || !process.env[env_key]) { return { valid: false, error: 'anthropic requires auth_ref (e.g., env:ANTHROPIC_API_KEY)' } } } } if (config.provider === 'openai' || config.provider === 'openai-compatible') { if (!config.api_key && !config.auth_ref && !process.env.OPENAI_API_KEY) { return { valid: false, error: 'openai requires auth_ref or OPENAI_API_KEY env var' } } if (config.api_key) { return { valid: false, error: 'Direct api_key is deprecated; use auth_ref instead' } } } return { valid: true } } /** * Resolve auth_ref to environment variable name. */ resolve_auth_ref(auth_ref?: string): string | null { if (!auth_ref) return null if (auth_ref.startsWith('env:')) { return auth_ref.slice(4) } return null } /** * Simple YAML parser for model configs. * In production, use a proper YAML library. */ private parse_yaml(content: string): Record { const result: Record = {} const lines = content.split('\n') let current_key = '' let current_config: Partial = {} for (const line of lines) { const trimmed = line.trim() // Skip comments and empty lines if (!trimmed || trimmed.startsWith('#')) continue // Check for top-level key (model name) if (trimmed.endsWith(':') && !trimmed.includes(' ')) { // Save previous config if (current_key && current_config.provider) { result[current_key] = current_config as ModelConfig } current_key = trimmed.slice(0, -1) current_config = {} continue } // Parse key: value pairs const colon_idx = trimmed.indexOf(':') if (colon_idx > 0) { const key = trimmed.slice(0, colon_idx).trim() const value = trimmed.slice(colon_idx + 1).trim() // Remove quotes from value const clean_value = value.replace(/^["']|["']$/g, '') switch (key) { case 'provider': current_config.provider = clean_value break case 'model': current_config.model = clean_value break case 'api_key': current_config.api_key = clean_value break case 'auth_ref': current_config.auth_ref = clean_value break case 'base_url': current_config.base_url = clean_value break case 'max_tokens': current_config.max_tokens = parseInt(clean_value, 10) break case 'temperature': current_config.temperature = parseFloat(clean_value) break case 'top_p': current_config.top_p = parseFloat(clean_value) break } } } // Save last config if (current_key && current_config.provider) { result[current_key] = current_config as ModelConfig } return result } } export function createModelConfigLoader(global_path?: string, project_path?: string): ModelConfigLoader { return new ModelConfigLoader(global_path, project_path) }