maestro/scripts/setup-lib.mjs
oss-sync 42031b18b0
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (8447335)
2026-06-05 06:25:26 +00:00

146 lines
6.6 KiB
JavaScript

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