@@ -112,6 +112,12 @@ const piSettingsPath = resolve(minimumIntelligenceDir, ".pi", "settings.json");
112112const memoryLogPath = resolve ( minimumIntelligenceDir , "memory.log" ) ;
113113const lastRunRawPath = resolve ( stateDir , "local-last-run.jsonl" ) ;
114114
115+ // Dedicated pi agent dir used only in local mode. Pointing PI_CODING_AGENT_DIR
116+ // here makes pi read our generated models.json (a custom OpenAI-compatible
117+ // provider) without disturbing the user's global ~/.pi/agent config.
118+ const localAgentDir = resolve ( stateDir , "pi-agent" ) ;
119+ const localModelsPath = resolve ( localAgentDir , "models.json" ) ;
120+
115121// Repo-root-relative session dir, matching agent.ts.
116122const sessionsDirRelative = ".github-minimum-intelligence/state/sessions" ;
117123
@@ -146,12 +152,11 @@ const LOCAL_BRAND_DEFAULTS: Record<string, { label: string; baseUrl: string }> =
146152 vllm : { label : "vLLM" , baseUrl : "http://localhost:8000/v1" } ,
147153} ;
148154
149- // Brand labels that map to a "openai-compatible" pi invocation. The key is
150- // what the user types or configures; the value is what pi actually receives
151- // (always "openai" today because pi has no first-class lmstudio/ollama/vllm
152- // provider — they all speak OpenAI Chat Completions) .
155+ // Local brands (lmstudio/ollama/vllm) are registered as first-class custom
156+ // providers in a generated models.json (see ensureLocalProviderConfig), so pi
157+ // receives the brand name verbatim and reaches the local server over the
158+ // OpenAI Chat Completions API. The brand IS the pi provider name now .
153159function resolvePiProvider ( userProvider : string ) : string {
154- if ( LOCAL_PROVIDERS . has ( userProvider ) ) return "openai" ;
155160 return userProvider ;
156161}
157162
@@ -740,6 +745,59 @@ function isLocalProvider(provider: string): boolean {
740745 return false ;
741746}
742747
748+ /**
749+ * Resolve the effective OpenAI-compatible base URL for a local provider,
750+ * honouring explicit env vars first, then well-known brand defaults.
751+ */
752+ function resolveLocalBaseUrl ( provider : string ) : string {
753+ return (
754+ process . env . LOCAL_LLM_BASE_URL ||
755+ process . env . OPENAI_BASE_URL ||
756+ LOCAL_BRAND_DEFAULTS [ provider ] ?. baseUrl ||
757+ "http://localhost:1234/v1"
758+ ) ;
759+ }
760+
761+ /**
762+ * Configure pi to talk to a local OpenAI-compatible server (LM Studio, Ollama,
763+ * vLLM, or an `openai` provider pointed at LOCAL_LLM_BASE_URL).
764+ *
765+ * Why this exists: pi's built-in `openai` provider ignores OPENAI_BASE_URL and
766+ * defaults to the Responses API, so it would contact the real api.openai.com
767+ * and fail with a 401. The supported mechanism for a local server is a
768+ * `models.json` describing a custom provider with an explicit `baseUrl` and the
769+ * `openai-completions` API. pi only reads `models.json` from its agent dir, so
770+ * we point PI_CODING_AGENT_DIR at a repo-local directory and write the file
771+ * there. This leaves the user's global ~/.pi/agent untouched and is applied
772+ * only for local providers.
773+ *
774+ * `compat.supportsDeveloperRole` / `supportsReasoningEffort` are disabled
775+ * because many local servers reject the `developer` role and the
776+ * `reasoning_effort` parameter used by reasoning-capable cloud models.
777+ */
778+ function ensureLocalProviderConfig ( provider : string , model : string ) : void {
779+ const baseUrl = resolveLocalBaseUrl ( provider ) ;
780+ mkdirSync ( localAgentDir , { recursive : true } ) ;
781+ const modelsConfig = {
782+ providers : {
783+ [ provider ] : {
784+ baseUrl,
785+ api : "openai-completions" ,
786+ apiKey : "local" ,
787+ compat : {
788+ supportsDeveloperRole : false ,
789+ supportsReasoningEffort : false ,
790+ } ,
791+ models : [ { id : model } ] ,
792+ } ,
793+ } ,
794+ } ;
795+ writeFileSync ( localModelsPath , JSON . stringify ( modelsConfig , null , 2 ) + "\n" ) ;
796+ process . env . PI_CODING_AGENT_DIR = localAgentDir ;
797+ process . env . OPENAI_BASE_URL = baseUrl ;
798+ if ( ! process . env . OPENAI_API_KEY ) process . env . OPENAI_API_KEY = "local" ;
799+ }
800+
743801// ─── pi binary location ───────────────────────────────────────────────────────
744802
745803function locatePiBin ( ) : string {
@@ -831,11 +889,12 @@ async function runTurn(
831889 : null ;
832890
833891 // Map brand providers (lmstudio/ollama/vllm) to what pi actually
834- // understands today (openai-compatible Chat Completions). pi has no
835- // first-class lmstudio provider, so the brand is purely a label for
836- // the user; the wire-format is always openai-compatible .
892+ // understands. For local mode we (re)write models.json so pi reaches the
893+ // local OpenAI-compatible server via a custom provider of the same name;
894+ // doing it here also picks up runtime /model and /provider switches .
837895 const piProvider = resolvePiProvider ( rt . provider ) ;
838896 const localMode = isLocalProvider ( rt . provider ) ;
897+ if ( localMode ) ensureLocalProviderConfig ( rt . provider , rt . model ) ;
839898 const args : string [ ] = [
840899 "--mode" , "json" ,
841900 "--tools" , "read,bash,edit,write,grep,find,ls" ,
@@ -856,6 +915,10 @@ async function runTurn(
856915 try {
857916 const proc = Bun . spawn ( [ rt . piBin , ...args ] , {
858917 cwd : repoRoot ,
918+ // Pass env explicitly so runtime mutations (e.g. PI_CODING_AGENT_DIR
919+ // and OPENAI_BASE_URL set by ensureLocalProviderConfig) reliably reach
920+ // the pi child on every platform.
921+ env : { ...process . env } ,
859922 stdout : "pipe" ,
860923 stderr : "inherit" ,
861924 } ) ;
@@ -1811,6 +1874,11 @@ async function main(): Promise<void> {
18111874 piBin,
18121875 } ;
18131876
1877+ // For local providers, generate models.json and point PI_CODING_AGENT_DIR at
1878+ // it up front so the REPL banner shows the right endpoint and the first turn
1879+ // is correctly wired.
1880+ if ( isLocalProvider ( rt . provider ) ) ensureLocalProviderConfig ( rt . provider , rt . model ) ;
1881+
18141882 // One-shot mode.
18151883 if ( args . prompt ) {
18161884 try {
0 commit comments