// 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; } // 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 }; }