Replace the previous implementation with one that registers the Provider API catalog through pi's own provider layer instead of shipping a custom transport, cache file, and hand-maintained pricing table. - models: derive the catalog from the published command-code CLI package (context windows, reasoning efforts, image input, output limits, rates) and keep it as the offline baseline; scripts/sync-catalog.mjs regenerates it and supports --check - refresh: use refreshModels plus context.publish so pi persists the live /provider/v1/models listing in models-store.json and restores it offline - auth: /login browser transfer through a localhost callback server with a pasted-key fallback; $COMMAND_CODE_API_KEY, --api-key and auth.json keep working - streaming: pi's native openai-completions and anthropic-messages adapters; the generate-transport fallback and Oh My Pi branches are gone - keep the context-overflow rewrite that enables pi's compaction retry and the /commandcode-quota command - tests: 51 cases under tests/<module>/ covering models, catalog sync, auth, the callback server, overflow handling, quota, and the extension factory Verified against the live API: chat, tool round trip, image input and --thinking max on deepseek/deepseek-v4.1-flash, quota output, and catalog persistence in an interactive session.
286 lines
10 KiB
JavaScript
286 lines
10 KiB
JavaScript
/**
|
|
* Regenerates src/catalog.ts from the published Command Code CLI package.
|
|
*
|
|
* The CLI ships the authoritative model reference (ids, context windows,
|
|
* reasoning efforts, advertised rates) and its bundle carries the
|
|
* input-modality and max-output metadata that the Provider API does not
|
|
* expose. Run `npm run sync:catalog` after Command Code publishes a new CLI
|
|
* release; review the printed diff before committing the regenerated file.
|
|
*
|
|
* Usage:
|
|
* node scripts/sync-catalog.mjs [--version 1.54.0] [--check]
|
|
*/
|
|
|
|
import { execFile } from "node:child_process"
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
import { tmpdir } from "node:os"
|
|
import { dirname, join } from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { promisify } from "node:util"
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..")
|
|
const catalogPath = join(projectRoot, "src", "catalog.ts")
|
|
const modelsReferencePath = "package/dist/bundled/command-code-knowledge/reference/models.md"
|
|
const cliBundlePath = "package/dist/cli.mjs"
|
|
const validEfforts = ["minimal", "low", "medium", "high", "xhigh", "max"]
|
|
|
|
function parseArguments(argv) {
|
|
const options = { version: undefined, check: false }
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index]
|
|
if (argument === "--check") options.check = true
|
|
else if (argument === "--version") {
|
|
options.version = argv[index + 1]
|
|
index += 1
|
|
} else throw new Error(`Unknown argument: ${argument}`)
|
|
}
|
|
return options
|
|
}
|
|
|
|
/** Parse one `| \`id\` | Name | Context | Efforts | Rates | Min plan | Notes |` row. */
|
|
export function parseReferenceRow(line) {
|
|
const match = /^\|\s*`([^`]+)`\s*\|([^|]*)\|([^|]*)\|([^|]*)\|([^|]*)\|/.exec(line)
|
|
if (!match) return undefined
|
|
const [, id, name, context, efforts, rates] = match
|
|
const effortText = efforts.trim()
|
|
return {
|
|
id: id.trim(),
|
|
name: name.trim(),
|
|
context: context.trim(),
|
|
efforts: effortText === "—" || effortText === "" ? [] : effortText.split(",").map((e) => e.trim()),
|
|
rates: rates.trim(),
|
|
}
|
|
}
|
|
|
|
/** `$0.66/$1.98 · cache $0.022 (write $2.5)` → per-million-token rates. */
|
|
export function parseRates(text) {
|
|
const match = /\$([\d.]+)\s*\/\s*\$([\d.]+)\s*·\s*cache\s*\$([\d.]+)(?:\s*\(write\s*\$([\d.]+)\))?/.exec(text)
|
|
if (!match) throw new Error(`Could not parse rates: ${text}`)
|
|
return {
|
|
input: Number(match[1]),
|
|
output: Number(match[2]),
|
|
cacheRead: Number(match[3]),
|
|
cacheWrite: match[4] === undefined ? 0 : Number(match[4]),
|
|
}
|
|
}
|
|
|
|
export function parseReference(markdown) {
|
|
const models = []
|
|
for (const line of markdown.split("\n")) {
|
|
const row = parseReferenceRow(line)
|
|
if (!row) continue
|
|
if (models.some((model) => model.id === row.id)) throw new Error(`Duplicate model id: ${row.id}`)
|
|
for (const effort of row.efforts) {
|
|
if (!validEfforts.includes(effort)) throw new Error(`Unknown effort for ${row.id}: ${effort}`)
|
|
}
|
|
models.push({ ...row, cost: parseRates(row.rates) })
|
|
}
|
|
if (models.length === 0) throw new Error("No model rows found in the Command Code reference")
|
|
return models
|
|
}
|
|
|
|
/**
|
|
* Slice the `{id:"<id>",inputModalities:...}` object literal out of the
|
|
* minified CLI bundle, tracking nested braces and string literals.
|
|
*/
|
|
export function sliceModelObject(bundle, id) {
|
|
const start = bundle.indexOf(`{id:${JSON.stringify(id)},inputModalities:`)
|
|
if (start < 0) return undefined
|
|
|
|
let depth = 0
|
|
let quote = ""
|
|
let escaped = false
|
|
for (let index = start; index < bundle.length; index += 1) {
|
|
const character = bundle[index]
|
|
if (quote) {
|
|
if (escaped) escaped = false
|
|
else if (character === "\\") escaped = true
|
|
else if (character === quote) quote = ""
|
|
continue
|
|
}
|
|
if (character === '"' || character === "'" || character === "`") {
|
|
quote = character
|
|
} else if (character === "{") {
|
|
depth += 1
|
|
} else if (character === "}") {
|
|
depth -= 1
|
|
if (depth === 0) return bundle.slice(start, index + 1)
|
|
}
|
|
}
|
|
throw new Error(`Unterminated model object for ${id}`)
|
|
}
|
|
|
|
function numberField(entry, key) {
|
|
const match = new RegExp(`${key}:(-?[0-9][0-9.e+]*)`).exec(entry)
|
|
if (!match) return undefined
|
|
const value = Number(match[1])
|
|
return Number.isFinite(value) ? value : undefined
|
|
}
|
|
|
|
function stringArrayField(entry, key) {
|
|
const match = new RegExp(`${key}:\\[([^\\]]*)\\]`).exec(entry)
|
|
if (!match) return undefined
|
|
return [...match[1].matchAll(/"([^"]*)"/g)].map((item) => item[1])
|
|
}
|
|
|
|
export function parseBundleMetadata(bundle, ids) {
|
|
const metadata = new Map()
|
|
for (const id of ids) {
|
|
const entry = sliceModelObject(bundle, id)
|
|
if (!entry) continue
|
|
const input = stringArrayField(entry, "inputModalities") ?? ["text"]
|
|
metadata.set(id, {
|
|
input,
|
|
reasoning: entry.includes("reasoning:!0") || entry.includes("reasoningEfforts:["),
|
|
maxOutputTokens: numberField(entry, "maxOutputTokens"),
|
|
contextWindow: numberField(entry, "contextWindow"),
|
|
})
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
export function buildCatalog(version, reference, metadata) {
|
|
return reference.map((model) => {
|
|
const bundle = metadata.get(model.id)
|
|
if (!bundle) throw new Error(`No CLI bundle metadata found for ${model.id}`)
|
|
const efforts = model.efforts
|
|
return {
|
|
id: model.id,
|
|
name: model.name,
|
|
contextWindow: bundle.contextWindow ?? 0,
|
|
efforts,
|
|
reasoning: bundle.reasoning || model.efforts.length > 0,
|
|
input: bundle.input,
|
|
maxOutputTokens: bundle.maxOutputTokens ?? 0,
|
|
cost: model.cost,
|
|
}
|
|
})
|
|
}
|
|
|
|
function renderCatalog(version, models) {
|
|
const lines = []
|
|
lines.push("/**")
|
|
lines.push(" * Generated by scripts/sync-catalog.mjs from the published Command Code CLI")
|
|
lines.push(" * package. Do not edit manually: run `npm run sync:catalog` instead.")
|
|
lines.push(" */")
|
|
lines.push("")
|
|
lines.push(`export const COMMAND_CODE_CLI_VERSION = ${JSON.stringify(version)}`)
|
|
lines.push("")
|
|
lines.push('export type CommandCodeInputModality = "text" | "image"')
|
|
lines.push("")
|
|
lines.push('export type CommandCodeReasoningEffort =')
|
|
for (const effort of validEfforts) lines.push(` | ${JSON.stringify(effort)}`)
|
|
lines.push("")
|
|
lines.push("export interface CommandCodeModelCost {")
|
|
lines.push(" input: number")
|
|
lines.push(" output: number")
|
|
lines.push(" cacheRead: number")
|
|
lines.push(" cacheWrite: number")
|
|
lines.push("}")
|
|
lines.push("")
|
|
lines.push("export interface CatalogModel {")
|
|
lines.push(" id: string")
|
|
lines.push(" name: string")
|
|
lines.push(" contextWindow: number")
|
|
lines.push(" efforts: readonly CommandCodeReasoningEffort[]")
|
|
lines.push(" reasoning: boolean")
|
|
lines.push(" input: readonly CommandCodeInputModality[]")
|
|
lines.push(" maxOutputTokens: number")
|
|
lines.push(" cost: CommandCodeModelCost")
|
|
lines.push("}")
|
|
lines.push("")
|
|
lines.push("export const CATALOG: readonly CatalogModel[] = [")
|
|
for (const model of models) {
|
|
lines.push(" {")
|
|
lines.push(` id: ${JSON.stringify(model.id)},`)
|
|
lines.push(` name: ${JSON.stringify(model.name)},`)
|
|
lines.push(` contextWindow: ${model.contextWindow},`)
|
|
lines.push(` efforts: [${model.efforts.map((effort) => JSON.stringify(effort)).join(", ")}],`)
|
|
lines.push(` reasoning: ${model.reasoning},`)
|
|
lines.push(` input: [${model.input.map((item) => JSON.stringify(item)).join(", ")}],`)
|
|
lines.push(` maxOutputTokens: ${model.maxOutputTokens},`)
|
|
lines.push(
|
|
` cost: { input: ${model.cost.input}, output: ${model.cost.output}, cacheRead: ${model.cost.cacheRead}, cacheWrite: ${model.cost.cacheWrite} },`,
|
|
)
|
|
lines.push(" },")
|
|
}
|
|
lines.push("]")
|
|
lines.push("")
|
|
return lines.join("\n")
|
|
}
|
|
|
|
async function latestVersion() {
|
|
const { stdout } = await execFileAsync("npm", ["view", "command-code", "version"], { encoding: "utf-8" })
|
|
return stdout.trim()
|
|
}
|
|
|
|
async function downloadCli(version, directory) {
|
|
const { stdout } = await execFileAsync("npm", ["pack", `command-code@${version}`, "--silent"], {
|
|
cwd: directory,
|
|
encoding: "utf-8",
|
|
})
|
|
const tarball = stdout.trim().split("\n").pop()
|
|
if (!tarball) throw new Error("npm pack did not report a tarball")
|
|
await execFileAsync("tar", ["xzf", tarball], { cwd: directory })
|
|
return {
|
|
reference: await readFile(join(directory, modelsReferencePath), "utf-8"),
|
|
bundle: await readFile(join(directory, cliBundlePath), "utf-8"),
|
|
}
|
|
}
|
|
|
|
function summarize(previous, next) {
|
|
const parse = (content) =>
|
|
content
|
|
? new Map(
|
|
[...content.matchAll(/^ {4}id: "([^"]+)",$/gm)].map((match) => [match[1], match[1]]),
|
|
)
|
|
: new Map()
|
|
const before = parse(previous)
|
|
const after = parse(next)
|
|
const added = [...after.keys()].filter((id) => !before.has(id))
|
|
const removed = [...before.keys()].filter((id) => !after.has(id))
|
|
return { added, removed }
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArguments(process.argv.slice(2))
|
|
const version = options.version ?? (await latestVersion())
|
|
const directory = await mkdtemp(join(tmpdir(), "commandcode-catalog-"))
|
|
|
|
try {
|
|
const { reference, bundle } = await downloadCli(version, directory)
|
|
const models = parseReference(reference)
|
|
const metadata = parseBundleMetadata(
|
|
bundle,
|
|
models.map((model) => model.id),
|
|
)
|
|
const catalog = buildCatalog(version, models, metadata)
|
|
const previous = await readFile(catalogPath, "utf-8").catch(() => undefined)
|
|
const next = renderCatalog(version, catalog)
|
|
const { added, removed } = summarize(previous, next)
|
|
|
|
console.log(`command-code@${version}: ${catalog.length} models`)
|
|
console.log(`added: ${added.length > 0 ? added.join(", ") : "none"}`)
|
|
console.log(`removed: ${removed.length > 0 ? removed.join(", ") : "none"}`)
|
|
|
|
if (options.check) {
|
|
if (previous !== next) {
|
|
console.error("src/catalog.ts is out of date; run `npm run sync:catalog`.")
|
|
process.exitCode = 1
|
|
}
|
|
return
|
|
}
|
|
|
|
await mkdir(dirname(catalogPath), { recursive: true })
|
|
await writeFile(catalogPath, next, "utf-8")
|
|
console.log(`wrote ${catalogPath}`)
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
await main()
|
|
}
|