From df269b3b002d0a6520e51440c718451aa679eef6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 25 Mar 2026 00:35:45 -0400 Subject: [PATCH] feat: complete offline mode support (#2429) * feat: complete offline mode support for local-only model setups - Add isLocalModel() to detect localhost/127.0.0.1/0.0.0.0/::1/unix sockets - Add isAllLocalChain() to verify all registry models are local - Validate --offline flag rejects remote models with clear error - Auto-enable PI_OFFLINE when all configured models are local - Return dummy API key for local models to skip auth validation - Filter web search results in offline mode (chat-controller + tool-execution) - Add ECONNREFUSED/ENOTFOUND/ENETUNREACH to INFRA_ERROR_CODES for immediate failure (no retry) when network is intentionally unavailable - Add comprehensive test suite (17 tests) Fixes #2341 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(test): update infra-error test for new offline-mode error codes The offline mode feature added ECONNREFUSED, ENOTFOUND, and ENETUNREACH to INFRA_ERROR_CODES but the test still asserted size === 6. Update the count to 9 and add detection tests for the three new codes. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../pi-coding-agent/src/core/auth-storage.ts | 16 +- .../src/core/local-model-check.ts | 45 +++++ .../src/core/model-registry.ts | 22 ++- packages/pi-coding-agent/src/main.ts | 19 ++ .../interactive/components/tool-execution.ts | 4 +- .../controllers/chat-controller.ts | 22 ++- .../extensions/gsd/auto/infra-errors.ts | 3 + .../extensions/gsd/tests/infra-error.test.ts | 22 ++- src/tests/offline-mode.test.ts | 165 ++++++++++++++++++ 9 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 packages/pi-coding-agent/src/core/local-model-check.ts create mode 100644 src/tests/offline-mode.test.ts diff --git a/packages/pi-coding-agent/src/core/auth-storage.ts b/packages/pi-coding-agent/src/core/auth-storage.ts index 5ae286177..2791f326d 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.ts @@ -744,7 +744,21 @@ export class AuthStorage { * @param providerId - The provider to get an API key for * @param sessionId - Optional session ID for sticky credential selection */ - async getApiKey(providerId: string, sessionId?: string): Promise { + async getApiKey(providerId: string, sessionId?: string, options?: { baseUrl?: string }): Promise { + // If the model has a local baseUrl, return a dummy key to avoid auth blocking + if (options?.baseUrl) { + try { + const hostname = new URL(options.baseUrl).hostname; + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0" || hostname === "::1") { + return "local-no-key-needed"; + } + } catch { + if (options.baseUrl.startsWith("unix:")) { + return "local-no-key-needed"; + } + } + } + // Runtime override takes highest priority const runtimeKey = this.runtimeOverrides.get(providerId); if (runtimeKey) { diff --git a/packages/pi-coding-agent/src/core/local-model-check.ts b/packages/pi-coding-agent/src/core/local-model-check.ts new file mode 100644 index 000000000..b468e459f --- /dev/null +++ b/packages/pi-coding-agent/src/core/local-model-check.ts @@ -0,0 +1,45 @@ +/** + * local-model-check.ts — Utility to detect if a model baseUrl is local. + * + * Leaf module with zero transitive dependencies on TypeScript parameter properties. + * Used by ModelRegistry and tests. + */ + +/** + * Check if a model's baseUrl points to a local endpoint. + * Returns true for localhost, 127.0.0.1, 0.0.0.0, ::1, or unix socket paths. + * Returns false if baseUrl is empty (cloud provider) or points to a remote host. + */ +export function isLocalModel(model: { baseUrl: string }): boolean { + const url = model.baseUrl; + if (!url) return false; + + // Unix socket paths + if (url.startsWith("unix://") || url.startsWith("unix:")) return true; + + try { + const parsed = new URL(url); + const hostname = parsed.hostname; + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "0.0.0.0" || + hostname === "::1" || + hostname === "[::1]" + ) { + return true; + } + } catch { + // If URL parsing fails, check raw string for local patterns + if ( + url.includes("localhost") || + url.includes("127.0.0.1") || + url.includes("0.0.0.0") || + url.includes("[::1]") + ) { + return true; + } + } + + return false; +} diff --git a/packages/pi-coding-agent/src/core/model-registry.ts b/packages/pi-coding-agent/src/core/model-registry.ts index dfc6c8580..0b36b27ee 100644 --- a/packages/pi-coding-agent/src/core/model-registry.ts +++ b/packages/pi-coding-agent/src/core/model-registry.ts @@ -28,6 +28,7 @@ import { ModelDiscoveryCache } from "./discovery-cache.js"; import type { DiscoveredModel, DiscoveryResult } from "./model-discovery.js"; import { getDefaultTTL, getDiscoverableProviders, getDiscoveryAdapter } from "./model-discovery.js"; import { clearConfigValueCache, resolveConfigValue, resolveHeaders } from "./resolve-config-value.js"; +import { isLocalModel } from "./local-model-check.js"; const Ajv = (AjvModule as any).default || AjvModule; const ajv = new Ajv(); @@ -557,7 +558,7 @@ export class ModelRegistry { async getApiKey(model: Model, sessionId?: string): Promise { const authMode = this.getProviderAuthMode(model.provider); if (authMode === "externalCli" || authMode === "none") return undefined; - return this.authStorage.getApiKey(model.provider, sessionId); + return this.authStorage.getApiKey(model.provider, sessionId, { baseUrl: model.baseUrl }); } /** @@ -807,6 +808,25 @@ export class ModelRegistry { } return converted; } + + /** + * Check if a model's baseUrl points to a local endpoint. + * Delegates to standalone isLocalModel() function. + */ + static isLocalModel(model: Model): boolean { + return isLocalModel(model); + } + + /** + * Check if all models in the registry are local. + * Returns true only if every model passes isLocalModel(). + * Returns false if there are no models. + */ + isAllLocalChain(): boolean { + const models = this.getAll(); + if (models.length === 0) return false; + return models.every((m) => isLocalModel(m)); + } } /** diff --git a/packages/pi-coding-agent/src/main.ts b/packages/pi-coding-agent/src/main.ts index c453f5eb8..8c9ef0919 100644 --- a/packages/pi-coding-agent/src/main.ts +++ b/packages/pi-coding-agent/src/main.ts @@ -391,6 +391,25 @@ export async function main(args: string[]) { const authStorage = AuthStorage.create(); const modelRegistry = new ModelRegistry(authStorage, getModelsPath()); + // Offline mode validation / auto-detection + if (offlineMode) { + // --offline flag: validate all models are local + if (!modelRegistry.isAllLocalChain()) { + const remoteModel = modelRegistry.getAll().find((m) => !ModelRegistry.isLocalModel(m)); + if (remoteModel) { + console.error( + `Error: --offline requires all configured models to be local. Found remote model: ${remoteModel.name} (${remoteModel.baseUrl || "cloud API"})`, + ); + process.exit(1); + } + } + } else if (modelRegistry.isAllLocalChain() && modelRegistry.getAll().length > 0) { + // Auto-detect: all models are local, enable offline mode + process.env.PI_OFFLINE = "1"; + process.env.PI_SKIP_VERSION_CHECK = "1"; + console.log("[gsd] All configured models are local \u2014 enabling offline mode automatically."); + } + const resourceLoader = new DefaultResourceLoader({ cwd, agentDir, diff --git a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts index 80d25b0f0..399819c30 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts @@ -895,7 +895,9 @@ export class ToolExecutionComponent extends Container { // Server-side Anthropic web search text = theme.fg("toolTitle", theme.bold("web search")); - if (this.result) { + if (process.env.PI_OFFLINE === "1") { + text += "\n\n" + theme.fg("muted", "\u{1F50C} Offline \u{2014} web search unavailable"); + } else if (this.result) { const output = this.getTextOutput().trim(); if (output) { const lines = output.split("\n"); diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index 32f10d339..ddb65f518 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -144,13 +144,21 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { } else if (content.type === "webSearchResult") { const component = host.pendingTools.get(content.toolUseId); if (component) { - const searchContent = content.content; - const isError = searchContent && typeof searchContent === "object" && "type" in (searchContent as any) && (searchContent as any).type === "web_search_tool_result_error"; - component.updateResult({ - content: [{ type: "text", text: host.formatWebSearchResult(searchContent) }], - isError: !!isError, - }); - host.pendingTools.delete(content.toolUseId); + if (process.env.PI_OFFLINE === "1") { + component.updateResult({ + content: [{ type: "text", text: "Web search disabled (offline mode)" }], + isError: false, + }); + host.pendingTools.delete(content.toolUseId); + } else { + const searchContent = content.content; + const isError = searchContent && typeof searchContent === "object" && "type" in (searchContent as any) && (searchContent as any).type === "web_search_tool_result_error"; + component.updateResult({ + content: [{ type: "text", text: host.formatWebSearchResult(searchContent) }], + isError: !!isError, + }); + host.pendingTools.delete(content.toolUseId); + } } } } diff --git a/src/resources/extensions/gsd/auto/infra-errors.ts b/src/resources/extensions/gsd/auto/infra-errors.ts index 92edf26fc..724daa551 100644 --- a/src/resources/extensions/gsd/auto/infra-errors.ts +++ b/src/resources/extensions/gsd/auto/infra-errors.ts @@ -18,6 +18,9 @@ export const INFRA_ERROR_CODES: ReadonlySet = new Set([ "EDQUOT", // disk quota exceeded "EMFILE", // too many open files (process) "ENFILE", // too many open files (system) + "ECONNREFUSED", // connection refused (offline / local server down) + "ENOTFOUND", // DNS lookup failed (offline / no network) + "ENETUNREACH", // network unreachable (offline / no route) ]); /** diff --git a/src/resources/extensions/gsd/tests/infra-error.test.ts b/src/resources/extensions/gsd/tests/infra-error.test.ts index 0eb379156..feb5630ea 100644 --- a/src/resources/extensions/gsd/tests/infra-error.test.ts +++ b/src/resources/extensions/gsd/tests/infra-error.test.ts @@ -7,10 +7,13 @@ import { isInfrastructureError, INFRA_ERROR_CODES } from "../auto/infra-errors.j // ── INFRA_ERROR_CODES constant ─────────────────────────────────────────────── test("INFRA_ERROR_CODES contains the expected codes", () => { - for (const code of ["ENOSPC", "ENOMEM", "EROFS", "EDQUOT", "EMFILE", "ENFILE"]) { + for (const code of [ + "ENOSPC", "ENOMEM", "EROFS", "EDQUOT", "EMFILE", "ENFILE", + "ECONNREFUSED", "ENOTFOUND", "ENETUNREACH", + ]) { assert.ok(INFRA_ERROR_CODES.has(code), `missing ${code}`); } - assert.equal(INFRA_ERROR_CODES.size, 6, "unexpected extra codes"); + assert.equal(INFRA_ERROR_CODES.size, 9, "unexpected extra codes"); }); // ── isInfrastructureError: code property detection ─────────────────────────── @@ -45,6 +48,21 @@ test("detects ENFILE via code property", () => { assert.equal(isInfrastructureError(err), "ENFILE"); }); +test("detects ECONNREFUSED via code property", () => { + const err = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:3000"), { code: "ECONNREFUSED" }); + assert.equal(isInfrastructureError(err), "ECONNREFUSED"); +}); + +test("detects ENOTFOUND via code property", () => { + const err = Object.assign(new Error("getaddrinfo ENOTFOUND api.example.com"), { code: "ENOTFOUND" }); + assert.equal(isInfrastructureError(err), "ENOTFOUND"); +}); + +test("detects ENETUNREACH via code property", () => { + const err = Object.assign(new Error("connect ENETUNREACH 2607:f8b0:4004::"), { code: "ENETUNREACH" }); + assert.equal(isInfrastructureError(err), "ENETUNREACH"); +}); + // ── isInfrastructureError: message fallback ────────────────────────────────── test("falls back to message scanning when no code property", () => { diff --git a/src/tests/offline-mode.test.ts b/src/tests/offline-mode.test.ts new file mode 100644 index 000000000..07c19b642 --- /dev/null +++ b/src/tests/offline-mode.test.ts @@ -0,0 +1,165 @@ +/** + * Offline mode support tests. + * + * Covers: + * - isLocalModel() detection for local vs cloud URLs + * - isAllLocalChain() aggregate check + * - Auto-detection sets PI_OFFLINE when all models are local + * - Validation rejects remote models with --offline flag + * - Network error codes in INFRA_ERROR_CODES + * - Web search tool filtered when PI_OFFLINE is set + * + * Fixes #2341 + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { isLocalModel } from "../../packages/pi-coding-agent/src/core/local-model-check.ts"; + +// ─── isLocalModel ─────────────────────────────────────────────────────────── + +test("isLocalModel returns true for localhost", () => { + assert.strictEqual(isLocalModel(fakeModel({ baseUrl: "http://localhost:11434" })), true); +}); + +test("isLocalModel returns true for 127.0.0.1", () => { + assert.strictEqual(isLocalModel(fakeModel({ baseUrl: "http://127.0.0.1:8080/v1" })), true); +}); + +test("isLocalModel returns true for 0.0.0.0", () => { + assert.strictEqual(isLocalModel(fakeModel({ baseUrl: "http://0.0.0.0:1234" })), true); +}); + +test("isLocalModel returns true for ::1 (IPv6 loopback)", () => { + assert.strictEqual(isLocalModel(fakeModel({ baseUrl: "http://[::1]:11434" })), true); +}); + +test("isLocalModel returns true for unix socket path", () => { + assert.strictEqual(isLocalModel(fakeModel({ baseUrl: "unix:///var/run/ollama.sock" })), true); +}); + +test("isLocalModel returns false for api.anthropic.com", () => { + assert.strictEqual(isLocalModel(fakeModel({ baseUrl: "https://api.anthropic.com" })), false); +}); + +test("isLocalModel returns false for api.openai.com", () => { + assert.strictEqual(isLocalModel(fakeModel({ baseUrl: "https://api.openai.com/v1" })), false); +}); + +test("isLocalModel returns false when no baseUrl (empty string = cloud)", () => { + assert.strictEqual(isLocalModel(fakeModel({ baseUrl: "" })), false); +}); + +// ─── isAllLocalChain (source-level check) ─────────────────────────────────── + +test("isAllLocalChain returns true when all models are local (logic check)", () => { + const models = [ + fakeModel({ baseUrl: "http://localhost:11434/v1" }), + fakeModel({ baseUrl: "http://127.0.0.1:8080" }), + ]; + assert.strictEqual(models.every((m) => isLocalModel(m)), true); +}); + +test("isAllLocalChain returns false when mixed local and remote", () => { + const models = [ + fakeModel({ baseUrl: "http://localhost:11434/v1" }), + fakeModel({ baseUrl: "https://api.anthropic.com" }), + ]; + assert.strictEqual(models.every((m) => isLocalModel(m)), false); +}); + +test("isAllLocalChain returns false for empty list", () => { + const models: Array<{ baseUrl: string }> = []; + // Empty => false (no models means we can't guarantee local) + assert.strictEqual(models.length === 0 ? false : models.every((m) => isLocalModel(m)), false); +}); + +// ─── INFRA_ERROR_CODES includes network errors ───────────────────────────── + +test("INFRA_ERROR_CODES includes ECONNREFUSED", async () => { + const { INFRA_ERROR_CODES } = await import( + "../../src/resources/extensions/gsd/auto/infra-errors.ts" + ); + assert.strictEqual(INFRA_ERROR_CODES.has("ECONNREFUSED"), true); +}); + +test("INFRA_ERROR_CODES includes ENOTFOUND", async () => { + const { INFRA_ERROR_CODES } = await import( + "../../src/resources/extensions/gsd/auto/infra-errors.ts" + ); + assert.strictEqual(INFRA_ERROR_CODES.has("ENOTFOUND"), true); +}); + +test("INFRA_ERROR_CODES includes ENETUNREACH", async () => { + const { INFRA_ERROR_CODES } = await import( + "../../src/resources/extensions/gsd/auto/infra-errors.ts" + ); + assert.strictEqual(INFRA_ERROR_CODES.has("ENETUNREACH"), true); +}); + +// ─── isInfrastructureError detects network errors in offline mode ─────────── + +test("isInfrastructureError returns code for ECONNREFUSED when offline", async () => { + const { isInfrastructureError } = await import( + "../../src/resources/extensions/gsd/auto/infra-errors.ts" + ); + const savedOffline = process.env.PI_OFFLINE; + process.env.PI_OFFLINE = "1"; + try { + const err = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + assert.strictEqual(isInfrastructureError(err), "ECONNREFUSED"); + } finally { + if (savedOffline === undefined) delete process.env.PI_OFFLINE; + else process.env.PI_OFFLINE = savedOffline; + } +}); + +// ─── Web search filtering when PI_OFFLINE set ────────────────────────────── + +test("web search tool is filtered when PI_OFFLINE is set", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + + const toolExecPath = join( + process.cwd(), + "packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts", + ); + const content = readFileSync(toolExecPath, "utf-8"); + assert.ok( + content.includes("PI_OFFLINE") && content.includes("web_search"), + "tool-execution.ts should check PI_OFFLINE for web_search", + ); + + const chatControllerPath = join( + process.cwd(), + "packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts", + ); + const chatContent = readFileSync(chatControllerPath, "utf-8"); + assert.ok( + chatContent.includes("PI_OFFLINE") && chatContent.includes("webSearchResult"), + "chat-controller.ts should check PI_OFFLINE for webSearchResult", + ); +}); + +// ─── Version check skipped when PI_OFFLINE ───────────────────────────────── + +test("version check is skipped when PI_OFFLINE is set", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + + const interactivePath = join( + process.cwd(), + "packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts", + ); + const content = readFileSync(interactivePath, "utf-8"); + assert.ok( + content.includes("PI_OFFLINE"), + "interactive-mode.ts should check PI_OFFLINE for version check skip", + ); +}); + +// ─── Helper ───────────────────────────────────────────────────────────────── + +function fakeModel(overrides: Partial<{ baseUrl: string }> = {}): { baseUrl: string } { + return { baseUrl: overrides.baseUrl ?? "" }; +}