Files
AirCoding/packages/llm/src/ModelConfigLoader.ts
AirCoding 7d3b2b4a4c fix(regression): repair 5 regressions from second round, close B10/B12/B15/B16
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>
2026-06-03 17:19:36 +08:00

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)
}