Round 2 regression fixes: - B10 (INV-2 outbox, CRITICAL): wiring.ts — switch durable events from eventBus.publish (live-only) to eventIngestor.ingest (persistent) for debug.record.created and memory.promoted. Add required RuntimeEvent fields (id, source, route). - B12 (Scheduler events, CRITICAL): Scheduler.ts — replace all 4 eventBus.publish calls with eventIngestor.ingest + registered event types (task.started/task.failed/agent.lost/agent.cancelled). Remove unregistered task.status.changed references. - B15 (duplicate ProjectionClient): remove orphan tui/src/ProjectionClient.ts (zero references, superseded by runtime/src/projection/ProjectionClient.ts re-exported via @aircoding/runtime barrel). - RuntimeApp: wire Scheduler→WorkerManager in constructor; document start() bootstrap→recover→hydrate→ready sequence (DD §22.2). - createRuntime: read project_id from .air/shared/project.json (DD §6.1 stable UUID), fallback to Date.now() only if not initialized. - B16 (api_key strict): ProviderManager.get_or_create_adapter now calls ModelConfigLoader.validate() before passing raw api_key to adapter. Also fix from R1 regression: - ArchitectureDesigner: replace broken additive-heuristic risk scoring (single runtime file→replan, large refactor→confirmation only) with change-scope classification (contracts→confirmation, breaking→escalate, large→replan, safe→silent_continue). Remove dead evaluate_risk(). - MainAgent test: update confirmation test from old state name AWAITING_CONFIRMATION to canonical CONFIRMING (B13 state machine fix). Test: 148/148 pass (regression + e2e + llm + toolchain-cpp). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
6.3 KiB
TypeScript
Executable File
216 lines
6.3 KiB
TypeScript
Executable File
/**
|
|
* 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<string, ModelConfig>
|
|
project?: Record<string, ModelConfig>
|
|
}
|
|
|
|
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<string, ModelConfig> {
|
|
const result: Record<string, ModelConfig> = {}
|
|
const lines = content.split('\n')
|
|
let current_key = ''
|
|
let current_config: Partial<ModelConfig> = {}
|
|
|
|
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)
|
|
} |