fix(models): cache catalog for offline startup
Persist the last valid Command Code model catalog and use it when live model discovery fails. Keep first-time offline startup non-fatal, surface clear warnings, and cover cached model selection with unit and pi integration regression tests.
This commit is contained in:
+178
-24
@@ -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<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", () => {
|
||||
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")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+60
-37
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user