Skip to content

Commit 9570869

Browse files
authored
Merge pull request #12 from japer-technology/copilot/change-local-llm-option
Auto-detect local LM Studio on the LAN instead of prompting for an endpoint
2 parents 9c04413 + b233e94 commit 9570869

1 file changed

Lines changed: 116 additions & 27 deletions

File tree

.github-minimum-intelligence/lifecycle/local-chat.ts

Lines changed: 116 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
openSync, closeSync,
9292
} from "fs";
9393
import { resolve, join, basename } from "path";
94+
import { networkInterfaces } from "os";
9495
import { createInterface } from "readline";
9596
import { execFileSync, execSync } from "child_process";
9697
import { marked } from "marked";
@@ -1543,10 +1544,95 @@ function printPersistHints(keyName: string, valueHint = "your-key-here"): void {
15431544
console.log("");
15441545
}
15451546

1547+
// ─── Local LLM network discovery ──────────────────────────────────────────────
1548+
1549+
// Default port LM Studio exposes its OpenAI-compatible server on.
1550+
const LMSTUDIO_SCAN_PORT = 1234;
1551+
1552+
// A discovered OpenAI-compatible local LLM server.
1553+
type LocalLLMHit = { baseUrl: string; host: string; models: string[] };
1554+
1555+
/**
1556+
* Collect the distinct IPv4 /24 prefixes (e.g. "192.168.1.") for every
1557+
* non-internal IPv4 address bound to this host. Used to enumerate the LAN.
1558+
*/
1559+
function hostIPv4Prefixes(): string[] {
1560+
const prefixes: string[] = [];
1561+
const nets = networkInterfaces();
1562+
for (const name of Object.keys(nets)) {
1563+
for (const ni of nets[name] ?? []) {
1564+
// Node typings declare `family` as a string ("IPv4"); some runtimes
1565+
// report the number 4. Accept both, and skip loopback/internal NICs.
1566+
const fam = ni.family as unknown;
1567+
const isV4 = fam === "IPv4" || fam === 4;
1568+
if (!isV4 || ni.internal) continue;
1569+
const parts = ni.address.split(".");
1570+
if (parts.length !== 4) continue;
1571+
const prefix = `${parts[0]}.${parts[1]}.${parts[2]}.`;
1572+
if (!prefixes.includes(prefix)) prefixes.push(prefix);
1573+
}
1574+
}
1575+
return prefixes;
1576+
}
1577+
1578+
/**
1579+
* Probe a single host:port for an OpenAI-compatible server by requesting
1580+
* `/v1/models`. Returns a hit (with any advertised model ids) or null on
1581+
* timeout / connection-refused / non-OK response.
1582+
*/
1583+
async function probeOpenAIServer(
1584+
host: string,
1585+
port: number,
1586+
timeoutMs: number,
1587+
): Promise<LocalLLMHit | null> {
1588+
const baseUrl = `http://${host}:${port}/v1`;
1589+
const ctrl = new AbortController();
1590+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
1591+
try {
1592+
const res = await fetch(`${baseUrl}/models`, {
1593+
signal: ctrl.signal,
1594+
// Some servers reject unauthenticated /models; send a benign dummy key.
1595+
headers: { authorization: "Bearer local" },
1596+
});
1597+
if (!res.ok) return null;
1598+
const body = (await res.json()) as any;
1599+
const models = Array.isArray(body?.data)
1600+
? body.data.map((m: any) => String(m?.id ?? "")).filter(Boolean)
1601+
: [];
1602+
return { baseUrl, host, models };
1603+
} catch {
1604+
return null;
1605+
} finally {
1606+
clearTimeout(timer);
1607+
}
1608+
}
1609+
1610+
/**
1611+
* Scan the host's IPv4 /24 networks — all 256 addresses each, plus localhost —
1612+
* for an OpenAI-compatible LLM server answering over http:// on `port`.
1613+
* Probes are issued concurrently; returns every server that responded.
1614+
*/
1615+
async function scanForLocalLLM(
1616+
port = LMSTUDIO_SCAN_PORT,
1617+
timeoutMs = 600,
1618+
): Promise<LocalLLMHit[]> {
1619+
const targets: string[] = ["127.0.0.1"];
1620+
for (const prefix of hostIPv4Prefixes()) {
1621+
for (let host = 0; host < 256; host++) {
1622+
const addr = `${prefix}${host}`;
1623+
if (!targets.includes(addr)) targets.push(addr);
1624+
}
1625+
}
1626+
const results = await Promise.all(
1627+
targets.map((host) => probeOpenAIServer(host, port, timeoutMs)),
1628+
);
1629+
return results.filter((r): r is LocalLLMHit => r !== null);
1630+
}
1631+
15461632
/**
15471633
* Recover from a missing cloud API key without crashing. Offers four paths:
15481634
* 1. Paste the key now (session-scoped).
1549-
* 2. Switch to a local LLM (OpenAI-compatible endpoint).
1635+
* 2. Scan the LAN for a local LM Studio server (OpenAI-compatible endpoint).
15501636
* 3. Show persistence instructions and quit.
15511637
* 4. Quit.
15521638
* Returns the (possibly updated) runtime config to use, or null to quit.
@@ -1562,7 +1648,7 @@ async function guideMissingApiKey(cfg: RuntimeCfg): Promise<RuntimeCfg | null> {
15621648
console.log("");
15631649
console.log(" " + c.bold("How would you like to continue?"));
15641650
console.log(" " + c.cyan("[1]") + " Paste your API key now " + c.dim("(used for this session only)"));
1565-
console.log(" " + c.cyan("[2]") + " Use a local LLM instead " + c.dim("(LM Studio, Ollama, vLLM…)"));
1651+
console.log(" " + c.cyan("[2]") + " Scan for local LM Studio " + c.dim("(auto-detected on your LAN)"));
15661652
console.log(" " + c.cyan("[3]") + " Show how to set the env var permanently, then quit");
15671653
console.log(" " + c.cyan("[q]") + " Quit");
15681654
console.log("");
@@ -1587,37 +1673,40 @@ async function guideMissingApiKey(cfg: RuntimeCfg): Promise<RuntimeCfg | null> {
15871673

15881674
if (choice === "2") {
15891675
console.log("");
1590-
console.log(" " + c.bold("Local LLM setup"));
1591-
console.log(" " + c.dim("Pick the local server you are running. They all speak the"));
1592-
console.log(" " + c.dim("OpenAI-compatible Chat Completions API, so pi talks to them"));
1593-
console.log(" " + c.dim("through its 'openai' provider client (you'll see that in"));
1594-
console.log(" " + c.dim("pi's diagnostics) but the launcher will label things by brand."));
1676+
console.log(" " + c.bold("Scanning your LAN for a local LM Studio server…"));
1677+
console.log(" " + c.dim("Probing this host's IPv4 /24 (all 256 addresses) plus"));
1678+
console.log(" " + c.dim(`localhost for an OpenAI-compatible server on port ${LMSTUDIO_SCAN_PORT}.`));
1679+
console.log(" " + c.dim("LM Studio speaks the OpenAI-compatible Chat Completions API,"));
1680+
console.log(" " + c.dim("so pi talks to it through its 'openai' provider client (you'll"));
1681+
console.log(" " + c.dim("see that in pi's diagnostics) labelled here as 'lmstudio'."));
15951682
console.log("");
1596-
console.log(" " + c.cyan("[a]") + " LM Studio " + c.gray("http://localhost:1234/v1"));
1597-
console.log(" " + c.cyan("[b]") + " Ollama " + c.gray("http://localhost:11434/v1"));
1598-
console.log(" " + c.cyan("[c]") + " vLLM " + c.gray("http://localhost:8000/v1"));
1599-
console.log(" " + c.cyan("[d]") + " Other openai-compatible endpoint");
1600-
console.log("");
1601-
let brand: "lmstudio" | "ollama" | "vllm" | "openai" = "lmstudio";
1602-
while (true) {
1603-
const b = (await promptLine(" Server [a/b/c/d]: ")).trim().toLowerCase();
1604-
if (b === "" || b === "a" || b === "lmstudio" || b === "lm-studio") { brand = "lmstudio"; break; }
1605-
if (b === "b" || b === "ollama") { brand = "ollama"; break; }
1606-
if (b === "c" || b === "vllm") { brand = "vllm"; break; }
1607-
if (b === "d" || b === "other" || b === "openai") { brand = "openai"; break; }
1608-
say.warn(`Unrecognised choice: "${b}"`, "Pick a, b, c, or d.");
1683+
1684+
const hits = await scanForLocalLLM(LMSTUDIO_SCAN_PORT);
1685+
if (hits.length === 0) {
1686+
say.warn(
1687+
"No local LM Studio server found on your network.",
1688+
`Make sure LM Studio is running with its local server enabled on ` +
1689+
`port ${LMSTUDIO_SCAN_PORT}, then pick this option again or choose another.`,
1690+
);
1691+
continue;
16091692
}
1610-
const defaults = LOCAL_BRAND_DEFAULTS[brand] ?? { label: "openai-compatible", baseUrl: "http://localhost:1234/v1" };
1611-
const url = (await promptLine(` Base URL [${defaults.baseUrl}]: `)).trim() || defaults.baseUrl;
1612-
const modelDefault = cfg.model || "local-model";
1613-
const newModel = (await promptLine(` Model id [${modelDefault}]: `)).trim() || modelDefault;
1693+
1694+
const hit = hits[0];
1695+
const url = hit.baseUrl;
1696+
const modelDefault = hit.models[0] || cfg.model || "local-model";
16141697
process.env.LOCAL_LLM_BASE_URL = url;
16151698
process.env.OPENAI_BASE_URL = url;
16161699
process.env.OPENAI_API_KEY = "local";
1617-
say.ok(`${defaults.label} endpoint set: ${url}`);
1618-
say.hint(`Provider label: ${brand} (pi sees --provider openai under the hood; --api-key local is sent on every turn so it won't complain about missing keys).`);
1700+
say.ok(
1701+
`Found LM Studio at ${url}` +
1702+
(hits.length > 1 ? ` ${c.dim(`(+${hits.length - 1} more on the LAN)`)}` : ""),
1703+
);
1704+
say.hint(
1705+
`Provider label: lmstudio (pi sees --provider openai under the hood; ` +
1706+
`--api-key local is sent on every turn so it won't complain about missing keys).`,
1707+
);
16191708
console.log("");
1620-
return { provider: brand, model: newModel, thinking: cfg.thinking };
1709+
return { provider: "lmstudio", model: modelDefault, thinking: cfg.thinking };
16211710
}
16221711

16231712
if (choice === "3") {

0 commit comments

Comments
 (0)