// setup-local-model.mjs — set up a local or self-hosted model from the command line. // // For offline and corporate-network situations: ask a few questions, write // openclaw.json directly, then actually test that the model answers. Deliberately // does not depend on the dashboard, which is the thing that tends to be broken // when someone reaches for this tool. // // Two kinds of endpoint: // 1) Ollama on this machine (http://127.0.0.1:11434) // 2) Any OpenAI-compatible endpoint — a company server, a relay (URL + token) // // Only the model-related fields are merged in; gateway and other settings are // left alone, and the previous file is backed up first. // // Usage: node setup-local-model.mjs // // Input: a real terminal gets interactive readline; piped input (tests, scripts) // is read once and dequeued line by line. import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { createInterface } from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import http from 'node:http'; import https from 'node:https'; function makePrompter() { if (input.isTTY) { const rl = createInterface({ input, output }); return { ask: async (q, def) => { const a = (await rl.question(`${q}${def ? ` [${def}]` : ''}: `)).trim(); return a || def || ''; }, close: () => rl.close(), }; } let queued = []; try { queued = readFileSync(0, 'utf8').split(/\r?\n/); } catch {} let i = 0; return { ask: async (q, def) => { const raw = (queued[i++] ?? '').trim(); const val = raw || def || ''; output.write(`${q}${def ? ` [${def}]` : ''}: ${val}\n`); return val; }, close: () => {}, }; } const CHAT_TIMEOUT_MS = 30000; function line(s = '') { output.write(s + '\n'); } function withScheme(raw) { return /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`; } function hostOf(raw) { try { return new URL(withScheme(String(raw).trim())).hostname || null; } catch { return null; } } function applyNoProxyFor(baseUrl) { const host = hostOf(baseUrl); const existing = (process.env.NO_PROXY || process.env.no_proxy || '') .split(',') .map((s) => s.trim()) .filter(Boolean); const merged = Array.from(new Set([...existing, 'localhost', '127.0.0.1', '::1', ...(host ? [host] : [])])); process.env.NO_PROXY = merged.join(','); process.env.no_proxy = process.env.NO_PROXY; } async function chatTest(baseUrl, apiKey, modelId) { applyNoProxyFor(baseUrl); const url = withScheme(baseUrl).replace(/\/+$/, '') + '/chat/completions'; const started = Date.now(); try { const res = await requestText(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) }, body: JSON.stringify({ model: modelId, messages: [{ role: 'user', content: 'Reply with exactly: connection ok' }], max_tokens: 64, stream: false }), timeoutMs: CHAT_TIMEOUT_MS, }); const ms = Date.now() - started; const text = res.body; if (!res.ok) return { ok: false, status: res.status, body: text.slice(0, 300), ms }; let reply = ''; try { const j = JSON.parse(text); reply = j?.choices?.[0]?.message?.content ?? j?.choices?.[0]?.text ?? ''; } catch { reply = text.slice(0, 200); } return { ok: true, reply: String(reply).trim(), ms }; } catch (err) { const ms = Date.now() - started; if (err?.code === 'ETIMEDOUT') return { ok: false, error: `ETIMEDOUT(>${CHAT_TIMEOUT_MS / 1000}s)`, ms }; return { ok: false, error: `${err?.cause?.code || err?.code || err?.name || 'ERR'}: ${err?.message || ''}`, ms }; } } function requestText(rawUrl, { method = 'GET', headers = {}, body, timeoutMs }) { return new Promise((resolve, reject) => { const u = new URL(rawUrl); const client = u.protocol === 'https:' ? https : http; const req = client.request(u, { method, headers }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: text }); }); }); req.setTimeout(timeoutMs, () => { const err = new Error('request timed out'); err.code = 'ETIMEDOUT'; req.destroy(err); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } async function main() { const configPath = process.argv[2] || process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { line('Usage: node setup-local-model.mjs '); process.exitCode = 2; return; } line('========================================'); line(' U-Claw — set up a local or self-hosted model'); line('========================================'); line(''); const rl = makePrompter(); const ask = rl.ask; try { line('Which kind of model?'); line(' 1) Ollama running on this machine (http://127.0.0.1:11434)'); line(' 2) A self-hosted or company endpoint (any OpenAI-compatible URL + token)'); const kind = await ask('Enter 1 or 2', '1'); let providerKey, baseUrl, apiKey, modelId; if (kind === '2') { providerKey = 'newapi'; line(''); line('The endpoint usually looks like http://192.168.1.50:3000/v1 — most need the /v1 on the end.'); baseUrl = await ask('Endpoint URL', 'http://192.168.1.50:3000/v1'); apiKey = await ask('token / API Key', ''); modelId = await ask('Model ID — your admin will have given you this, e.g. deepseek-v3', ''); } else { providerKey = 'ollama'; line(''); baseUrl = await ask('Ollama URL — the default is right unless you changed it', 'http://127.0.0.1:11434/v1'); // Ollama's OpenAI-compatible endpoint lives at /v1; add it if missing if (!/\/v1\/?$/.test(baseUrl)) baseUrl = baseUrl.replace(/\/+$/, '') + '/v1'; apiKey = 'ollama'; // Ollama ignores auth; any placeholder works modelId = await ask('Model name — run `ollama list` to see yours, e.g. qwen2.5', 'qwen2.5'); } if (!baseUrl || !modelId) { line(''); line('No endpoint or model ID given — nothing was changed.'); process.exitCode = 2; return; } // Merge into the existing config so gateway and other settings survive; back up first let config = {}; if (existsSync(configPath)) { try { config = JSON.parse(readFileSync(configPath, 'utf8')); } catch { config = {}; } try { copyFileSync(configPath, configPath + '.bak'); } catch {} } else { try { mkdirSync(dirname(configPath), { recursive: true }); } catch {} } config.gateway ||= { mode: 'local', auth: { token: 'uclaw' } }; config.models ||= {}; config.models.mode = 'merge'; config.models.providers ||= {}; config.models.providers[providerKey] = { baseUrl, apiKey, api: 'openai-completions', models: [{ id: modelId, name: modelId }], }; config.agents ||= {}; config.agents.defaults ||= {}; config.agents.defaults.model ||= {}; config.agents.defaults.model.primary = `${providerKey}/${modelId}`; writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); line(''); line(`✓ Saved to ${configPath}`); line(` provider=${providerKey} baseUrl=${baseUrl} model=${modelId}`); if (existsSync(configPath + '.bak')) line(' Your previous settings were kept as openclaw.json.bak'); line(''); // Prove it actually works rather than just claiming it was saved line('Testing it — sending one message to the model…'); const r = await chatTest(baseUrl, apiKey, modelId); line(''); if (r.ok) { line(`✓ It works. The model replied in ${r.ms}ms: ${r.reply.slice(0, 120) || '(empty reply, but the request succeeded)'}`); line(''); line('You are set. Start U-Claw normally and it will use this model.'); } else if (r.status) { line(`✗ The server answered with HTTP ${r.status} in ${r.ms}ms: ${r.body}`); line(''); if (r.status === 401 || r.status === 403) line(' The network is fine but the token was rejected. Ollama ignores auth entirely; for a company endpoint, check the token with whoever issued it.'); else if (r.status === 404) line(' The network is fine but the path or model ID is wrong. Check whether the endpoint needs /v1 on the end, and that the model name matches exactly.'); else line(' The network is fine; the server itself errored. Send the text above to whoever runs it.'); } else { const host = (() => { try { return new URL(withScheme(baseUrl)).hostname; } catch { return baseUrl; } })(); line(`✗ Could not reach it: ${r.error} (${r.ms}ms)`); line(''); if (providerKey === 'ollama') { line(' Ollama is not answering. It is usually not running, or the model was never pulled. On this machine, run:'); line(' ollama serve starts it, skip if it already runs in the background'); line(` ollama pull ${modelId} downloads the model — do this while you still have internet`); line(' Then run this tool again.'); } else { line(' Either the address is wrong, the network cannot reach it, a firewall is blocking it, or the service is down.'); line(' Test it from this machine with:'); line(` ping ${host}`); line(' If that also fails, ask whoever runs the server to confirm the address, port and firewall rules.'); } } } finally { rl.close(); } } main();