Merge pull request #27 from k3-2o/fix/offline-startup

fix: allow extension to load when offline
This commit is contained in:
Patrick Wozniak
2026-08-02 10:49:35 +02:00
committed by GitHub
8 changed files with 433 additions and 74 deletions
+6
View File
@@ -2,6 +2,12 @@
## Unreleased ## Unreleased
- Allow pi to start when model discovery is unavailable. The provider now caches the last successfully fetched model catalog so previously discovered Command Code models remain selectable offline; a first offline start without a cache keeps Command Code unavailable until `/reload` succeeds.
### Contributors
- @k3-2o — reported that the model-list fetch blocked pi startup when offline.
## 0.4.2 - 2026-07-05 ## 0.4.2 - 2026-07-05
- Fix Oh My Pi extension validation by avoiding the missing `calculateCost` export from OMP's legacy `pi-ai` shim. - Fix Oh My Pi extension validation by avoiding the missing `calculateCost` export from OMP's legacy `pi-ai` shim.
+3 -1
View File
@@ -134,7 +134,9 @@ On startup, the provider fetches:
https://api.commandcode.ai/provider/v1/models https://api.commandcode.ai/provider/v1/models
``` ```
For tests or local mocks, override it with `COMMANDCODE_MODELS_URL`. The last successfully fetched catalog is cached at `<agent-dir>/commandcode-models.json` (`~/.pi/agent/commandcode-models.json` by default). The agent directory follows pi's `PI_CODING_AGENT_DIR` setting, so compatible hosts such as OMP keep the cache in their own agent directory. If model discovery is temporarily unavailable, the provider uses this cached catalog so previously discovered Command Code models remain selectable. On a first offline start without a cache, pi still loads, but Command Code models remain unavailable until the connection is restored and `/reload` succeeds.
For tests or local mocks, override the endpoint with `COMMANDCODE_MODELS_URL` and the cache file with `COMMANDCODE_MODELS_CACHE`.
## Pricing ## Pricing
+11 -3
View File
@@ -13,15 +13,18 @@
*/ */
import { AssistantMessageEventStream } from "@earendil-works/pi-ai" import { AssistantMessageEventStream } from "@earendil-works/pi-ai"
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { join } from "node:path"
import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"
import { calculateCommandCodeCost } from "./src/cost.ts" import { calculateCommandCodeCost } from "./src/cost.ts"
import { DEFAULT_MODELS_URL, fetchCommandCodeModels } from "./src/models.ts" import { DEFAULT_MODELS_URL, loadCommandCodeModels } from "./src/models.ts"
import { getApiKey, login, refreshToken } from "./src/oauth.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts"
const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE
const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL
const MODELS_CACHE_PATH =
process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json")
type CommandCodeModelCost = { type CommandCodeModelCost = {
input: number input: number
@@ -79,7 +82,12 @@ const streamCommandCode = createStreamCommandCode({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default async function (pi: ExtensionAPI) { export default async function (pi: ExtensionAPI) {
const models = await fetchCommandCodeModels({ url: MODELS_URL }) const { models, warning } = await loadCommandCodeModels({
url: MODELS_URL,
cachePath: MODELS_CACHE_PATH,
})
if (warning) console.warn(`[commandcode] ${warning}`)
pi.registerProvider("commandcode", { pi.registerProvider("commandcode", {
name: "Command Code", name: "Command Code",
+3 -3
View File
@@ -1646,9 +1646,9 @@
} }
}, },
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.6.4", "version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
+126 -6
View File
@@ -1,6 +1,10 @@
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
import { dirname } from "node:path"
export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models"
const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 const DEFAULT_MAX_OUTPUT_TOKENS = 65_536
const MODEL_CACHE_VERSION = 1
interface ApiModel { interface ApiModel {
id: string id: string
@@ -21,19 +25,39 @@ interface FetchCommandCodeModelsOptions {
fetchImpl?: typeof fetch fetchImpl?: typeof fetch
} }
interface LoadCommandCodeModelsOptions extends FetchCommandCodeModelsOptions {
cachePath: string
}
export interface LoadCommandCodeModelsResult {
models: readonly CommandCodeModel[]
source: "live" | "cache" | "empty"
warning?: string
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null return typeof value === "object" && value !== null && !Array.isArray(value)
} }
function stringField(record: Record<string, unknown>, key: string): string { function stringField(record: Record<string, unknown>, key: string): string {
const value = record[key] const value = record[key]
if (typeof value !== "string") throw new Error(`Expected ${key} to be a string`) if (typeof value !== "string" || value.length === 0) {
throw new Error(`Expected ${key} to be a non-empty string`)
}
return value return value
} }
function numberField(record: Record<string, unknown>, key: string): number { function booleanField(record: Record<string, unknown>, key: string): boolean {
const value = record[key] const value = record[key]
if (typeof value !== "number") throw new Error(`Expected ${key} to be a number`) if (typeof value !== "boolean") throw new Error(`Expected ${key} to be a boolean`)
return value
}
function positiveNumberField(record: Record<string, unknown>, key: string): number {
const value = record[key]
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new Error(`Expected ${key} to be a positive number`)
}
return value return value
} }
@@ -43,10 +67,31 @@ function parseApiModel(value: unknown): ApiModel {
return { return {
id: stringField(value, "id"), id: stringField(value, "id"),
name: stringField(value, "name"), name: stringField(value, "name"),
contextLength: numberField(value, "context_length"), contextLength: positiveNumberField(value, "context_length"),
} }
} }
function parseCachedModel(value: unknown): CommandCodeModel {
if (!isRecord(value)) throw new Error("Expected cached model entry to be an object")
return {
id: stringField(value, "id"),
name: stringField(value, "name"),
reasoning: booleanField(value, "reasoning"),
contextWindow: positiveNumberField(value, "contextWindow"),
maxTokens: positiveNumberField(value, "maxTokens"),
}
}
function requireModels(models: readonly CommandCodeModel[]): readonly CommandCodeModel[] {
if (models.length === 0) throw new Error("Command Code returned an empty model catalog")
return models
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] {
if (!isRecord(value)) throw new Error("Expected models response to be an object") if (!isRecord(value)) throw new Error("Expected models response to be an object")
if (value.object !== "list") throw new Error("Expected models response object to be 'list'") if (value.object !== "list") throw new Error("Expected models response object to be 'list'")
@@ -63,6 +108,16 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma
})) }))
} }
export function commandCodeModelsFromCache(value: unknown): readonly CommandCodeModel[] {
if (!isRecord(value)) throw new Error("Expected model cache to be an object")
if (value.version !== MODEL_CACHE_VERSION) {
throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`)
}
if (!Array.isArray(value.models)) throw new Error("Expected cached models to be an array")
return requireModels(value.models.map(parseCachedModel))
}
export async function fetchCommandCodeModels( export async function fetchCommandCodeModels(
options: FetchCommandCodeModelsOptions = {}, options: FetchCommandCodeModelsOptions = {},
): Promise<readonly CommandCodeModel[]> { ): Promise<readonly CommandCodeModel[]> {
@@ -81,5 +136,70 @@ export async function fetchCommandCodeModels(
} }
const body: unknown = await response.json() const body: unknown = await response.json()
return commandCodeModelsFromApiResponse(body) return requireModels(commandCodeModelsFromApiResponse(body))
}
async function readCommandCodeModelsCache(cachePath: string): Promise<readonly CommandCodeModel[]> {
const contents = await readFile(cachePath, "utf-8")
const parsed: unknown = JSON.parse(contents)
return commandCodeModelsFromCache(parsed)
}
async function writeCommandCodeModelsCache(
cachePath: string,
models: readonly CommandCodeModel[],
): Promise<void> {
await mkdir(dirname(cachePath), { recursive: true })
const temporaryPath = `${cachePath}.${process.pid}.tmp`
try {
await writeFile(
temporaryPath,
`${JSON.stringify({ version: MODEL_CACHE_VERSION, models }, null, 2)}\n`,
{ encoding: "utf-8", mode: 0o600 },
)
await rename(temporaryPath, cachePath)
} finally {
try {
await rm(temporaryPath, { force: true })
} catch {
// Best-effort cleanup must not hide the original cache write error.
}
}
}
export async function loadCommandCodeModels(
options: LoadCommandCodeModelsOptions,
): Promise<LoadCommandCodeModelsResult> {
const cachePath = options.cachePath
try {
const models = await fetchCommandCodeModels(options)
try {
await writeCommandCodeModelsCache(cachePath, models)
return { models, source: "live" }
} catch (error) {
return {
models,
source: "live",
warning: `Loaded the live Command Code model catalog but could not update ${cachePath}: ${errorMessage(error)}`,
}
}
} catch (liveError) {
try {
const models = await readCommandCodeModelsCache(cachePath)
return {
models,
source: "cache",
warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}). Using the cached catalog from ${cachePath}.`,
}
} catch (cacheError) {
return {
models: [],
source: "empty",
warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}), and no valid cached catalog is available at ${cachePath} (${errorMessage(cacheError)}). Command Code models will remain unavailable until /reload succeeds.`,
}
}
}
} }
+184 -7
View File
@@ -1,11 +1,17 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { describe, it } from "node:test" import { describe, it } from "node:test"
import { commandCodeModelsFromApiResponse } from "../src/models.ts" import {
commandCodeModelsFromApiResponse,
commandCodeModelsFromCache,
loadCommandCodeModels,
type CommandCodeModel,
} from "../src/models.ts"
describe("commandCodeModelsFromApiResponse()", () => { const API_RESPONSE = {
it("converts the Provider API model list to pi models", () => {
const models = commandCodeModelsFromApiResponse({
object: "list", object: "list",
data: [ data: [
{ {
@@ -17,9 +23,9 @@ describe("commandCodeModelsFromApiResponse()", () => {
context_length: 1_000_000, context_length: 1_000_000,
}, },
], ],
}) }
assert.deepEqual(models, [ const EXPECTED_MODELS: readonly CommandCodeModel[] = [
{ {
id: "Qwen/Qwen3.7-Max", id: "Qwen/Qwen3.7-Max",
name: "Qwen 3.7 Max (CC)", name: "Qwen 3.7 Max (CC)",
@@ -27,10 +33,181 @@ describe("commandCodeModelsFromApiResponse()", () => {
contextWindow: 1_000_000, contextWindow: 1_000_000,
maxTokens: 65_536, maxTokens: 65_536,
}, },
]) ]
function successfulFetch(): typeof fetch {
return () =>
Promise.resolve(
new Response(JSON.stringify(API_RESPONSE), {
status: 200,
headers: { "content-type": "application/json" },
}),
)
}
function failingFetch(message = "offline"): typeof fetch {
return () => Promise.reject(new TypeError(message))
}
async function withTemporaryCache(
run: (paths: { directory: string; cachePath: string }) => Promise<void>,
): Promise<void> {
const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-models-"))
try {
await run({ directory, cachePath: join(directory, "models.json") })
} finally {
await rm(directory, { recursive: true, force: true })
}
}
describe("commandCodeModelsFromApiResponse()", () => {
it("converts the Provider API model list to pi models", () => {
assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS)
}) })
it("rejects unexpected API shapes", () => { it("rejects unexpected API shapes", () => {
assert.throws(() => commandCodeModelsFromApiResponse({ object: "list", data: [{}] })) assert.throws(() => commandCodeModelsFromApiResponse({ object: "list", data: [{}] }))
}) })
}) })
describe("commandCodeModelsFromCache()", () => {
it("accepts the current cache format", () => {
assert.deepEqual(
commandCodeModelsFromCache({ version: 1, models: EXPECTED_MODELS }),
EXPECTED_MODELS,
)
})
it("rejects empty, invalid, and unsupported caches", () => {
assert.throws(() => commandCodeModelsFromCache({ version: 1, models: [] }))
assert.throws(() => commandCodeModelsFromCache({ version: 2, models: EXPECTED_MODELS }))
assert.throws(() =>
commandCodeModelsFromCache({
version: 1,
models: [{ ...EXPECTED_MODELS[0], contextWindow: -1 }],
}),
)
})
})
describe("loadCommandCodeModels()", () => {
it("returns live models and writes a validated cache", async () => {
await withTemporaryCache(async ({ cachePath }) => {
const result = await loadCommandCodeModels({
cachePath,
fetchImpl: successfulFetch(),
})
assert.deepEqual(result, { models: EXPECTED_MODELS, source: "live" })
assert.deepEqual(
commandCodeModelsFromCache(JSON.parse(await readFile(cachePath, "utf-8"))),
EXPECTED_MODELS,
)
})
})
it("uses the last valid catalog when the refresh fails", async () => {
await withTemporaryCache(async ({ cachePath }) => {
await loadCommandCodeModels({ cachePath, fetchImpl: successfulFetch() })
const result = await loadCommandCodeModels({
cachePath,
fetchImpl: failingFetch(),
})
assert.deepEqual(result.models, EXPECTED_MODELS)
assert.equal(result.source, "cache")
assert.match(result.warning ?? "", /offline/)
assert.match(result.warning ?? "", /Using the cached catalog/)
})
})
it("starts with an empty catalog when offline without a valid cache", async () => {
await withTemporaryCache(async ({ cachePath }) => {
const result = await loadCommandCodeModels({
cachePath,
fetchImpl: failingFetch(),
})
assert.deepEqual(result.models, [])
assert.equal(result.source, "empty")
assert.match(result.warning ?? "", /no valid cached catalog/)
assert.match(result.warning ?? "", /until \/reload succeeds/)
})
})
it("recovers live models after an empty offline start", async () => {
await withTemporaryCache(async ({ cachePath }) => {
const empty = await loadCommandCodeModels({
cachePath,
fetchImpl: failingFetch(),
})
assert.equal(empty.source, "empty")
assert.deepEqual(empty.models, [])
const recovered = await loadCommandCodeModels({
cachePath,
fetchImpl: successfulFetch(),
})
assert.deepEqual(recovered, { models: EXPECTED_MODELS, source: "live" })
assert.deepEqual(
commandCodeModelsFromCache(JSON.parse(await readFile(cachePath, "utf-8"))),
EXPECTED_MODELS,
)
})
})
it("ignores a corrupt cache after a failed refresh", async () => {
await withTemporaryCache(async ({ cachePath }) => {
await writeFile(cachePath, "not json", "utf-8")
const result = await loadCommandCodeModels({
cachePath,
fetchImpl: failingFetch(),
})
assert.deepEqual(result.models, [])
assert.equal(result.source, "empty")
assert.match(result.warning ?? "", /Unexpected token|JSON/)
})
})
it("keeps live models usable when the cache cannot be written", async () => {
await withTemporaryCache(async ({ directory }) => {
const unwritableCachePath = join(directory, "cache-directory")
await mkdir(unwritableCachePath)
const result = await loadCommandCodeModels({
cachePath: unwritableCachePath,
fetchImpl: successfulFetch(),
})
assert.deepEqual(result.models, EXPECTED_MODELS)
assert.equal(result.source, "live")
assert.match(result.warning ?? "", /could not update/)
})
})
it("falls back to cache for HTTP and response parsing failures", async () => {
await withTemporaryCache(async ({ cachePath }) => {
await loadCommandCodeModels({ cachePath, fetchImpl: successfulFetch() })
for (const fetchImpl of [
(() => Promise.resolve(new Response("boom", { status: 500 }))) as typeof fetch,
(() =>
Promise.resolve(
new Response("not json", {
status: 200,
headers: { "content-type": "application/json" },
}),
)) as typeof fetch,
]) {
const result = await loadCommandCodeModels({ cachePath, fetchImpl })
assert.deepEqual(result.models, EXPECTED_MODELS)
assert.equal(result.source, "cache")
}
})
})
})
+3
View File
@@ -166,6 +166,9 @@ try {
assert.match(listOutput, /commandcode/) assert.match(listOutput, /commandcode/)
assert.match(listOutput, /deepseek\/deepseek-v4-flash/) assert.match(listOutput, /deepseek\/deepseek-v4-flash/)
assert.equal(modelListRequestCount, 1) assert.equal(modelListRequestCount, 1)
assert.doesNotThrow(() =>
accessSync(join(tempHome, ".omp", "agent", "commandcode-models.json"), constants.R_OK),
)
assert.doesNotMatch(result.stdout + result.stderr, /Failed to load extension/) assert.doesNotMatch(result.stdout + result.stderr, /Failed to load extension/)
console.log("[omp-compat] print mode through real extension and mock API") console.log("[omp-compat] print mode through real extension and mock API")
+80 -37
View File
@@ -6,24 +6,16 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { spawn, spawnSync } from "node:child_process" import { spawn, spawnSync } from "node:child_process"
import { import { accessSync, constants, mkdtempSync, rmSync } from "node:fs"
accessSync,
constants,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs"
import { createServer } from "node:http" import { createServer } from "node:http"
import { homedir, tmpdir } from "node:os" import { tmpdir } from "node:os"
import { delimiter, dirname, join, resolve } from "node:path" import { delimiter, dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const PROJECT_DIR = resolve(__dirname, "..") const PROJECT_DIR = resolve(__dirname, "..")
const EXT_PATH = resolve(PROJECT_DIR, "index.ts") const EXT_PATH = resolve(PROJECT_DIR, "index.ts")
const TEST_MODEL = "deepseek/deepseek-v4-flash" const TEST_MODEL = "cc-offline-cache-model"
function findPiBinary() { function findPiBinary() {
if (process.env.PI_BIN) return process.env.PI_BIN if (process.env.PI_BIN) return process.env.PI_BIN
@@ -77,7 +69,7 @@ const server = createServer((req, res) => {
context_length: 1_000_000, context_length: 1_000_000,
}, },
{ {
id: "Qwen/Qwen3.7-Max", id: "cc-second-model",
object: "model", object: "model",
created: 1779824324, created: 1779824324,
owned_by: "command-code", owned_by: "command-code",
@@ -132,34 +124,17 @@ const address = server.address()
const port = typeof address === "object" && address ? address.port : 0 const port = typeof address === "object" && address ? address.port : 0
const apiBase = `http://127.0.0.1:${port}` const apiBase = `http://127.0.0.1:${port}`
function hasLivePiAuth() { const tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-"))
return (
!!process.env.COMMANDCODE_API_KEY ||
existsSync(join(homedir(), ".commandcode", "auth.json")) ||
existsSync(join(homedir(), ".omp", "agent", "auth.json")) ||
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
)
}
let tempHome
const env = { const env = {
...process.env, ...process.env,
HOME: tempHome,
USERPROFILE: tempHome,
PI_CODING_AGENT_DIR: join(tempHome, "custom-pi-agent"),
COMMANDCODE_API_BASE: apiBase, COMMANDCODE_API_BASE: apiBase,
COMMANDCODE_API_KEY: "mock-key",
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
} }
if (hasLivePiAuth()) {
console.log("[pi-local] using live pi auth")
} else {
console.log("[pi-local] live pi auth not found; using mock auth fallback")
tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-"))
mkdirSync(join(tempHome, ".commandcode"), { recursive: true })
writeFileSync(join(tempHome, ".commandcode", "auth.json"), JSON.stringify({ apiKey: "mock-key" }))
env.HOME = tempHome
env.USERPROFILE = tempHome
env.COMMANDCODE_API_KEY = "mock-key"
}
function runPi(args, timeoutMs = 30_000) { function runPi(args, timeoutMs = 30_000) {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(PI_BIN, args, { const child = spawn(PI_BIN, args, {
@@ -291,15 +266,83 @@ async function runRpcQuery(timeoutMs = 30_000) {
} }
try { try {
console.log("[pi-local] first offline start without a cache")
const onlineModelsUrl = env.COMMANDCODE_MODELS_URL
env.COMMANDCODE_MODELS_URL = "http://127.0.0.1:1/provider/v1/models"
const modelsCachePath = join(env.PI_CODING_AGENT_DIR, "commandcode-models.json")
rmSync(modelsCachePath, { force: true })
const firstOfflineList = await runPi(
["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"],
20_000,
)
assert.equal(firstOfflineList.code, 0, firstOfflineList.stderr)
assert.doesNotMatch(firstOfflineList.stderr, /Failed to load extension/)
assert.match(firstOfflineList.stdout || firstOfflineList.stderr, /No models matching/)
assert.match(firstOfflineList.stderr, /no valid cached catalog/)
assert.match(firstOfflineList.stderr, /until \/reload succeeds/)
assert.throws(() => accessSync(modelsCachePath, constants.R_OK), /ENOENT|no such file/i)
// A fresh process re-runs the extension entrypoint, which is the same path /reload uses.
console.log("[pi-local] recover models after empty offline start")
env.COMMANDCODE_MODELS_URL = onlineModelsUrl
modelListRequestCount = 0
const recoveryList = await runPi(
["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"],
20_000,
)
assert.equal(recoveryList.code, 0, recoveryList.stderr)
const recoveryOutput = recoveryList.stdout || recoveryList.stderr
assert.match(recoveryOutput, /cc-offline-cache-model/)
assert.match(recoveryOutput, /cc-second-model/)
assert.doesNotMatch(recoveryList.stderr, /no valid cached catalog/)
assert.doesNotMatch(recoveryList.stderr, /Failed to load extension/)
assert.equal(modelListRequestCount, 1)
assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK))
console.log("[pi-local] list models through real extension") console.log("[pi-local] list models through real extension")
modelListRequestCount = 0 modelListRequestCount = 0
const list = await runPi(["--no-extensions", "-e", EXT_PATH, "--list-models"], 20_000) const list = await runPi(["--no-extensions", "-e", EXT_PATH, "--list-models"], 20_000)
assert.equal(list.code, 0, list.stderr) assert.equal(list.code, 0, list.stderr)
const listOutput = list.stdout || list.stderr const listOutput = list.stdout || list.stderr
assert.match(listOutput, /commandcode/) assert.match(listOutput, /commandcode/)
assert.match(listOutput, /deepseek\/deepseek-v4-flash/) assert.match(listOutput, /cc-offline-cache-model/)
assert.match(listOutput, /Qwen\/Qwen3\.7-Max/) assert.match(listOutput, /cc-second-model/)
assert.equal(modelListRequestCount, 1) assert.equal(modelListRequestCount, 1)
assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK))
console.log("[pi-local] list cached models while model discovery is offline")
env.COMMANDCODE_MODELS_URL = "http://127.0.0.1:1/provider/v1/models"
const offlineList = await runPi(
["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"],
20_000,
)
assert.equal(offlineList.code, 0, offlineList.stderr)
const offlineListOutput = offlineList.stdout || offlineList.stderr
assert.match(offlineListOutput, /cc-offline-cache-model/)
assert.match(offlineListOutput, /cc-second-model/)
assert.match(offlineList.stderr, /Using the cached catalog/)
console.log("[pi-local] use a cached model while model discovery is offline")
requestCount = 0
const offlinePrint = await runPi(
[
"--no-extensions",
"-e",
EXT_PATH,
"-p",
"say mock token",
"--provider",
"commandcode",
"--model",
TEST_MODEL,
],
30_000,
)
assert.equal(offlinePrint.code, 0, offlinePrint.stderr)
assert.match(offlinePrint.stdout, /mock-pi-ok/)
assert.match(offlinePrint.stderr, /Using the cached catalog/)
assert.equal(requestCount, 1)
env.COMMANDCODE_MODELS_URL = onlineModelsUrl
console.log("[pi-local] print mode through real extension and mock API") console.log("[pi-local] print mode through real extension and mock API")
requestCount = 0 requestCount = 0
@@ -347,5 +390,5 @@ try {
console.log("[pi-local] PASS") console.log("[pi-local] PASS")
} finally { } finally {
await new Promise((resolve) => server.close(resolve)) await new Promise((resolve) => server.close(resolve))
if (tempHome) rmSync(tempHome, { recursive: true, force: true }) rmSync(tempHome, { recursive: true, force: true })
} }