@@ -91,6 +91,7 @@ import {
9191 openSync , closeSync ,
9292} from "fs" ;
9393import { resolve , join , basename } from "path" ;
94+ import { networkInterfaces } from "os" ;
9495import { createInterface } from "readline" ;
9596import { execFileSync , execSync } from "child_process" ;
9697import { 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