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:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
### Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -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 `~/.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
|
## Pricing
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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, type CommandCodeModel } 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
|
||||||
|
|
||||||
type CommandCodeModelCost = {
|
type CommandCodeModelCost = {
|
||||||
input: number
|
input: number
|
||||||
@@ -79,12 +80,12 @@ const streamCommandCode = createStreamCommandCode({
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default async function (pi: ExtensionAPI) {
|
export default async function (pi: ExtensionAPI) {
|
||||||
let models: readonly CommandCodeModel[]
|
const { models, warning } = await loadCommandCodeModels({
|
||||||
try {
|
url: MODELS_URL,
|
||||||
models = await fetchCommandCodeModels({ url: MODELS_URL })
|
cachePath: MODELS_CACHE_PATH,
|
||||||
} catch {
|
})
|
||||||
models = []
|
|
||||||
}
|
if (warning) console.warn(`[commandcode] ${warning}`)
|
||||||
|
|
||||||
pi.registerProvider("commandcode", {
|
pi.registerProvider("commandcode", {
|
||||||
name: "Command Code",
|
name: "Command Code",
|
||||||
|
|||||||
+131
-6
@@ -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"
|
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 +26,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 +68,35 @@ 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 defaultCommandCodeModelsCachePath(): string {
|
||||||
|
return join(homedir(), ".commandcode", "pi-models.json")
|
||||||
|
}
|
||||||
|
|
||||||
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 +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(
|
export async function fetchCommandCodeModels(
|
||||||
options: FetchCommandCodeModelsOptions = {},
|
options: FetchCommandCodeModelsOptions = {},
|
||||||
): Promise<readonly CommandCodeModel[]> {
|
): Promise<readonly CommandCodeModel[]> {
|
||||||
@@ -81,5 +141,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 ?? 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.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+161
-7
@@ -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,158 @@ 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("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 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,32 +124,15 @@ 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,
|
||||||
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`,
|
||||||
}
|
COMMANDCODE_MODELS_CACHE: join(tempHome, "commandcode-models.json"),
|
||||||
|
|
||||||
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) {
|
||||||
@@ -291,16 +266,64 @@ 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"
|
||||||
|
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")
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
const print = await runPi(
|
const print = await runPi(
|
||||||
@@ -347,5 +370,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 })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user