// Pure, dependency-free helpers for the setup wizard. Tested by setup-lib.test.mjs. export const ALL_ROLES = ['auto', 'fast', 'quality', 'title', 'reflection']; export const CONNECTION_TYPES = ['direct', 'aao_gateway']; // Normalize a user-entered LLM endpoint URL. // Returns { endpoint, base } or { error }. // endpoint = scheme://host[:port]/path with no trailing slash (used for {endpoint}/models) // base = endpoint with a trailing /v1 stripped (used for {base}/api/tags), mirroring src/worker.ts export function parseEndpoint(input) { const raw = String(input ?? '').trim(); if (!raw) return { error: 'endpoint is required' }; const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`; let url; try { url = new URL(withScheme); } catch { return { error: `invalid URL: ${raw}` }; } if (url.protocol !== 'http:' && url.protocol !== 'https:') { return { error: `unsupported scheme: ${url.protocol.replace(':', '')}` }; } if (!url.hostname) return { error: 'missing host' }; const endpoint = url.toString().replace(/\/+$/, ''); const base = endpoint.replace(/\/v1$/, ''); return { endpoint, base }; } // Build the single llm.workers[0] entry the wizard writes. export function buildWorkerEntry({ connectionType, endpoint, model, apiKey }) { const entry = { id: connectionType === 'aao_gateway' ? 'gateway' : 'local-llm', connection_type: connectionType, endpoint, model, roles: [...ALL_ROLES], max_concurrency: 1, enabled: true, vlm: false, }; if (connectionType === 'aao_gateway') entry.api_key = apiKey; return entry; } // Build the single worker entry in **camelCase v2 shape** that // ConfigManager.updateConfig expects (it snake-cases on save). The CLI writes // raw YAML via buildWorkerEntry (snake_case); the browser wizard instead PUTs a // JSON config object through updateConfig, which requires camelCase keys. // Passing buildWorkerEntry's snake_case output to updateConfig double-converts // and corrupts keys (Codex review P1 #1) — so the wizard MUST use this builder. export function buildLlmWorkerCamel({ connectionType, endpoint, model, apiKey }) { const entry = { id: connectionType === 'aao_gateway' ? 'gateway' : 'local-llm', connectionType, endpoint, model, roles: [...ALL_ROLES], maxConcurrency: 1, enabled: true, vlm: false, }; if (connectionType === 'aao_gateway') entry.apiKey = apiKey; return entry; } // Detect whether the runtime has a usable LLM connection. Operate on the // RESOLVED worker list (provider.workers from ConfigManager.getConfig(), after // loadConfig has applied OLLAMA_* env overrides). A fresh install's synthetic // default worker carries an endpoint but an EMPTY model (config-normalize.ts // EMPTY_MODEL=''), so requiring a non-empty model naturally excludes it. // An env override (OLLAMA_MODEL) fills the model, so it counts as configured. // (Codex review P1 #2.) // Execution roles a worker must cover for normal task running. A worker that // only serves e.g. 'title' or 'reflection' can't run user tasks, so the wizard // must NOT consider the install configured (Codex P2 #6). Empty/missing roles // mean "all roles" at runtime (src/worker.ts defaults to auto/fast/quality), so // they count as covering execution. const EXECUTION_ROLES = ['auto', 'fast', 'quality']; function workerCoversExecution(w) { const roles = w.roles; if (!Array.isArray(roles) || roles.length === 0) return true; return roles.some((r) => EXECUTION_ROLES.includes(r)); } export function isLlmConfigured(workers) { const list = Array.isArray(workers) ? workers : []; return list.some( (w) => w && w.enabled !== false && typeof w.endpoint === 'string' && w.endpoint.trim() !== '' && typeof w.model === 'string' && w.model.trim() !== '' && workerCoversExecution(w), ); } // Quote a scalar when a YAML plain scalar would be ambiguous or invalid. function yamlScalar(v) { if (typeof v === 'boolean' || typeof v === 'number') return String(v); const s = String(v); const needsQuote = s === '' || /[:#\[\]{}&*!|>'"%@`,]/.test(s) || // structural / indicator chars (note: ':' covers host:port, model:tag) /^[\s]|[\s]$/.test(s) || // leading/trailing space /^[-?]/.test(s) || // leading '-' or '?' /^(true|false|null|yes|no|on|off|~)$/i.test(s) || /^[0-9.+-]/.test(s) || // could be parsed as a number /[\n\r\t]/.test(s); // control chars that would break multiline YAML if (!needsQuote) return s; return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`; } // Render a minimal config.yaml (config_version + one llm.workers entry). // We do NOT edit config.yaml.example (fragile + contradicts the minimal goal). export function renderConfigYaml(worker) { const L = []; L.push('# Generated by `npm run setup`. Full reference: config.yaml.example'); L.push('config_version: 2'); L.push(''); L.push('llm:'); L.push(' workers:'); L.push(` - id: ${yamlScalar(worker.id)}`); L.push(` connection_type: ${yamlScalar(worker.connection_type)}`); L.push(` endpoint: ${yamlScalar(worker.endpoint)}`); L.push(` model: ${yamlScalar(worker.model)}`); if (worker.api_key !== undefined) L.push(` api_key: ${yamlScalar(worker.api_key)}`); L.push(` roles: [${worker.roles.map(yamlScalar).join(', ')}]`); L.push(` max_concurrency: ${worker.max_concurrency}`); L.push(` enabled: ${worker.enabled}`); L.push(` vlm: ${worker.vlm}`); L.push(''); return L.join('\n'); } // Upsert only PORT in a .env file; never write OLLAMA_* (those hit the legacy // provider.* env override path in src/config.ts and would conflict with llm.workers). export function renderDotenv(existingText, { port }) { const line = `PORT=${port}`; const text = String(existingText ?? ''); if (!text.trim()) return `${line}\n`; const kept = text.replace(/\n$/, '').split('\n').filter((l) => !/^\s*PORT\s*=/.test(l)); kept.push(line); return `${kept.join('\n')}\n`; } // Resolve + validate non-interactive answers from env. Single endpoint env for both types. export function parseAnswersFromEnv(env) { const connectionType = env.SETUP_CONNECTION_TYPE || 'direct'; if (!CONNECTION_TYPES.includes(connectionType)) { return { error: `SETUP_CONNECTION_TYPE must be one of ${CONNECTION_TYPES.join(', ')}` }; } const ep = parseEndpoint(env.SETUP_LLM_ENDPOINT || ''); if (ep.error) return { error: `SETUP_LLM_ENDPOINT: ${ep.error}` }; const model = String(env.SETUP_MODEL || '').trim(); if (!model) return { error: 'SETUP_MODEL is required in --yes mode' }; let apiKey; if (connectionType === 'aao_gateway') { apiKey = String(env.SETUP_LLM_API_KEY || '').trim(); if (!apiKey) return { error: 'SETUP_LLM_API_KEY is required for aao_gateway' }; } const portRaw = env.PORT || '9876'; const port = Number(portRaw); if (!Number.isInteger(port) || port < 1 || port > 65535) { return { error: `PORT must be an integer 1-65535 (got ${portRaw})` }; } return { answers: { connectionType, endpoint: ep.endpoint, base: ep.base, model, apiKey, port } }; } // Probe model availability the same way the runtime worker does (src/worker.ts): // 1) GET {base}/api/tags -> Ollama { models: [{ name }] } // 2) GET {endpoint}/models -> OpenAI { data: [{ id }] } // Bearer is forwarded when apiKey is set (AAO gateway requires it). export async function probeModels({ endpoint, base, apiKey, fetchImpl = fetch, timeoutMs = 5000 }) { const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; const tryFetch = async (url, parse) => { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), timeoutMs); try { const r = await fetchImpl(url, { headers, signal: ac.signal }); if (!r || !r.ok) return { ok: false, error: `HTTP ${r ? r.status : 'no-response'} from ${url}` }; let data; try { data = await r.json(); } catch { return { ok: false, error: `non-JSON response from ${url}` }; } return { ok: true, models: parse(data) }; } catch (e) { const why = e && e.name === 'AbortError' ? 'timeout' : (e && e.message) || 'fetch failed'; return { ok: false, error: `${url}: ${why}` }; } finally { clearTimeout(timer); } }; const ollama = await tryFetch(`${base}/api/tags`, (d) => (d && Array.isArray(d.models) ? d.models.map((m) => m && m.name).filter(Boolean) : [])); if (ollama.ok) return { ok: true, models: ollama.models }; const openai = await tryFetch(`${endpoint}/models`, (d) => (d && Array.isArray(d.data) ? d.data.map((m) => m && m.id).filter(Boolean) : [])); if (openai.ok) return { ok: true, models: openai.models }; return { ok: false, models: [], error: openai.error || ollama.error }; }