diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5207f..2abfd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Allow pi to start when the model list fetch fails due to no network connectivity. Command Code models are fetched live when online; if the provider starts offline, run `/reload` once connected to refresh the model catalog. +- 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 diff --git a/README.md b/README.md index a07c288..f1073c9 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ On startup, the provider fetches: 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 `~/.commandcode/pi-models.json`. 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 location with `COMMANDCODE_MODELS_CACHE`. ## Pricing diff --git a/index.ts b/index.ts index 5fa47e1..0ebe19c 100644 --- a/index.ts +++ b/index.ts @@ -17,11 +17,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" -import { DEFAULT_MODELS_URL, fetchCommandCodeModels, type CommandCodeModel } from "./src/models.ts" +import { DEFAULT_MODELS_URL, loadCommandCodeModels } from "./src/models.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts" const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL +const MODELS_CACHE_PATH = process.env.COMMANDCODE_MODELS_CACHE type CommandCodeModelCost = { input: number @@ -79,12 +80,12 @@ const streamCommandCode = createStreamCommandCode({ // --------------------------------------------------------------------------- export default async function (pi: ExtensionAPI) { - let models: readonly CommandCodeModel[] - try { - models = await fetchCommandCodeModels({ url: MODELS_URL }) - } catch { - models = [] - } + const { models, warning } = await loadCommandCodeModels({ + url: MODELS_URL, + cachePath: MODELS_CACHE_PATH, + }) + + if (warning) console.warn(`[commandcode] ${warning}`) pi.registerProvider("commandcode", { name: "Command Code", diff --git a/src/models.ts b/src/models.ts index c4be26f..237b003 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,6 +1,11 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import { homedir } from "node:os" +import { dirname, join } from "node:path" + export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 +const MODEL_CACHE_VERSION = 1 interface ApiModel { id: string @@ -21,19 +26,39 @@ interface FetchCommandCodeModelsOptions { 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 { - return typeof value === "object" && value !== null + return typeof value === "object" && value !== null && !Array.isArray(value) } function stringField(record: Record, key: string): string { 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 } -function numberField(record: Record, key: string): number { +function booleanField(record: Record, key: string): boolean { 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, 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 } @@ -43,10 +68,35 @@ function parseApiModel(value: unknown): ApiModel { return { id: stringField(value, "id"), 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 defaultCommandCodeModelsCachePath(): string { + return join(homedir(), ".commandcode", "pi-models.json") +} + export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { 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'") @@ -63,6 +113,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( options: FetchCommandCodeModelsOptions = {}, ): Promise { @@ -81,5 +141,70 @@ export async function fetchCommandCodeModels( } const body: unknown = await response.json() - return commandCodeModelsFromApiResponse(body) + return requireModels(commandCodeModelsFromApiResponse(body)) +} + +async function readCommandCodeModelsCache(cachePath: string): Promise { + 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 { + 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 { + const cachePath = options.cachePath ?? defaultCommandCodeModelsCachePath() + + 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.`, + } + } + } } diff --git a/tests/test-models.ts b/tests/test-models.ts index 15ee3fd..aed6dfe 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -1,36 +1,190 @@ 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 { commandCodeModelsFromApiResponse } from "../src/models.ts" +import { + commandCodeModelsFromApiResponse, + commandCodeModelsFromCache, + loadCommandCodeModels, + type CommandCodeModel, +} from "../src/models.ts" + +const API_RESPONSE = { + object: "list", + data: [ + { + id: "Qwen/Qwen3.7-Max", + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "Qwen 3.7 Max", + context_length: 1_000_000, + }, + ], +} + +const EXPECTED_MODELS: readonly CommandCodeModel[] = [ + { + id: "Qwen/Qwen3.7-Max", + name: "Qwen 3.7 Max (CC)", + reasoning: true, + contextWindow: 1_000_000, + 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, +): Promise { + 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", () => { - const models = commandCodeModelsFromApiResponse({ - object: "list", - data: [ - { - id: "Qwen/Qwen3.7-Max", - object: "model", - created: 1779824324, - owned_by: "command-code", - name: "Qwen 3.7 Max", - context_length: 1_000_000, - }, - ], - }) - - assert.deepEqual(models, [ - { - id: "Qwen/Qwen3.7-Max", - name: "Qwen 3.7 Max (CC)", - reasoning: true, - contextWindow: 1_000_000, - maxTokens: 65_536, - }, - ]) + assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS) }) it("rejects unexpected API shapes", () => { 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("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") + } + }) + }) +}) diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 617025a..ffe1687 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -6,24 +6,16 @@ import assert from "node:assert/strict" import { spawn, spawnSync } from "node:child_process" -import { - accessSync, - constants, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from "node:fs" +import { accessSync, constants, mkdtempSync, rmSync } from "node:fs" 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 { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const PROJECT_DIR = resolve(__dirname, "..") const EXT_PATH = resolve(PROJECT_DIR, "index.ts") -const TEST_MODEL = "deepseek/deepseek-v4-flash" +const TEST_MODEL = "cc-offline-cache-model" function findPiBinary() { if (process.env.PI_BIN) return process.env.PI_BIN @@ -77,7 +69,7 @@ const server = createServer((req, res) => { context_length: 1_000_000, }, { - id: "Qwen/Qwen3.7-Max", + id: "cc-second-model", object: "model", created: 1779824324, owned_by: "command-code", @@ -132,32 +124,15 @@ const address = server.address() const port = typeof address === "object" && address ? address.port : 0 const apiBase = `http://127.0.0.1:${port}` -function hasLivePiAuth() { - 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 tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-")) const env = { ...process.env, + HOME: tempHome, + USERPROFILE: tempHome, COMMANDCODE_API_BASE: apiBase, + COMMANDCODE_API_KEY: "mock-key", 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" + COMMANDCODE_MODELS_CACHE: join(tempHome, "commandcode-models.json"), } function runPi(args, timeoutMs = 30_000) { @@ -291,16 +266,64 @@ async function runRpcQuery(timeoutMs = 30_000) { } 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" + rmSync(env.COMMANDCODE_MODELS_CACHE, { 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/) + env.COMMANDCODE_MODELS_URL = onlineModelsUrl + console.log("[pi-local] list models through real extension") modelListRequestCount = 0 const list = await runPi(["--no-extensions", "-e", EXT_PATH, "--list-models"], 20_000) assert.equal(list.code, 0, list.stderr) const listOutput = list.stdout || list.stderr assert.match(listOutput, /commandcode/) - assert.match(listOutput, /deepseek\/deepseek-v4-flash/) - assert.match(listOutput, /Qwen\/Qwen3\.7-Max/) + assert.match(listOutput, /cc-offline-cache-model/) + assert.match(listOutput, /cc-second-model/) assert.equal(modelListRequestCount, 1) + 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") requestCount = 0 const print = await runPi( @@ -347,5 +370,5 @@ try { console.log("[pi-local] PASS") } finally { await new Promise((resolve) => server.close(resolve)) - if (tempHome) rmSync(tempHome, { recursive: true, force: true }) + rmSync(tempHome, { recursive: true, force: true }) }