diff --git a/.agents/skills/pi-commandcode-release/SKILL.md b/.agents/skills/pi-commandcode-release/SKILL.md deleted file mode 100644 index 397814c..0000000 --- a/.agents/skills/pi-commandcode-release/SKILL.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -name: pi-commandcode-release -description: Use when preparing, validating, publishing, or documenting a pi-commandcode-provider release/deployment, including version bumps, changelog entries, npm publish, GitHub releases, tags, and release follow-up comments. ---- - -# pi-commandcode-provider Release Skill - -Use this skill for every `pi-commandcode-provider` release. - -## Core rule - -Keep release work explicit and auditable. Do not publish, tag, push, or merge unless the user explicitly asks in the current conversation. - -## Required release order - -Preferred order for stable releases: - -1. Create a release branch with the version/changelog changes. -2. Open a release PR. -3. Wait for CI to pass. -4. Create a local annotated git tag for the release commit while the PR is still open. -5. Publish the npm package from the release branch/commit. -6. Create the GitHub Release from the release tag. -7. Verify npm and the GitHub Release. -8. Merge the release PR into `main`. -9. Move/recreate the git tag on the merge commit if the project requires tags to point at `main`, then force-update only after explicit user approval. Prefer documenting the tag target instead of force-updating. -10. Comment on included PRs/issues after the package and GitHub Release are live. - -If branch protection or repository policy requires tags to point at `main`, ask before changing the order or force-updating a tag. - -## Version and changelog - -- Increase semver appropriately; for a patch release use `npm version patch --no-git-tag-version`. -- Update `CHANGELOG.md` in the same PR. -- Add a dated section for the new version. -- Include user-facing changes and dependency/security fixes. -- Include a `Contributors` subsection for every release that had external reports, PRs, testing, or issue validation. - -Example changelog structure: - -```md -## 0.4.1 - 2026-06-16 - -- Fix provider registration for newer pi versions. -- Resolve npm audit findings. - -### Contributors - -- @user-a — fixed provider registration. -- @user-b — reported/validated retry behavior. -``` - -## Validation - -Run the checks from `RELEASE.md`. For this repo, at minimum: - -```sh -npm test -npm run typecheck -npm run format:check -npm audit --audit-level=moderate -npm pack --dry-run -git diff --check -``` - -If local pi auth makes `test:pi-local` use the wrong provider state, rerun the suite with a harmless mock Command Code API key in the environment instead of real auth. - -If a live-auth test is needed and the user explicitly approves using local/server auth, use the repo helper if present. Never print API keys. - -## GitHub Release notes - -Always include: - -- Summary of changes. -- Contributors section with GitHub handles. -- Validation section. -- Links to relevant PRs/issues. - -## npm publish - -Publish manually/local from the checked-out release commit: - -```sh -npm publish --tag latest --access public -``` - -Verify: - -```sh -npm view pi-commandcode-provider version dist-tags --json -npm view pi-commandcode-provider@ version --json -``` - -If npm auth fails, stop and ask the user to log in; do not read token files. - -## Follow-up comments - -After npm and GitHub Release are live, comment only on PRs/issues actually included in the release: - -```txt -Shipped in `pi-commandcode-provider@` / GitHub release `v`. -``` diff --git a/.agents/skills/refresh-model-catalog/SKILL.md b/.agents/skills/refresh-model-catalog/SKILL.md deleted file mode 100644 index 25140ba..0000000 --- a/.agents/skills/refresh-model-catalog/SKILL.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -name: refresh-model-catalog -description: Use when adding or removing Command Code models, refreshing the model catalog snapshot (image, reasoning, effort, output-limit metadata), updating display pricing, or refreshing test fixtures in pi-commandcode-provider. ---- - -# Refresh Model Catalog - -Use this skill whenever the Command Code model catalog changes: new or retired models, changed reasoning efforts, output limits, or pricing. All commands run from the repository root and work on Windows and Linux. - -## Core rules - -- Do not commit, tag, push, or publish unless the user explicitly asks in the current conversation. -- Pricing is manually reviewed: temporary promotions and long-context tiers require explicit review of the official pricing page. Never copy prices blindly from the API. -- Keep the change focused: one refresh per PR, no unrelated refactors. -- Follow [CONTRIBUTING.md](../../../CONTRIBUTING.md) for commit message rules. - -## Workflow - -### 1. Detect drift - -```sh -npm run check:commandcode-catalog -``` - -This compares the repository snapshot against the latest published `command-code` npm package and reports added/removed models, changed efforts, and version drift. Use the report to scope the work. - -### 2. Sync static model metadata - -```sh -npm run sync:commandcode-catalog -``` - -Regenerates `src/commandcode-catalog.ts` and bumps the documented CLI version in `README.md`. Review the diff; the catalog also lists reasoning models without selectable efforts. - -Never add efforts to the generated file by hand. Manual effort policy for reasoning models that upstream ships without levels lives in `src/commandcode-catalog-overrides.ts` and is merged at load time. When the sync report lists a model from that file under "New effort metadata", remove its override; `tests/test-models.ts` fails until you do. - -### 3. Update display pricing (manual review) - -Fetch and compare against `src/pricing.ts`: - -- Add entries for new models and remove entries for retired models. Missing models silently display zero cost, so `MODEL_COSTS` must cover the full catalog. -- The pricing page's "Cache Read"/"Cache Write" columns map to `cacheRead`/`cacheWrite`; a "—" column means `0`. -- Update `PRICING_LAST_VERIFIED` to today's date. -- Add or update `TEMPORARY_PRICING` entries for promotions with an end date, so tests fail when they expire. - -### 4. Refresh the test fixtures - -```sh -node .agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs -npx tsx .agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts -``` - -The first script snapshots the live model-id list into `tests/fixtures/commandcode-model-ids.json`; the second regenerates `tests/fixtures/commandcode-pricing.json` from `MODEL_COSTS`. The pricing test fails until `MODEL_COSTS` matches the catalog snapshot exactly. - -### 5. Update test expectations - -Adjust the model-specific assertions that the refresh invalidated, typically in: - -- `tests/test-pricing.ts`: fixture date assertions, the `freeModels` set, and per-model rate assertions. -- `tests/test-models.ts`: image/reasoning/effort/output-limit assertions and catalog entry counts. - -Do not weaken assertions to make them pass; update them to the verified upstream values. - -### 6. Validate - -```sh -npm run test:models -npm run test:pricing -npm run typecheck -npm run format:check -git diff --check -``` - -Run the full `npm test` before reporting the work as done when the environment allows it. - -### 7. Document - -Add entries to the `Unreleased` section of `CHANGELOG.md` covering new/retired models, effort changes, and pricing refreshes. diff --git a/.agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs b/.agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs deleted file mode 100644 index f9be878..0000000 --- a/.agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -// Refreshes tests/fixtures/commandcode-model-ids.json from the live Command Code -// models API. Run from the repository root: -// node .agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs -import { writeFile } from "node:fs/promises" - -import { format, resolveConfig } from "prettier" - -const MODELS_URL = "https://api.commandcode.ai/provider/v1/models" -const FIXTURE_PATH = new URL( - "../../../../tests/fixtures/commandcode-model-ids.json", - import.meta.url, -) - -const response = await fetch(MODELS_URL) -if (!response.ok) { - throw new Error(`Failed to fetch Command Code models: ${response.status} ${response.statusText}`) -} - -const body = await response.json() -if (body?.object !== "list" || !Array.isArray(body.data)) { - throw new Error("Expected a Command Code models list response") -} - -const modelIds = body.data.map((model) => { - if (typeof model?.id !== "string" || model.id.length === 0) { - throw new Error("Expected each model entry to have a non-empty id") - } - return model.id -}) -if (modelIds.length === 0) throw new Error("Command Code returned an empty model catalog") - -const fixture = { fetchedAt: new Date().toISOString(), source: MODELS_URL, modelIds } -const options = await resolveConfig(new URL("../../../../.prettierrc.json", import.meta.url)) -const contents = await format(JSON.stringify(fixture), { - ...options, - filepath: "commandcode-model-ids.json", -}) -await writeFile(FIXTURE_PATH, contents, "utf-8") -console.log(`Wrote ${modelIds.length} model ids to tests/fixtures/commandcode-model-ids.json`) diff --git a/.agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts b/.agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts deleted file mode 100644 index 21d8773..0000000 --- a/.agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Regenerates tests/fixtures/commandcode-pricing.json from src/pricing.ts so the -// snapshot always matches MODEL_COSTS. Run from the repository root: -// npx tsx .agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts -import { writeFile } from "node:fs/promises" - -import { format, resolveConfig } from "prettier" - -import { MODEL_COSTS, PRICING_LAST_VERIFIED, PRICING_SOURCE_URL } from "../../../../src/pricing.ts" - -const FIXTURE_PATH = new URL("../../../../tests/fixtures/commandcode-pricing.json", import.meta.url) - -const costs: Record = {} -const tiers: Record = {} -for (const [modelId, cost] of Object.entries(MODEL_COSTS)) { - costs[modelId] = [cost.input, cost.output, cost.cacheRead, cost.cacheWrite] - if (cost.tiers) { - tiers[modelId] = cost.tiers.map((tier) => [ - tier.inputTokensAbove, - tier.input, - tier.output, - tier.cacheRead, - tier.cacheWrite, - ]) - } -} - -const fixture = { - verifiedAt: PRICING_LAST_VERIFIED, - source: PRICING_SOURCE_URL, - tierPolicy: - "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.", - tiers, - costs, -} -const options = await resolveConfig(new URL("../../../../.prettierrc.json", import.meta.url)) -const contents = await format(JSON.stringify(fixture), { - ...options, - filepath: "commandcode-pricing.json", -}) -await writeFile(FIXTURE_PATH, contents, "utf-8") -console.log( - `Wrote ${Object.keys(costs).length} model prices to tests/fixtures/commandcode-pricing.json`, -) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 05c7e5b..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,21 +0,0 @@ -# Code ownership for security-sensitive paths -# These files require maintainer review on every PR - -# Security-critical: auth, secrets, OAuth flow -/src/auth-server.ts @patlux -/src/oauth.ts @patlux -/src/converters.ts @patlux - -# CI/CD pipeline -.github/workflows/ @patlux - -# Dependencies -package.json @patlux -package-lock.json @patlux - -# Security tooling -.semgrep/ @patlux -.gitleaks.toml @patlux - -# This file itself -.github/CODEOWNERS @patlux diff --git a/.github/scripts/check-commandcode-model-metadata.ts b/.github/scripts/check-commandcode-model-metadata.ts deleted file mode 100644 index 543e8e0..0000000 --- a/.github/scripts/check-commandcode-model-metadata.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { execFile } from "node:child_process" -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join, resolve } from "node:path" -import { pathToFileURL } from "node:url" -import { promisify } from "node:util" - -import { - COMMAND_CODE_CLI_VERSION, - MODEL_EFFORTS, - MODEL_INPUT_MODALITIES, - MODEL_MAX_OUTPUT_TOKENS, - MODEL_REASONING, -} from "../../src/commandcode-catalog.ts" - -const execFileAsync = promisify(execFile) -const MODELS_REFERENCE_PATH = "dist/bundled/command-code-knowledge/reference/models.md" -const CLI_BUNDLE_PATH = "dist/cli.mjs" -const TEXT_ONLY_MARKER = ',__name(isKnownTextOnlyModel,"isKnownTextOnlyModel")' -const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]) - -function quoteWindowsArgument(argument: string): string { - if (argument.length === 0) return '""' - if (!/[\s"]/.test(argument)) return argument - return `"${argument.replaceAll('"', '\\"')}"` -} - -/** - * Run npm on Windows and POSIX. npm is a `.cmd` shim on Windows, which - * execFile cannot spawn directly, so route it through the shell there with - * shell-safe quoting. - */ -function execNpmFileAsync( - args: readonly string[], - options: { cwd: string; encoding: "utf-8"; maxBuffer?: number }, -): Promise<{ stdout: string }> { - if (process.platform !== "win32") return execFileAsync("npm", args, options) - const command = ["npm", ...args.map(quoteWindowsArgument)].join(" ") - return execFileAsync(command, { ...options, shell: true }) -} -const CATALOG_SOURCE_PATH = new URL("../../src/commandcode-catalog.ts", import.meta.url) -const README_PATH = new URL("../../README.md", import.meta.url) - -export interface CommandCodeModelMetadata { - imageModelIds: readonly string[] - reasoningModelIds: readonly string[] - reasoningEfforts: Readonly> - maxOutputTokens: Readonly> -} - -export interface ModelMetadataDiff { - versionChanged: boolean - addedImageModelIds: readonly string[] - removedImageModelIds: readonly string[] - addedReasoningModelIds: readonly string[] - removedReasoningModelIds: readonly string[] - addedEffortModelIds: readonly string[] - removedEffortModelIds: readonly string[] - changedEffortModelIds: readonly string[] - addedMaxOutputModelIds: readonly string[] - removedMaxOutputModelIds: readonly string[] - changedMaxOutputModelIds: readonly string[] -} - -interface PackedPackage { - filename: string -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === "string") -} - -function sorted(values: Iterable): string[] { - return [...values].sort((left, right) => left.localeCompare(right)) -} - -function parsePackedPackage(value: unknown): PackedPackage { - if (!Array.isArray(value) || value.length !== 1 || !isRecord(value[0])) { - throw new Error("Expected npm pack to return one package") - } - - const filename = value[0].filename - if (typeof filename !== "string" || filename.length === 0) { - throw new Error("Expected npm pack to return a tarball filename") - } - - return { filename } -} - -export function parsePackageVersion(value: unknown): string { - if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(value)) { - throw new Error("Expected npm view to return one semantic version") - } - return value -} - -export function parseModelsReference(markdown: string): { - modelIds: readonly string[] - reasoningEfforts: Readonly> -} { - const modelIds = new Set() - const reasoningEfforts: Record = {} - - for (const line of markdown.split("\n")) { - const match = /^\| `([^`]+)` \| [^|]* \| [^|]* \| ([^|]*) \|/.exec(line) - if (!match) continue - - const modelId = match[1] - const effortsColumn = match[2]?.trim() - if (!modelId || !effortsColumn) throw new Error(`Could not parse model row: ${line}`) - if (modelIds.has(modelId)) throw new Error(`Duplicate model id in reference: ${modelId}`) - modelIds.add(modelId) - - if (effortsColumn === "—") continue - - const efforts = effortsColumn.split(",").map((effort) => effort.trim()) - if (efforts.length === 0 || efforts.some((effort) => !VALID_EFFORTS.has(effort))) { - throw new Error(`Unexpected reasoning efforts for ${modelId}: ${effortsColumn}`) - } - reasoningEfforts[modelId] = efforts - } - - if (modelIds.size === 0) throw new Error("No model rows found in Command Code reference") - - return { - modelIds: sorted(modelIds), - reasoningEfforts: Object.fromEntries( - Object.entries(reasoningEfforts).sort(([left], [right]) => left.localeCompare(right)), - ), - } -} - -export function parseKnownTextOnlyModelIds(bundle: string): readonly string[] { - const markerIndex = bundle.indexOf(TEXT_ONLY_MARKER) - if (markerIndex < 0) { - throw new Error("Could not find Command Code's isKnownTextOnlyModel catalog") - } - - const setStart = bundle.lastIndexOf("new Set([", markerIndex) - if (setStart < 0) throw new Error("Could not find the text-only model set") - - const arrayStart = setStart + "new Set(".length - const arrayEnd = markerIndex - 1 - const literal = bundle.slice(arrayStart, arrayEnd) - const parsed: unknown = JSON.parse(literal) - if (!isStringArray(parsed)) throw new Error("Expected the text-only model catalog to be strings") - - return sorted(new Set(parsed)) -} - -function modelObject(bundle: string, modelId: string): string { - const start = bundle.indexOf(`{id:${JSON.stringify(modelId)},inputModalities:`) - if (start < 0) throw new Error(`Could not find model metadata for ${modelId}`) - - 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 - continue - } - if (character === "{") depth += 1 - else if (character === "}" && --depth === 0) return bundle.slice(start, index + 1) - } - - throw new Error(`Unterminated model metadata for ${modelId}`) -} - -export function parseBundleModelCapabilities( - bundle: string, - modelIds: readonly string[], -): { - reasoningModelIds: readonly string[] - maxOutputTokens: Readonly> -} { - const reasoningModelIds: string[] = [] - const maxOutputTokens: Record = {} - - for (const modelId of modelIds) { - const entry = modelObject(bundle, modelId) - if (entry.includes("reasoning:!0") || entry.includes("reasoningEfforts:[")) { - reasoningModelIds.push(modelId) - } - const maxOutput = /maxOutputTokens:([^,}]+)/.exec(entry)?.[1] - if (maxOutput) { - const value = Number(maxOutput) - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`Unexpected max output tokens for ${modelId}: ${maxOutput}`) - } - maxOutputTokens[modelId] = value - } - } - - return { - reasoningModelIds: sorted(reasoningModelIds), - maxOutputTokens: Object.fromEntries( - Object.entries(maxOutputTokens).sort(([left], [right]) => left.localeCompare(right)), - ), - } -} - -export function commandCodeModelMetadataFromContents( - modelsReference: string, - cliBundle: string, -): CommandCodeModelMetadata { - const reference = parseModelsReference(modelsReference) - const textOnlyModelIds = new Set(parseKnownTextOnlyModelIds(cliBundle)) - const capabilities = parseBundleModelCapabilities(cliBundle, reference.modelIds) - - return { - imageModelIds: reference.modelIds.filter((modelId) => !textOnlyModelIds.has(modelId)), - reasoningModelIds: capabilities.reasoningModelIds, - reasoningEfforts: reference.reasoningEfforts, - maxOutputTokens: capabilities.maxOutputTokens, - } -} - -export function currentModelMetadata(): CommandCodeModelMetadata { - return { - imageModelIds: sorted(Object.keys(MODEL_INPUT_MODALITIES)), - reasoningModelIds: sorted(Object.keys(MODEL_REASONING)), - reasoningEfforts: Object.fromEntries( - Object.entries(MODEL_EFFORTS) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([modelId, efforts]) => [modelId, [...efforts]]), - ), - maxOutputTokens: Object.fromEntries( - Object.entries(MODEL_MAX_OUTPUT_TOKENS).sort(([left], [right]) => left.localeCompare(right)), - ), - } -} - -export function diffModelMetadata( - current: CommandCodeModelMetadata, - upstream: CommandCodeModelMetadata, - currentVersion = COMMAND_CODE_CLI_VERSION, - upstreamVersion = COMMAND_CODE_CLI_VERSION, -): ModelMetadataDiff { - const currentImages = new Set(current.imageModelIds) - const upstreamImages = new Set(upstream.imageModelIds) - const currentReasoning = new Set(current.reasoningModelIds) - const upstreamReasoning = new Set(upstream.reasoningModelIds) - const currentEffortIds = Object.keys(current.reasoningEfforts) - const upstreamEffortIds = Object.keys(upstream.reasoningEfforts) - const currentEffortSet = new Set(currentEffortIds) - const upstreamEffortSet = new Set(upstreamEffortIds) - const currentMaxOutputIds = Object.keys(current.maxOutputTokens) - const upstreamMaxOutputIds = Object.keys(upstream.maxOutputTokens) - const currentMaxOutputSet = new Set(currentMaxOutputIds) - const upstreamMaxOutputSet = new Set(upstreamMaxOutputIds) - - return { - versionChanged: currentVersion !== upstreamVersion, - addedImageModelIds: sorted( - upstream.imageModelIds.filter((modelId) => !currentImages.has(modelId)), - ), - removedImageModelIds: sorted( - current.imageModelIds.filter((modelId) => !upstreamImages.has(modelId)), - ), - addedReasoningModelIds: sorted( - upstream.reasoningModelIds.filter((modelId) => !currentReasoning.has(modelId)), - ), - removedReasoningModelIds: sorted( - current.reasoningModelIds.filter((modelId) => !upstreamReasoning.has(modelId)), - ), - addedEffortModelIds: sorted( - upstreamEffortIds.filter((modelId) => !currentEffortSet.has(modelId)), - ), - removedEffortModelIds: sorted( - currentEffortIds.filter((modelId) => !upstreamEffortSet.has(modelId)), - ), - changedEffortModelIds: sorted( - upstreamEffortIds.filter( - (modelId) => - currentEffortSet.has(modelId) && - JSON.stringify(current.reasoningEfforts[modelId]) !== - JSON.stringify(upstream.reasoningEfforts[modelId]), - ), - ), - addedMaxOutputModelIds: sorted( - upstreamMaxOutputIds.filter((modelId) => !currentMaxOutputSet.has(modelId)), - ), - removedMaxOutputModelIds: sorted( - currentMaxOutputIds.filter((modelId) => !upstreamMaxOutputSet.has(modelId)), - ), - changedMaxOutputModelIds: sorted( - upstreamMaxOutputIds.filter( - (modelId) => - currentMaxOutputSet.has(modelId) && - current.maxOutputTokens[modelId] !== upstream.maxOutputTokens[modelId], - ), - ), - } -} - -export function hasModelMetadataDiff(diff: ModelMetadataDiff): boolean { - return ( - diff.versionChanged || - Object.entries(diff).some(([key, modelIds]) => key !== "versionChanged" && modelIds.length > 0) - ) -} - -function formatList(modelIds: readonly string[]): string { - return modelIds.length > 0 ? modelIds.map((modelId) => `\`${modelId}\``).join(", ") : "None" -} - -function formatReasoningChanges( - modelIds: readonly string[], - current: CommandCodeModelMetadata, - upstream: CommandCodeModelMetadata, -): string { - if (modelIds.length === 0) return "None" - return modelIds - .map( - (modelId) => - `\`${modelId}\`: \`${(current.reasoningEfforts[modelId] ?? []).join(", ")}\` → \`${( - upstream.reasoningEfforts[modelId] ?? [] - ).join(", ")}\``, - ) - .join("
") -} - -function quoted(value: string): string { - return JSON.stringify(value) -} - -function recordEntries( - values: Readonly>, -): readonly [string, readonly string[]][] { - return Object.entries(values).sort(([left], [right]) => left.localeCompare(right)) -} - -export function renderCommandCodeCatalog( - packageVersion: string, - metadata: CommandCodeModelMetadata, -): string { - const imageEntries = sorted(metadata.imageModelIds) - .map((modelId) => ` ${quoted(modelId)}: ["text", "image"],`) - .join("\n") - const reasoningEntries = sorted(metadata.reasoningModelIds) - .map((modelId) => ` ${quoted(modelId)}: true,`) - .join("\n") - const effortEntries = recordEntries(metadata.reasoningEfforts) - .map( - ([modelId, efforts]) => - ` ${quoted(modelId)}: [${efforts.map((effort) => quoted(effort)).join(", ")}],`, - ) - .join("\n") - const maxOutputEntries = Object.entries(metadata.maxOutputTokens) - .sort(([left], [right]) => left.localeCompare(right)) - .map( - ([modelId, value]) => - ` ${quoted(modelId)}: ${value.toLocaleString("en-US").replaceAll(",", "_")},`, - ) - .join("\n") - - return `export const COMMAND_CODE_CLI_VERSION = ${quoted(packageVersion)}\n\nexport type CommandCodeInputType = "text" | "image"\nexport type CommandCodeReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"\n\n/**\n * Generated from command-code@${packageVersion} by \`npm run sync:commandcode-catalog\`.\n * Do not edit manually.\n */\nexport const MODEL_INPUT_MODALITIES: Readonly> = {\n${imageEntries}\n}\n\nexport const MODEL_REASONING: Readonly> = {\n${reasoningEntries}\n}\n\nexport const MODEL_EFFORTS: Readonly> = {\n${effortEntries}\n}\n\nexport const MODEL_MAX_OUTPUT_TOKENS: Readonly> = {\n${maxOutputEntries}\n}\n` -} - -function updateDocumentedCatalogVersion( - contents: string, - packageVersion: string, - context: string, -): string { - const pattern = /command-code@\d+\.\d+\.\d+(?:[-+][^`\s,]+)?/ - if (!pattern.test(contents)) throw new Error(`Could not find the ${context} catalog version`) - return contents.replace(pattern, `command-code@${packageVersion}`) -} - -export function updateReadmeCatalogVersion(readme: string, packageVersion: string): string { - return updateDocumentedCatalogVersion(readme, packageVersion, "README") -} - -async function writeSynchronizedCatalog( - packageVersion: string, - metadata: CommandCodeModelMetadata, -): Promise { - const readme = await readFile(README_PATH, "utf-8") - await Promise.all([ - writeFile(CATALOG_SOURCE_PATH, renderCommandCodeCatalog(packageVersion, metadata), "utf-8"), - writeFile(README_PATH, updateReadmeCatalogVersion(readme, packageVersion), "utf-8"), - ]) -} - -function metadataReport( - packageVersion: string, - current: CommandCodeModelMetadata, - upstream: CommandCodeModelMetadata, - diff: ModelMetadataDiff, -): string { - const status = hasModelMetadataDiff(diff) ? "❌ Drift detected" : "✅ Metadata is current" - return [ - "## Command Code static model metadata", - "", - `**${status}**`, - "", - `- Repository snapshot: \`command-code@${COMMAND_CODE_CLI_VERSION}\``, - `- Inspected package: \`command-code@${packageVersion}\``, - `- Image-capable models: ${current.imageModelIds.length} repository / ${upstream.imageModelIds.length} upstream`, - `- Reasoning models: ${current.reasoningModelIds.length} repository / ${upstream.reasoningModelIds.length} upstream`, - `- Models with selectable efforts: ${Object.keys(current.reasoningEfforts).length} repository / ${Object.keys(upstream.reasoningEfforts).length} upstream`, - `- Model-specific output limits: ${Object.keys(current.maxOutputTokens).length} repository / ${Object.keys(upstream.maxOutputTokens).length} upstream`, - "", - "| Change | Models |", - "| --- | --- |", - `| CLI version | ${diff.versionChanged ? `\`${COMMAND_CODE_CLI_VERSION}\` → \`${packageVersion}\`` : "Current"} |`, - `| New image support | ${formatList(diff.addedImageModelIds)} |`, - `| Removed image support | ${formatList(diff.removedImageModelIds)} |`, - `| New reasoning models | ${formatList(diff.addedReasoningModelIds)} |`, - `| Removed reasoning models | ${formatList(diff.removedReasoningModelIds)} |`, - `| New effort metadata | ${formatList(diff.addedEffortModelIds)} |`, - `| Removed effort metadata | ${formatList(diff.removedEffortModelIds)} |`, - `| Changed reasoning efforts | ${formatReasoningChanges(diff.changedEffortModelIds, current, upstream)} |`, - `| New output limits | ${formatList(diff.addedMaxOutputModelIds)} |`, - `| Removed output limits | ${formatList(diff.removedMaxOutputModelIds)} |`, - `| Changed output limits | ${formatList(diff.changedMaxOutputModelIds)} |`, - "", - ].join("\n") -} - -async function resolvePackageSpec( - packageSpec: string, - directory: string, - npmCacheDirectory: string, -): Promise { - if (packageSpec !== "command-code@latest") return packageSpec - - const { stdout } = await execNpmFileAsync( - ["view", packageSpec, "version", "--json", "--prefer-online", "--cache", npmCacheDirectory], - { - cwd: directory, - encoding: "utf-8", - }, - ) - return `command-code@${parsePackageVersion(JSON.parse(stdout) as unknown)}` -} - -async function inspectPackedPackage(packageSpec: string): Promise<{ - packageVersion: string - metadata: CommandCodeModelMetadata -}> { - const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-model-check-")) - const npmCacheDirectory = join(directory, "npm-cache") - - try { - const resolvedPackageSpec = await resolvePackageSpec(packageSpec, directory, npmCacheDirectory) - const { stdout } = await execNpmFileAsync( - ["pack", resolvedPackageSpec, "--json", "--prefer-online", "--cache", npmCacheDirectory], - { - cwd: directory, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - }, - ) - const packed = parsePackedPackage(JSON.parse(stdout) as unknown) - await execFileAsync("tar", ["-xzf", packed.filename], { cwd: directory }) - - const packageDirectory = join(directory, "package") - const packageJsonContents = await readFile(join(packageDirectory, "package.json"), "utf-8") - const packageJson: unknown = JSON.parse(packageJsonContents) - if (!isRecord(packageJson) || typeof packageJson.version !== "string") { - throw new Error("Expected command-code package.json to contain a version") - } - - const [modelsReference, cliBundle] = await Promise.all([ - readFile(join(packageDirectory, MODELS_REFERENCE_PATH), "utf-8"), - readFile(join(packageDirectory, CLI_BUNDLE_PATH), "utf-8"), - ]) - - return { - packageVersion: packageJson.version, - metadata: commandCodeModelMetadataFromContents(modelsReference, cliBundle), - } - } finally { - await rm(directory, { recursive: true, force: true }) - } -} - -async function main(): Promise { - const write = process.argv.includes("--write") - const packageSpec = - process.argv.find((argument) => argument.startsWith("command-code@")) ?? "command-code@latest" - const current = currentModelMetadata() - const upstreamPackage = await inspectPackedPackage(packageSpec) - const diff = diffModelMetadata( - current, - upstreamPackage.metadata, - COMMAND_CODE_CLI_VERSION, - upstreamPackage.packageVersion, - ) - const report = metadataReport( - upstreamPackage.packageVersion, - current, - upstreamPackage.metadata, - diff, - ) - - console.log(report) - - if (write) { - await writeSynchronizedCatalog(upstreamPackage.packageVersion, upstreamPackage.metadata) - console.log(`Synchronized static metadata with command-code@${upstreamPackage.packageVersion}.`) - return - } - - if (hasModelMetadataDiff(diff)) { - throw new Error( - `Static model metadata differs from command-code@${upstreamPackage.packageVersion}. Update src/models.ts and the snapshot version.`, - ) - } -} - -function isMainModule(): boolean { - const entrypoint = process.argv[1] - return entrypoint !== undefined && pathToFileURL(resolve(entrypoint)).href === import.meta.url -} - -if (isMainModule()) { - try { - await main() - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)) - process.exitCode = 1 - } -} diff --git a/.github/scripts/memory-benchmark.mjs b/.github/scripts/memory-benchmark.mjs deleted file mode 100644 index 59d15be..0000000 --- a/.github/scripts/memory-benchmark.mjs +++ /dev/null @@ -1,417 +0,0 @@ -#!/usr/bin/env node - -import { mkdtemp, readFile, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join, resolve } from "node:path" -import { execFile, spawn } from "node:child_process" -import process from "node:process" -import { promisify } from "node:util" - -const execFileAsync = promisify(execFile) - -const KIB_PER_MIB = 1024 -const RUNS = positiveInteger(process.env.MEMORY_BENCHMARK_RUNS, 6) -const WARMUP_MS = positiveInteger(process.env.MEMORY_BENCHMARK_WARMUP_MS, 2500) -const SAMPLE_COUNT = positiveInteger(process.env.MEMORY_BENCHMARK_SAMPLES, 12) -const SAMPLE_INTERVAL_MS = positiveInteger(process.env.MEMORY_BENCHMARK_INTERVAL_MS, 100) - -const basePath = requiredPath("MEMORY_BENCHMARK_BASE_PATH") -const headPath = requiredPath("MEMORY_BENCHMARK_HEAD_PATH") -const piCli = requiredPath("MEMORY_BENCHMARK_PI_CLI") -const outputPath = resolve(process.env.MEMORY_BENCHMARK_OUTPUT ?? "memory-benchmark.md") -const jsonOutputPath = resolve(process.env.MEMORY_BENCHMARK_JSON_OUTPUT ?? "memory-benchmark.json") -const baseSha = process.env.MEMORY_BENCHMARK_BASE_SHA ?? "base" -const headSha = process.env.MEMORY_BENCHMARK_HEAD_SHA ?? "head" -const piVersion = process.env.MEMORY_BENCHMARK_PI_VERSION ?? "unknown" -const runtimeName = process.versions.bun ? "Bun" : "Node" -const runtimeVersion = process.versions.bun ?? process.versions.node - -const benchmarkDir = await mkdtemp(join(tmpdir(), "pi-memory-benchmark-")) -const cachePath = join(benchmarkDir, "commandcode-models.json") -await writeFile( - cachePath, - `${JSON.stringify( - { - version: 1, - models: [ - { - id: "memory-benchmark-model", - name: "Memory Benchmark Model (CC)", - reasoning: true, - contextWindow: 128_000, - maxTokens: 65_536, - }, - ], - }, - null, - 2, - )}\n`, -) - -const variants = { - baseline: { label: "pi without extension", extensionPath: undefined }, - base: { label: `Base (${shortSha(baseSha)})`, extensionPath: basePath }, - head: { label: `PR (${shortSha(headSha)})`, extensionPath: headPath }, -} - -const results = Object.fromEntries(Object.keys(variants).map((key) => [key, []])) - -console.log( - `Benchmarking ${RUNS} alternating rounds with pi ${piVersion}, ${runtimeName} ${runtimeVersion}, ` + - `${SAMPLE_COUNT} samples after ${WARMUP_MS} ms warm-up`, -) - -// Discard one cold run per variant before collecting measurements. -for (const key of ["baseline", "base", "head"]) { - console.log(`Cold warm-up: ${variants[key].label}`) - await measureProcess(variants[key].extensionPath) -} - -for (let round = 0; round < RUNS; round += 1) { - const order = round % 2 === 0 ? ["baseline", "base", "head"] : ["baseline", "head", "base"] - console.log(`Round ${round + 1}/${RUNS}: ${order.map((key) => variants[key].label).join(" → ")}`) - - for (const key of order) { - const measurement = await measureProcess(variants[key].extensionPath) - results[key].push(measurement) - console.log(` ${variants[key].label}: ${formatProgress(measurement)}`) - } -} - -const summary = Object.fromEntries( - Object.entries(results).map(([key, measurements]) => [key, summarize(measurements)]), -) -const comparisons = summarizeComparisons(results) -const report = renderReport(summary, comparisons) - -await writeFile(outputPath, report) -await writeFile( - jsonOutputPath, - `${JSON.stringify( - { - metadata: { - baseSha, - headSha, - piVersion, - runtimeName, - runtimeVersion, - runs: RUNS, - warmupMs: WARMUP_MS, - sampleCount: SAMPLE_COUNT, - sampleIntervalMs: SAMPLE_INTERVAL_MS, - }, - runs: results, - summary, - comparisons, - }, - null, - 2, - )}\n`, -) - -console.log(`Wrote ${outputPath}`) -console.log(`Wrote ${jsonOutputPath}`) - -async function measureProcess(extensionPath) { - const args = [ - piCli, - "--mode", - "rpc", - "--no-session", - "--no-extensions", - "--no-skills", - "--no-prompt-templates", - "--no-themes", - "--no-context-files", - ] - if (extensionPath) args.push("-e", extensionPath) - - const child = spawn(process.execPath, args, { - detached: true, - env: { - ...process.env, - PI_CODING_AGENT_DIR: join(benchmarkDir, "pi-agent"), - PI_OFFLINE: "1", - COMMANDCODE_MODELS_CACHE: cachePath, - COMMANDCODE_MODELS_URL: "http://127.0.0.1:9/provider/v1/models", - }, - stdio: ["pipe", "pipe", "pipe"], - }) - - let stderr = "" - child.stdout.resume() - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf8") - if (stderr.length > 16_384) stderr = stderr.slice(-16_384) - }) - - try { - await wait(WARMUP_MS) - ensureRunning(child, stderr) - - const samples = [] - for (let index = 0; index < SAMPLE_COUNT; index += 1) { - samples.push(await readSampledMemory(child.pid)) - await wait(SAMPLE_INTERVAL_MS) - ensureRunning(child, stderr) - } - - const sampled = Object.fromEntries( - Object.keys(samples[0]).map((metric) => [ - metric, - metric === "peakRss" - ? Math.max(...samples.map((sample) => sample[metric])) - : median(samples.map((sample) => sample[metric])), - ]), - ) - const snapshot = process.platform === "darwin" ? await readDarwinFootprint(child.pid) : {} - return { ...sampled, ...snapshot } - } finally { - stopProcessGroup(child) - await Promise.race([onceExit(child), wait(3000)]) - stopProcessGroup(child, "SIGKILL") - } -} - -async function readSampledMemory(pid) { - if (process.platform === "darwin") { - const { stdout } = await execFileAsync("/bin/ps", ["-o", "rss=", "-p", String(pid)]) - const rssKiB = Number.parseInt(stdout.trim(), 10) - if (!Number.isFinite(rssKiB)) throw new Error(`Could not parse RSS from ps output: ${stdout}`) - return { rss: rssKiB / KIB_PER_MIB } - } - - if (process.platform === "linux") return readLinuxMemory(pid) - throw new Error(`Unsupported memory benchmark platform: ${process.platform}`) -} - -async function readDarwinFootprint(pid) { - const { stdout } = await execFileAsync("/usr/bin/footprint", [ - "-p", - String(pid), - "-f", - "bytes", - "--noCategories", - ]) - const footprint = /Footprint:\s*(\d+) B/.exec(stdout) - const peak = /phys_footprint_peak:\s*(\d+) B/.exec(stdout) - if (!footprint || !peak) throw new Error(`Could not parse macOS footprint output:\n${stdout}`) - - return { - physicalFootprint: Number(footprint[1]) / 1024 / 1024, - physicalPeak: Number(peak[1]) / 1024 / 1024, - } -} - -async function readLinuxMemory(pid) { - const [status, smaps] = await Promise.all([ - readFile(`/proc/${pid}/status`, "utf8"), - readFile(`/proc/${pid}/smaps_rollup`, "utf8"), - ]) - const statusValues = parseKiBFields(status) - const smapsValues = parseKiBFields(smaps) - const privateMemory = - (smapsValues.Private_Clean ?? 0) + - (smapsValues.Private_Dirty ?? 0) + - (smapsValues.Private_Hugetlb ?? 0) - - return { - rss: requireMetric(statusValues, "VmRSS"), - anonymousRss: requireMetric(statusValues, "RssAnon"), - pss: requireMetric(smapsValues, "Pss"), - uss: privateMemory, - peakRss: requireMetric(statusValues, "VmHWM"), - } -} - -function parseKiBFields(contents) { - const result = {} - for (const line of contents.split("\n")) { - const match = /^([A-Za-z_]+):\s+(\d+) kB$/.exec(line.trim()) - if (match) result[match[1]] = Number(match[2]) / KIB_PER_MIB - } - return result -} - -function summarize(measurements) { - return Object.fromEntries( - Object.keys(measurements[0]).map((metric) => { - const values = measurements.map((measurement) => measurement[metric]) - const center = median(values) - return [ - metric, - { median: center, mad: median(values.map((value) => Math.abs(value - center))) }, - ] - }), - ) -} - -function summarizeComparisons(measurements) { - const metrics = Object.keys(measurements.baseline[0]) - const paired = (left, right, metric) => - left.map((measurement, index) => measurement[metric] - right[index][metric]) - const estimate = (values) => { - const center = median(values) - return { median: center, mad: median(values.map((value) => Math.abs(value - center))) } - } - - return Object.fromEntries( - metrics.map((metric) => [ - metric, - { - headMinusBase: estimate(paired(measurements.head, measurements.base, metric)), - baseOverhead: estimate(paired(measurements.base, measurements.baseline, metric)), - headOverhead: estimate(paired(measurements.head, measurements.baseline, metric)), - }, - ]), - ) -} - -function renderReport(summary, comparisons) { - const metrics = - process.platform === "darwin" - ? [ - ["rss", "Stable RSS"], - ["physicalFootprint", "Physical footprint"], - ["physicalPeak", "Physical peak"], - ] - : [ - ["rss", "Stable RSS"], - ["anonymousRss", "Anonymous RSS"], - ["pss", "PSS"], - ["uss", "USS (private memory)"], - ["peakRss", "Peak RSS"], - ] - - const comparisonRows = metrics - .map(([key, label]) => { - const base = summary.base[key] - const head = summary.head[key] - const difference = comparisons[key].headMinusBase - const percentage = base.median === 0 ? 0 : (difference.median / base.median) * 100 - return `| ${label} | ${formatEstimate(base)} | ${formatEstimate(head)} | ${formatSignedEstimate(difference)} | ${formatSigned(percentage, "%")} |` - }) - .join("\n") - - const clearChanges = metrics - .map(([key, label]) => [label, comparisons[key].headMinusBase]) - .filter(([, estimate]) => Math.abs(estimate.median) > estimate.mad) - const interpretation = - clearChanges.length === 0 - ? "No metric shows a clear Base-to-PR difference beyond its measured run-to-run variation." - : `Differences larger than their measured MAD: ${clearChanges - .map(([label, estimate]) => `${label} ${formatSignedEstimate(estimate)}`) - .join(", ")}.` - - const overheadRows = metrics - .map(([key, label]) => { - const comparison = comparisons[key] - return `| ${label} | ${formatSignedEstimate(comparison.baseOverhead)} | ${formatSignedEstimate(comparison.headOverhead)} | ${formatSignedEstimate(comparison.headMinusBase)} |` - }) - .join("\n") - - return ( - `## Memory benchmark\n\n` + - `Compared base \`${shortSha(baseSha)}\` with PR head \`${shortSha(headSha)}\` on the same GitHub-hosted ${process.platform} runner. Lower values are better.\n\n` + - `| Metric | Base | PR | PR − Base | Change |\n` + - `|---|---:|---:|---:|---:|\n` + - `${comparisonRows}\n\n` + - `**Interpretation:** ${interpretation}\n\n` + - `### Extension overhead above pi baseline\n\n` + - `| Metric | Base overhead | PR overhead | Difference |\n` + - `|---|---:|---:|---:|\n` + - `${overheadRows}\n\n` + - `Values are medians of ${RUNS} alternating, paired runs. The value after \`±\` is the median absolute deviation (MAD). ` + - `Each process was sampled ${SAMPLE_COUNT} times after a ${WARMUP_MS} ms warm-up.\n\n` + - `Environment: pi \`${piVersion}\`, ${runtimeName} \`${runtimeVersion}\`, ${process.platform} \`${process.arch}\`. ` + - measurementSource() + - `\n\n> This is a comparative signal, not a pass/fail threshold. GitHub-hosted runner noise can affect absolute values.\n` - ) -} - -function measurementSource() { - if (process.platform === "darwin") { - return "RSS comes from `ps`; physical footprint and peak come from macOS `footprint`." - } - return "PSS and USS come from `/proc//smaps_rollup`; RSS metrics come from `/proc//status`." -} - -function median(values) { - const sorted = [...values].sort((left, right) => left - right) - const middle = Math.floor(sorted.length / 2) - return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] -} - -function formatProgress(measurement) { - if (process.platform === "darwin") { - return `${formatMiB(measurement.rss)} RSS, ${formatMiB(measurement.physicalFootprint)} physical` - } - return `${formatMiB(measurement.rss)} RSS, ${formatMiB(measurement.pss)} PSS` -} - -function formatEstimate(value) { - return `${formatMiB(value.median)} ± ${value.mad.toFixed(1)} MiB` -} - -function formatMiB(value) { - return `${value.toFixed(1)} MiB` -} - -function formatSignedEstimate(value) { - return `${formatSigned(value.median)} ± ${value.mad.toFixed(1)} MiB` -} - -function formatSigned(value, suffix = " MiB") { - const sign = value > 0 ? "+" : "" - return `${sign}${value.toFixed(1)}${suffix}` -} - -function requiredPath(name) { - const value = process.env[name] - if (!value) throw new Error(`${name} is required`) - return resolve(value) -} - -function positiveInteger(value, fallback) { - if (value === undefined) return fallback - const parsed = Number.parseInt(value, 10) - if (!Number.isInteger(parsed) || parsed <= 0) - throw new Error(`Expected a positive integer, got ${value}`) - return parsed -} - -function requireMetric(values, name) { - const value = values[name] - if (value === undefined) throw new Error(`Missing ${name} in Linux process memory data`) - return value -} - -function shortSha(sha) { - return sha.slice(0, 7) -} - -function ensureRunning(child, stderr) { - if (child.exitCode !== null || child.signalCode !== null) { - throw new Error( - `pi exited before memory sampling completed (code ${child.exitCode}, signal ${child.signalCode})\n${stderr}`, - ) - } -} - -function stopProcessGroup(child, signal = "SIGTERM") { - if (!child.pid) return - try { - process.kill(-child.pid, signal) - } catch (error) { - if (error?.code !== "ESRCH") throw error - } -} - -function onceExit(child) { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise((resolveExit) => child.once("exit", resolveExit)) -} - -function wait(milliseconds) { - return new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)) -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index f5dabb4..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,209 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - -jobs: - # ────────────────────────────────────────────────────────── - # Code correctness - # ────────────────────────────────────────────────────────── - typecheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npm run typecheck - # `npm test` includes tests/test-pi-local.mjs, which drives a real pi - # binary against the mock API and otherwise skips silently. - - name: Install pi - run: | - npm install -g @earendil-works/pi-coding-agent@latest - echo "PI_BIN=$(npm prefix -g)/bin/pi" >> "$GITHUB_ENV" - - name: Verify pi starts - run: '"$PI_BIN" --version' - - run: npm test - env: - PI_LOCAL_REQUIRED: "1" - - format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm run format:check - - # ────────────────────────────────────────────────────────── - # Oh My Pi host compatibility — real omp binary, mock API - # ────────────────────────────────────────────────────────── - omp-compat: - name: Oh My Pi compatibility - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - uses: oven-sh/setup-bun@v2.2.0 - with: - bun-version: 1.4.0 - - run: npm ci - - name: Install Oh My Pi - run: | - npm install -g @oh-my-pi/pi-coding-agent@latest - echo "OMP_BIN=$(npm prefix -g)/bin/omp" >> "$GITHUB_ENV" - - name: Verify omp starts - run: '"$OMP_BIN" --version' - - name: Run OMP compatibility suite - env: - OMP_COMPAT_REQUIRED: "1" - run: node tests/test-omp-compat.mjs - - # ────────────────────────────────────────────────────────── - # Code-level vulnerability scanning (SAST) - # ────────────────────────────────────────────────────────── - codeql: - name: CodeQL SAST - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v3 - with: - languages: javascript-typescript - queries: +security-extended,security-and-quality - - uses: github/codeql-action/autobuild@v3 - - uses: github/codeql-action/analyze@v3 - - # ────────────────────────────────────────────────────────── - # Custom static analysis — pi extension attack patterns - # ────────────────────────────────────────────────────────── - semgrep: - name: Semgrep — pi extension audit - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - container: - image: semgrep/semgrep:latest - steps: - - uses: actions/checkout@v4 - - name: Run Semgrep with custom rules - run: | - semgrep --config .semgrep/ --error --output semgrep-report.sarif --sarif . - - name: Upload SARIF - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: semgrep-report.sarif - category: semgrep-pi-audit - if: always() - - # ────────────────────────────────────────────────────────── - # Hardcoded secrets detection - # ────────────────────────────────────────────────────────── - gitleaks: - name: Gitleaks — secrets scan - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_ENABLE_COMMENTS: "true" - - # ────────────────────────────────────────────────────────── - # Dependency supply-chain security - # ────────────────────────────────────────────────────────── - deps-review: - name: Dependency review - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - - name: Check if dependency graph is enabled - run: | - echo "Dependency review requires enabling Dependency graph in repo settings." - echo "Go to: https://github.com/patlux/pi-commandcode-provider/settings/security_analysis" - echo "Enable: Dependency graph" - - uses: actions/dependency-review-action@v4 - with: - fail-on-severity: high - allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD - comment-summary-in-pr: always - continue-on-error: true - - deps-audit: - name: npm audit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm audit --audit-level=moderate - - name: Exit gracefully on audit findings - if: failure() - run: | - echo "::warning::npm audit found vulnerabilities. Review and patch before merging." - - # ────────────────────────────────────────────────────────── - # Postinstall script check — prevents install-time malware - # ────────────────────────────────────────────────────────── - check-scripts: - name: Check lifecycle scripts - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Check for malicious lifecycle scripts - run: | - echo "::group::package.json scripts" - node -e " - const pkg = require('./package.json'); - const dangerous = ['preinstall','install','postinstall','prepublish','prepare']; - const found = dangerous.filter(s => pkg.scripts && pkg.scripts[s]); - if (found.length) { - found.forEach(s => console.log('WARNING: package.json has "' + s + '":', pkg.scripts[s])); - process.exit(1); - } else { - console.log('No dangerous lifecycle scripts in package.json'); - } - " - echo "::endgroup::" - echo "::group::dependency scripts (top-level)" - npm query '.scripts' --all 2>/dev/null | node -e " - const d = require('fs').readFileSync('/dev/stdin','utf8'); - if (!d.trim()) { console.log('No dependency scripts found'); process.exit(0); } - let pkgs; - try { pkgs = JSON.parse(d); } catch(e) { console.log('Could not parse npm query output'); process.exit(0); } - if (!Array.isArray(pkgs)) pkgs = Object.values(pkgs); - const withScripts = pkgs.filter(p => p && p.pkgid && p.scripts); - withScripts.forEach(p => { - const dangerous = ['preinstall','install','postinstall','prepublish','prepare']; - const has = Object.keys(p.scripts || {}).filter(s => dangerous.includes(s)); - if (has.length) console.log('⚠', p.pkgid, 'has scripts:', Object.keys(p.scripts)); - }); - if (withScripts.length === 0) console.log('No dependency lifecycle scripts'); - " 2>&1 || true - echo "::endgroup::" diff --git a/.github/workflows/memory-benchmark.yml b/.github/workflows/memory-benchmark.yml deleted file mode 100644 index b23d8d8..0000000 --- a/.github/workflows/memory-benchmark.yml +++ /dev/null @@ -1,154 +0,0 @@ -name: Memory benchmark - -on: - pull_request: - branches: [main] - types: [opened, synchronize, reopened, ready_for_review] - -concurrency: - group: memory-benchmark-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - compare: - name: Compare base and PR memory - runs-on: macos-14 - timeout-minutes: 15 - permissions: - contents: read - env: - PI_VERSION: 0.84.4 - BUN_VERSION: 1.4.0 - NODE_VERSION: 22.23.2 - MEMORY_BENCHMARK_RUNS: 6 - MEMORY_BENCHMARK_WARMUP_MS: 2500 - MEMORY_BENCHMARK_SAMPLES: 12 - MEMORY_BENCHMARK_INTERVAL_MS: 100 - - steps: - - name: Check out benchmark implementation - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - path: benchmark - persist-credentials: false - - - name: Check out base revision - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: base - persist-credentials: false - - - name: Check out PR revision - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - path: head - persist-credentials: false - - - uses: actions/setup-node@v7 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - cache-dependency-path: | - base/package-lock.json - head/package-lock.json - - - uses: oven-sh/setup-bun@v2.2.0 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install base dependencies - working-directory: base - run: npm ci --ignore-scripts - - - name: Install PR dependencies - working-directory: head - run: npm ci --ignore-scripts - - - name: Install pinned pi host - run: | - npm install \ - --prefix pi-host \ - --ignore-scripts \ - --no-save \ - "@earendil-works/pi-coding-agent@$PI_VERSION" - - - name: Compare memory usage - env: - MEMORY_BENCHMARK_BASE_PATH: ${{ github.workspace }}/base - MEMORY_BENCHMARK_HEAD_PATH: ${{ github.workspace }}/head - MEMORY_BENCHMARK_PI_CLI: ${{ github.workspace }}/pi-host/node_modules/@earendil-works/pi-coding-agent/dist/cli.js - MEMORY_BENCHMARK_BASE_SHA: ${{ github.event.pull_request.base.sha }} - MEMORY_BENCHMARK_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - MEMORY_BENCHMARK_PI_VERSION: ${{ env.PI_VERSION }} - MEMORY_BENCHMARK_OUTPUT: ${{ github.workspace }}/memory-benchmark.md - MEMORY_BENCHMARK_JSON_OUTPUT: ${{ github.workspace }}/memory-benchmark.json - run: bun benchmark/.github/scripts/memory-benchmark.mjs - - - name: Add benchmark to job summary - if: always() && hashFiles('memory-benchmark.md') != '' - run: cat memory-benchmark.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload benchmark report - if: always() && hashFiles('memory-benchmark.md') != '' - uses: actions/upload-artifact@v7 - with: - name: memory-benchmark-report - path: | - memory-benchmark.md - memory-benchmark.json - retention-days: 30 - - comment: - name: Update PR comment - needs: compare - if: >- - always() && - needs.compare.result == 'success' && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04 - permissions: - actions: read - contents: read - pull-requests: write - - steps: - - name: Download benchmark report - uses: actions/download-artifact@v8 - with: - name: memory-benchmark-report - - - name: Update sticky PR comment - uses: actions/github-script@v9 - env: - REPORT_PATH: memory-benchmark.md - with: - script: | - const fs = require("node:fs") - const marker = "" - const report = fs.readFileSync(process.env.REPORT_PATH, "utf8") - const body = `${marker}\n${report}` - const { owner, repo } = context.repo - const issue_number = context.issue.number - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }) - const previous = comments.find( - (comment) => comment.user?.type === "Bot" && comment.body?.includes(marker), - ) - - if (previous) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: previous.id, - body, - }) - } else { - await github.rest.issues.createComment({ owner, repo, issue_number, body }) - } diff --git a/.github/workflows/model-metadata.yml b/.github/workflows/model-metadata.yml deleted file mode 100644 index 88c9ca5..0000000 --- a/.github/workflows/model-metadata.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Command Code catalog sync - -on: - pull_request: - branches: [main] - paths: - - ".github/scripts/check-commandcode-model-metadata.ts" - - ".github/workflows/model-metadata.yml" - - "src/commandcode-catalog.ts" - - "src/core.ts" - - "src/models.ts" - - "tests/test-model-metadata-check.ts" - schedule: - - cron: "17 6 * * *" - workflow_dispatch: - -concurrency: - group: commandcode-catalog-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'sync' }} - cancel-in-progress: true - -jobs: - check: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - registry-url: https://registry.npmjs.org - - run: npm ci - - name: Compare with the latest Command Code CLI - run: npm run check:commandcode-catalog | tee commandcode-catalog-report.md - - name: Publish catalog report - if: always() - run: cat commandcode-catalog-report.md >> "$GITHUB_STEP_SUMMARY" - - sync: - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - registry-url: https://registry.npmjs.org - - run: npm ci - - name: Synchronize with the latest Command Code CLI - run: npm run sync:commandcode-catalog | tee commandcode-catalog-report.md - - name: Format and verify synchronized files - run: | - npm run format -- src/commandcode-catalog.ts README.md - npm run typecheck - npm run test:models - npm run format:check - git diff --check - - name: Publish catalog report - if: always() - run: cat commandcode-catalog-report.md >> "$GITHUB_STEP_SUMMARY" - - name: Create or update synchronization PR - uses: peter-evans/create-pull-request@v8 - with: - branch: automation/commandcode-catalog - delete-branch: true - commit-message: "chore(models): sync Command Code catalog" - title: "chore(models): sync Command Code catalog" - body: | - Automated synchronization with the latest published `command-code` CLI package. - - This updates only machine-readable compatibility metadata: - - CLI version used in the `x-command-code-version` header - - image-input capabilities - - reasoning capability and selectable effort levels - - model-specific maximum output limits - - documented catalog snapshot version - - Pricing remains review-only because CLI documentation does not represent every pricing tier and temporary promotion used by the provider. - assignees: patlux - add-paths: | - src/commandcode-catalog.ts - README.md diff --git a/.gitignore b/.gitignore index 95d7ca8..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ node_modules/ -dist/ -*.js.map -.DS_Store diff --git a/.gitleaks.toml b/.gitleaks.toml deleted file mode 100644 index dcd3149..0000000 --- a/.gitleaks.toml +++ /dev/null @@ -1,45 +0,0 @@ -# Custom Gitleaks config for pi-commandcode-provider. -# Extends the default rule set with patterns specific to pi extensions. - -title = "pi-commandcode-provider secret scan" - -[allowlist] - description = "Known safe paths and test fixtures" - paths = [ - # Test fixtures use intentionally fake credentials. - "^tests/", - ] - -# ────────────────────────────────────────────────────────── -# Project-specific rules -# ────────────────────────────────────────────────────────── - -[[rules]] - id = "pi-commandcode-api-key" - description = "Command Code API key hardcoded in source" - regex = '''(?i)COMMANDCODE_API_KEY\s*[=:]\s*['"](user_[A-Za-z0-9_-]{10,}|cc_[A-Za-z0-9_-]{10,})['"]''' - tags = ["pi-extension", "commandcode", "api-key"] - -[[rules]] - id = "pi-auth-file-pattern" - description = "In-line pi auth.json content in source code" - regex = '''['"](apiKey|commandcode|command-code)['"]\s*:\s*['"]user_[A-Za-z0-9_-]{10,}['"]''' - tags = ["pi-extension", "auth"] - -[[rules]] - id = "pi-hardcoded-bearer-token" - description = "Hardcoded Bearer token (20+ chars) in Authorization header" - regex = '''Bearer [A-Za-z0-9_.\-]{20,}''' - tags = ["pi-extension", "auth-token"] - -[[rules]] - id = "pi-oauth-callback-url" - description = "OAuth callback URL with hardcoded key" - regex = '''callbackUrl\s*=\s*['"]http://localhost:\d+/callback['"]''' - tags = ["pi-extension", "oauth"] - -[[rules]] - id = "pi-test-api-key" - description = "Test API key value that looks real" - regex = '''['"](user_testKey|mock-key|fake-key|test-api-key)['"]''' - tags = ["pi-extension", "test"] diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 85421a6..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": false, - "trailingComma": "all", - "singleQuote": false, - "printWidth": 100, - "tabWidth": 2 -} diff --git a/.semgrep/pi-extension-audit.yaml b/.semgrep/pi-extension-audit.yaml deleted file mode 100644 index 120a0ec..0000000 --- a/.semgrep/pi-extension-audit.yaml +++ /dev/null @@ -1,272 +0,0 @@ -rules: - # ──────────────────────────────────────────────────────────────────────── - # Exfiltration: sending secrets to remote servers - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-data-exfiltration-fetch - patterns: - - pattern: fetch($URL, ...) - message: > - Data exfiltration risk: sending data to $URL via fetch. - Verify the destination is not an attacker-controlled server. - severity: ERROR - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - focus-metavariable: $URL - - - id: pi-extension-data-exfiltration-xmlhttprequest - pattern: new XMLHttpRequest() - message: > - Suspicious XMLHttpRequest usage in provider code. - Could exfiltrate data to external servers. - severity: WARNING - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - # ──────────────────────────────────────────────────────────────────────── - # Secret leakage: logging / sending API keys - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-logging-secrets - patterns: - - pattern-either: - - pattern: console.log(...) - - pattern: console.error(...) - - pattern: console.warn(...) - - pattern-regex: "api[kK]ey|API_KEY|COMMANDCODE|Bearer|authPath" - message: > - Potential secret logging: log statement includes an API key, auth token, - or auth file path pattern. Do not log secrets. - severity: ERROR - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - - id: pi-extension-secret-in-error-message - patterns: - - pattern: Error($MSG) - - pattern-regex: "api[kK]ey|API_KEY|COMMANDCODE|Bearer" - message: > - Possible secret leak in error message: $MSG. - Error messages surfaced to pi may leak credentials. - Use generic messages instead. - severity: WARNING - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - # ──────────────────────────────────────────────────────────────────────── - # URL hijacking: changing API base endpoints - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-api-base-override - patterns: - - pattern-either: - - pattern: $VAR = "..." - - pattern: $VAR = process.env.$ENV_VAR ?? "..." - - metavariable-regex: - metavariable: $VAR - regex: "(?i).*(api_?base|api_base_url|base_url|host|endpoint|models_url).*" - - metavariable-regex: - metavariable: $ENV_VAR - regex: "(?i).*API.*BASE.*" - message: > - API base URL override: $ENV_VAR can redirect all requests including auth - headers to any server. Review any change to how this variable is set. - severity: ERROR - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - - - # ──────────────────────────────────────────────────────────────────────── - # Supply chain: dependency injection - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-untrusted-dynamic-require - patterns: - - pattern: require($MODULE) - - metavariable-regex: - metavariable: $MODULE - regex: "^(?!['\"](\\.[/\\\\]|node:?|@earendil-works/))['\"]" - message: > - Dynamic require() of non-local, non-standard-library module: $MODULE. - External contributors could introduce malicious packages this way. - severity: ERROR - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - - id: pi-extension-postinstall-script - patterns: - - pattern: '"postinstall": "$SCRIPT"' - message: > - postinstall script detected: $SCRIPT. Install-time scripts can execute - arbitrary code on user machines. Must be reviewed with extreme care. - severity: ERROR - languages: [json] - paths: - include: - - "package.json" - - # ──────────────────────────────────────────────────────────────────────── - # File system: reading auth files / ~/.pi - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-auth-path-traversal - patterns: - - pattern: join($HOME, $PATH) - - metavariable-regex: - metavariable: $HOME - regex: "homedir|homeDir|HOME|process\\.env\\.HOME" - - metavariable-regex: - metavariable: $PATH - regex: "['\"].*\\.\\./.*['\"]" - message: > - Auth path traversal: $HOME / $PATH could read outside the intended auth - directory. Path must stay within ~/.commandcode, ~/.pi/agent, ~/.omp/agent. - severity: ERROR - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - - id: pi-extension-readfile-sensitive-paths - patterns: - - pattern: readFileSync($PATH, ...) - - pattern-not: readFileSync($PATH, "utf-8") - - metavariable-regex: - metavariable: $PATH - regex: "(auth\\.json|credentials|\\.env|id_rsa|ssh|\\.netrc|netrc|\\.npmrc)" - message: > - Suspicious file read: $PATH. Auth files or credentials being read with - potentially unsafe encoding. Review carefully. - severity: WARNING - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - # ──────────────────────────────────────────────────────────────────────── - # Network: unexpected fetch calls - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-unexpected-fetch - pattern: fetch(...) - message: > - Direct fetch() call detected. Provider code should use injected - fetchImpl for testability and security. If this is intentional, - add a // nosemgrep comment on the line above. - severity: WARNING - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - # ──────────────────────────────────────────────────────────────────────── - # Shell execution - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-shell-execution - patterns: - - pattern-either: - - pattern: child_process.exec(...) - - pattern: child_process.execSync(...) - - pattern: child_process.spawn(...) - - pattern: child_process.spawnSync(...) - - pattern: child_process.fork(...) - - pattern: exec($SCRIPT) - - pattern: execSync($SCRIPT) - message: > - Shell execution: $SCRIPT. External contributors should not add - child_process calls to provider source code. - severity: ERROR - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - # ──────────────────────────────────────────────────────────────────────── - # Malicious import patterns - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-unusual-import - patterns: - - pattern-either: - - pattern: "import $X from \"...\"" - - pattern: "const $X = require(\"...\")" - - metavariable-regex: - metavariable: $X - regex: "(compression|pako|zlib|tar|stream|archiver|request|axios|needle|got|superagent|node-fetch|undici)" - message: > - Suspicious import of $X in provider source. Network or compression - libraries could be used for data exfiltration. - severity: WARNING - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - - id: pi-extension-eval-like - patterns: - - pattern-either: - - pattern: eval(...) - - pattern: new Function(...) - - pattern: setTimeout($X, ...) - - pattern: setInterval($X, ...) - - pattern-not: setTimeout(() => ..., ...) - - pattern-not: setTimeout(function(...) {...}, ...) - - pattern-not: setTimeout(function $F(...) {...}, ...) - - pattern-not: setInterval(() => ..., ...) - - pattern-not: setInterval(function(...) {...}, ...) - - pattern-not: setInterval(function $F(...) {...}, ...) - message: > - Code injection risk: eval, Function constructor, or eval-like setTimeout - detected. These can execute arbitrary code. - severity: ERROR - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - # ──────────────────────────────────────────────────────────────────────── - # Process environment: reading secrets - # ──────────────────────────────────────────────────────────────────────── - - - id: pi-extension-env-leak - patterns: - - pattern: process.env.$VAR - - metavariable-regex: - metavariable: $VAR - regex: "(?!COMMANDCODE_|COMMAND_CODE_|CMD_ZDR|NODE_|PATH|HOME|SHELL|USER|LANG|LC_|TERM|TMPDIR|NIX_).*" - message: > - Reading unexpected environment variable $VAR. Provider should only - read documented Command Code or standard runtime variables. - severity: WARNING - languages: [javascript, typescript] - paths: - include: - - "**/*.ts" - - "**/index.ts" - - # ──────────────────────────────────────────────────────────────────────── - # OAuth flow manipulation - # ──────────────────────────────────────────────────────────────────────── diff --git a/.semgrepignore b/.semgrepignore deleted file mode 100644 index 611fe76..0000000 --- a/.semgrepignore +++ /dev/null @@ -1,7 +0,0 @@ -# Semgrep ignore patterns -# Exclude test files and build artifacts - -tests/ -node_modules/ -dist/ -coverage/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 72b14b7..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -# Agent Instructions - -- Follow [CONTRIBUTING.md](CONTRIBUTING.md) before changing code, tests, docs, or commit messages. -- Use [RELEASE.md](RELEASE.md) for prerelease, npm smoke-test, stable release, tag, and GitHub follow-up work. -- Keep changes focused and reviewable; avoid unrelated refactors. -- Run the relevant checks before reporting work as done. -- Do not commit, tag, push, or publish unless explicitly asked in the current conversation. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index bc65df6..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,178 +0,0 @@ -# Changelog - -## Unreleased - -- Refresh the generated Command Code capability catalog from `command-code@1.44.0` to `command-code@1.53.1`. `deepseek/deepseek-v4.1-flash` now advertises image input and its `low`, `high`, and `max` reasoning efforts, so pi forwards attached images and exposes the full thinking-level selector instead of the text-only, level-less defaults. New `gpt-6-astra`, `xai/grok-4.6`, and `inclusionai/ling-3.0-flash-sante:free` metadata comes along. -- Drop the now-obsolete manual Meta Muse Spark effort overrides: upstream ships efforts for `meta/muse-spark-1.1` through `1.3-contributor` and `MiniMaxAI/MiniMax-M3`, so `src/commandcode-catalog-overrides.ts` is empty. - -## 0.6.4 - 2026-09-03 - -- Refresh the generated Command Code capability catalog from `command-code@1.40.1` to `command-code@1.44.0`, adding current image-input, reasoning, effort, and output-limit metadata for newly published models. -- Expose selectable thinking levels (`minimal`, `low`, `medium`, `high`, `xhigh`) for `meta/muse-spark-1.3` and `meta/muse-spark-1.3-contributor`, so Pi and Oh My Pi forward the selected `reasoning_effort` instead of keeping thinking disabled. - -### Contributors - -- @heie54 — added and validated Muse Spark 1.3 reasoning support (#80). - -## 0.6.3 - 2026-09-02 - -- Fix Oh My Pi chat returning `401 Invalid 'Authorization' header` after `/login`: OMP kept the unresolved `$COMMAND_CODE_API_KEY` placeholder as a literal config API key that shadowed its stored credentials and was sent as the Bearer token. The placeholder is now registered only on pi, where it keeps the API-key login method and `--api-key` working next to OAuth; on OMP the provider omits `apiKey` unless a real key is configured. Placeholders passed by the host are also resolved or stripped on the Provider API and compat stream paths, and the legacy generate transport resolves its key through the same rule. -- Cover stored `/login` OAuth and API-key credentials, `--api-key`, and env keys end to end on both pi and Oh My Pi, asserting the exact Bearer token the mock API receives. CI now runs the pi end-to-end suite against a real `pi` binary instead of skipping it. - -### Contributors - -- @ebreen — reported and diagnosed the Oh My Pi `/login` 401, and opened the fix that this release builds on (#78). - -## 0.6.2 - 2026-09-02 - -- Fix `omp plugin install` on Oh My Pi 18.x, which rejected 0.6.1 because its pi-ai lacks the `registerApiProvider` export; the compat registration now resolves at runtime and is skipped on hosts that register custom APIs themselves. -- Run the Oh My Pi compatibility suite against a real `omp` binary in CI as a required check, and assert there that the extension loads against OMP's bundled pi packages. -- Pin the CI memory benchmark and Oh My Pi jobs to Bun 1.4.0, Node 22.23.2, and pi 0.84.4. - -### Contributors - -- @AmeMizuki — reported the failing `omp plugin install` on Oh My Pi 18.1.2. - -## 0.6.1 - 2026-09-01 - -- Expose selectable thinking levels (`minimal`, `low`, `medium`, `high`, `xhigh`) for `meta/muse-spark-1.1`, `meta/muse-spark-1.2`, and `meta/muse-spark-1.2-contributor` through a manual catalog override, so `/thinking` and `Shift+Tab` no longer stay locked on `off` for these reasoning models. -- Start from the cached model catalog and refresh it in the background instead of blocking host startup on the catalog request; a first start without a cache still waits for the live catalog. -- Register the `commandcode-custom` API in the `@earendil-works/pi-ai/compat` registry so sibling extensions that stream with the active Command Code model no longer fail with `No API provider registered for api: commandcode-custom` on plain pi. -- Assert structural catalog invariants in the model tests so the daily catalog sync no longer fails on every upstream change. -- Display the monthly renewal date and remaining days in `/commandcode-quota`. -- Stop silently dropping `role: "developer"` messages (for example OMP advisor steering notes, reminders, and nudges). `/alpha/generate` only accepts `user`, `assistant`, and `tool` roles, so developer messages are now forwarded as `user` messages with identical content in the same chronological position instead of disappearing from the request. -- Add `Qwen/Qwen3.8-Flash` and `z-ai/glm-5.3-flash` with their verified reasoning efforts (`low, medium, xhigh` and `low, high, max`) and display pricing. -- Refresh static model capabilities from `command-code@1.40.1`, adding `claude-fable-5-1`, `deepseek/deepseek-v4-flash-fast`, and `tencent/hy4-preview` with their reasoning efforts, adding `moonshotai/Kimi-K3` efforts and the `z-ai/glm-5.3-flash` output limit, and dropping the retired `stealth/ox-alpha` and `minimax/minimax-m3-free`. -- Refresh display pricing for the current 62-model catalog, adding `claude-fable-5-1`, `deepseek/deepseek-v4-flash-fast`, and `tencent/hy4-preview`, removing the retired `stealth/ox-alpha`, `minimax/minimax-m3-free`, and `minimax/minimax-m2.7-free`, and ending the expired Claude Sonnet 5 introductory and Gemini 3.7 Flash promotional windows. -- Fix `npm run sync:commandcode-catalog` and `npm run check:commandcode-catalog` on Windows by spawning npm through the shell. -- Add a `refresh-model-catalog` agent skill with cross-platform helper scripts that snapshot the live model catalog and regenerate the pricing fixture from `MODEL_COSTS`. - -### Contributors - -- @warc0s — preserved developer messages on the `/alpha/generate` transport with OMP advisory coverage. -- @jagaliano — added the quota renewal date and diagnosed the failing daily catalog sync. -- @ThomasByr — added GLM 5.3 Flash and Qwen 3.8 Flash and contributed the `refresh-model-catalog` skill. -- @hjshin-ubob — proposed selectable thinking levels for the Muse Spark models. -- @Sokoshy — analyzed the `commandcode-custom` compat registry failure on plain pi. -- @CoderTCY — measured and proposed the cache-first catalog startup. -- @MertSoylu — reported the missing GLM 5.3 Flash effort levels. - -## 0.6.0 - 2026-08-25 - -- Allow switching from a vision-capable model to a text-only model by omitting historical image tool results while preserving their text output; direct image prompts still fail clearly. -- Stream incremental tool-call arguments from the `/alpha/generate` transport instead of waiting for the final complete tool-call event. -- Add a daily GitHub Actions synchronization job that opens or updates a pull request for CLI version, image capability, reasoning, effort, and output-limit changes in the latest published Command Code catalog. -- Refresh static model capabilities from `command-code@1.32.2`, separating reasoning support from selectable effort levels and honoring model-specific output limits. -- Reject truncated, aborted, and network-failed generate streams instead of reporting partial responses as successful. -- Normalize malformed tool results and synthesize missing tool results so follow-up requests preserve valid tool-call history. -- Refresh display pricing for all 58 current models, including Gemini 3.7 Flash, Qwen 3.8 27B, Ox Alpha, Muse Spark 1.2, and Grok 4.6 long-context rates. -- Accept the official `COMMAND_CODE_API_KEY` and `CMD_ZDR` environment variables while retaining legacy aliases. -- Align generate request metadata with the CLI by forwarding stable session IDs, optional temperature, and the CLI user agent. -- Validate manually pasted API keys, use the CLI's two-minute browser timeout, and reject OAuth state mismatches without closing the callback server. -- Add `/commandcode-quota` with live credits, plan, usage totals, and rolling-limit diagnostics from Command Code's alpha usage endpoints. -- Add `zai-org/GLM-5.3` with its verified reasoning efforts and display pricing. -- Prefer Command Code's Provider API (`/provider/v1/chat/completions` and `/provider/v1/messages`) and automatically fall back to the existing `/alpha/generate` transport only when the Provider API returns `403 upgrade_required` for a Go-plan account. -- Remember the detected transport for the running process, re-detect it when credentials change, prevent stale in-flight requests from overwriting the new credential's transport, and never fall back for unrelated authentication, permission, rate-limit, network, or server failures. -- Use Pi's native OpenAI- and Anthropic-compatible providers for Provider API streaming, including adaptive thinking for current reasoning-capable Claude models, while preserving the existing hardened generate transport, dynamic model discovery, offline cache, refresh/status commands, pricing, and OAuth credentials. -- Let `/login` use browser authentication, an explicit API-key prompt, or a directly pasted API key. -- Add optional zero-data-retention headers through `CMD_ZDR=1` and the legacy `COMMANDCODE_ZDR=1` alias. -- Refresh GPT-5.6 Terra and Luna display prices after their temporary 50% promotion ended, and display the current DeepSeek V4 off-peak rates for its time-dependent pricing. -- Add isolated live E2E profiles for separate Go-, GOAT-, and Provider-plan credentials, covering transport selection, reasoning across turns, quota identity, aborts, tools, GOAT vision, Go image rejection, and packed-package validation. -- Fix extension load failure on newer pi hosts that reject registering a custom API under a built-in name (`openai-completions`); register under `commandcode-custom` instead and restore the real wire API before native compat dispatch. - -### Contributors - -- @jagaliano — added the live quota dashboard and hardened its integration. -- @omariqbalnaru — fixed custom API registration for Oh My Pi 17.4.0. -- @ThomasByr — added GLM-5.3 pricing and reasoning levels. -- @newCman1 — added DeepSeek V4 vision model support. - -## 0.5.1 - 2026-08-11 - -- Add model-specific image input capabilities from the `command-code@1.15.1` catalog and forward user and tool-result images using the current Command Code wire format. -- Update the Command Code client version header to `1.15.1`. - -### Contributors - -- @DiyarD — reported missing vision support for GPT-5.6 Luna, Muse Spark 1.2, and other vision-capable models. - -## 0.5.0 - 2026-08-07 - -- Stop replaying completed assistant reasoning traces to Command Code while preserving visible text and completed tool calls in follow-up request history. -- Add `/commandcode-refresh` and `/commandcode-status` commands for safe model-catalog refreshes and redacted diagnostics. -- Bound model discovery to a configurable 10-second timeout so a slow Provider API cannot block pi startup; timed-out discovery uses the validated cache when available. -- Normalize Command Code context overflow failures so pi can auto-compact and retry, while leaving unrelated rate-limit and capacity errors unchanged. -- Keep the legacy `/alpha/generate` integration explicitly text-only: image input and image tool results are rejected instead of being silently dropped, and models do not claim image capability until the protocol exposes documented support and limits. -- Replace blanket reasoning metadata with model-specific Command Code effort support. Known models expose a `thinkingLevelMap`, and selected supported Pi levels are forwarded as `params.reasoning_effort`; unsupported or unknown models do not receive reasoning request fields. -- Add repository commands for testing the current checkout either in a logged-out, automatically cleaned-up pi environment or with existing credentials and only Command Code models enabled. -- Refresh display pricing for the current Command Code model catalog, remove expired Qwen promotional rates, add current free and discounted models, and require review when temporary prices expire. -- Use the host-provided `pi-ai` and `pi-coding-agent` core packages instead of installing private runtime copies, including for local and out-of-store development checkouts. -- Fix cached input tokens being counted twice. - -### Contributors - -- @IfkumRfnl — fixed cached input token accounting. - -## 0.4.3 - 2026-08-02 - -- 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 - -- Fix Oh My Pi extension validation by avoiding the missing `calculateCost` export from OMP's legacy `pi-ai` shim. -- Add a regression test that locks the local Command Code cost calculation to pi-ai's upstream `calculateCost` behavior. - -### Contributors - -- @CoderTCY — reported the Oh My Pi installation failure. - -## 0.4.1 - 2026-06-16 - -- Use the explicit `$COMMANDCODE_API_KEY` provider registration syntax expected by newer pi versions, removing the startup deprecation warning while keeping legacy placeholder compatibility. -- Refresh development dependency lockfile entries to resolve npm audit findings for `tsx`/`esbuild` and `protobufjs`. - -### Contributors - -- @plumj-am — fixed the pi provider `apiKey` deprecation warning. -- @cad0p — reported retry/deprecation-related issues that helped validate the current behavior. -- @bl4zee1g — reported provider availability concerns that prompted additional local/live validation. - -## 0.4.0 - 2026-06-02 - -- Add retry mechanism for transient HTTP errors (429, 5xx) and stream-level errors, configurable via pi `settings.json` `retry.provider` fields (`timeoutMs`, `maxRetries`, `maxRetryDelayMs`). Supports exponential backoff with jitter and `Retry-After` header. - -## 0.3.1 - 2026-05-29 - -- Bump CLI version header to `0.29.0` for Command Code API parity. -- Harden PR security pipeline CI configuration. - -## 0.3.0 - 2026-05-28 - -- Add OMP (Oh My Pi) provider compatibility: support `~/.omp/agent/auth.json` auth path, handle OMP's env-var-name-as-apiKey quirk, convert OMP system prompt arrays to text. -- Close open thinking blocks before starting text or tool output to prevent event ordering issues when upstream omits `reasoning-end`. -- Correct DeepSeek V4 Pro discount as permanent (no expiry), not time-limited. -- Correct DeepSeek V4 Flash cache-read rate to $0.028/M and add xiaomi/mimo models to pricing table. -- Upgrade pi dependencies from `@mariozechner` 0.72.0 to `@earendil-works` 0.75.5. -- Move `pi-coding-agent` to optional peerDependencies. - -## 0.2.0 - 2026-05-27 - -- Stream `reasoning-delta` events incrementally instead of buffering the full thinking block until `reasoning-end`. Emits `thinking_start`, `thinking_delta`, and `thinking_end` events as they arrive so the UI can show reasoning in real time. -- Close open text blocks on `reasoning-start` and `reasoning-delta` so thinking and text never overlap in the output. -- Add live display pricing (`MODEL_COSTS`) for known Command Code models. Cost falls back to zero for models not yet in the price table until the Provider API exposes pricing directly. -- Fetch models from the Command Code Provider API at startup (inherited from upstream 0.1.1) and overlay the static cost table. - -## 0.1.1 - 2026-05-26 - -- Align Command Code generate requests with CLI `0.27.2` headers and payload shape. -- Support official Command Code CLI auth files using the `command-code` credential key. -- Handle `reasoning-start` and ignore streamed `tool-result` events. -- Cap generated `max_tokens` by the selected model and the Command Code output limit. - -## 0.1.0 - 2026-05-05 - -- Initial public release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 53ee9e4..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,179 +0,0 @@ -# Contributing - -Thanks for helping improve `pi-commandcode-provider`. - -This is an unofficial Command Code provider for pi. Keep changes small, tested, and easy to review. - -## Development setup - -```sh -npm install -npm test -``` - -Useful commands: - -```sh -npm run typecheck -npm run format:check -npm run test:unit -npm run test:models -npm run test:oauth -npm run test:abort -npm run test:stream -npm run test:pi-isolated -npm run test:pi-authenticated -npm run test:pi-local -``` - -Start an isolated pi instance with only the current checkout installed and no existing Command Code credentials: - -```sh -npm run pi:isolated -``` - -Run `/login` inside pi. Temporary credentials, configuration, and sessions are deleted when pi exits. - -Start the current checkout with your existing pi credentials and only Command Code models in the model picker: - -```sh -npm run pi:authenticated -``` - -Both commands accept additional pi arguments after `--`, for example `npm run pi:authenticated -- --model claude-sonnet-4-6`. - -Run the transport-specific live tests with separate credentials: - -```sh -COMMANDCODE_E2E_GO_API_KEY_FILE=/path/to/go-key npm run test:e2e:live:go -COMMANDCODE_E2E_GOAT_API_KEY_FILE=/path/to/goat-key npm run test:e2e:live:goat -COMMANDCODE_E2E_PROVIDER_API_KEY_FILE=/path/to/provider-key npm run test:e2e:live:provider -``` - -Use `npm run test:e2e:live:all` with the Go and GOAT file variables to run both subscription transports sequentially. Store keys in a secret manager and export each one to a new mode-`0600` temporary file for the test; never add key files to the repository. Direct `*_API_KEY` variables are intended primarily for protected CI secrets. - -### pi end-to-end - -`tests/test-pi-local.mjs` runs the extension inside a real `pi` binary against a mock Command Code API, including every credential source (`/login` OAuth and API-key credentials, `--api-key`, env keys). It skips locally when `pi` is not on `PATH`; CI installs pi and runs it as part of `npm test` with `PI_LOCAL_REQUIRED=1`. Point `PI_BIN` at another pi executable to test against a specific version. - -### Oh My Pi compatibility - -`tests/test-omp-compat.mjs` runs the extension inside a real `omp` binary against a mock Command Code API. It skips locally when `omp` is not on `PATH`; CI installs Oh My Pi and runs it as a required check with `OMP_COMPAT_REQUIRED=1`, so a change that only loads on pi fails CI instead of the next `omp plugin install`. - -To run it locally, point `OMP_BIN` at an omp executable (Oh My Pi needs Bun ≥ 1.3.14): - -```sh -npm install -g @oh-my-pi/pi-coding-agent -OMP_BIN="$(npm prefix -g)/bin/omp" node tests/test-omp-compat.mjs -``` - -Before opening a PR, run: - -```sh -npm test -npm run format:check -git diff --check -``` - -For release and npm smoke-test steps, see [RELEASE.md](RELEASE.md). - -## Pull request guidelines - -- Keep PRs focused on one problem or feature. -- Add or update tests for behavior changes. -- Update `README.md`, `CHANGELOG.md`, or `RELEASE.md` when user-facing behavior changes. -- Avoid broad refactors unless the PR is specifically about refactoring. -- Do not include API keys, tokens, real auth files, `.env` files, or other secrets. -- Prefer documented/public Command Code API behavior. If compatibility with CLI behavior is needed, document why. -- Make sure npm package contents still make sense when `package.json` `files` changes. - -## Testing pi integration changes - -For provider, auth, request-shape, or stream changes, test both local code and the package form when possible. - -Local extension smoke: - -```sh -pi --no-extensions -e ./index.ts --list-models commandcode -``` - -Npm package smoke and isolated `/login` testing are documented in [RELEASE.md](RELEASE.md#test-the-npm-package-in-pi). - -## Commit message rules - -Use Angular-style Conventional Commits. - -Format: - -```txt -(): -``` - -Examples: - -```txt -feat(auth): support Command Code CLI auth files -fix(core): cap max tokens by selected model -docs(release): document npm smoke testing -test(stream): cover reasoning start events -chore(release): publish 0.1.1 -``` - -### Types - -Use one of these types: - -- `feat`: a new user-facing feature -- `fix`: a bug fix -- `docs`: documentation-only changes -- `style`: formatting-only changes, no behavior change -- `refactor`: code restructuring without behavior change -- `perf`: performance improvement -- `test`: adding or changing tests -- `build`: package, dependency, or build-system changes -- `ci`: CI workflow changes -- `chore`: maintenance that does not fit another type -- `revert`: revert a previous commit - -### Scopes - -Use a short lowercase scope. Prefer existing project areas: - -- `auth` -- `oauth` -- `core` -- `models` -- `stream` -- `tests` -- `docs` -- `release` -- `deps` -- `ci` - -A scope is strongly recommended. If no scope fits, choose the closest project area instead of omitting it. - -### Subject line - -- Use imperative mood: `fix(auth): read oauth credentials`, not `fixed` or `fixes`. -- Keep it concise. -- Start lowercase after the colon. -- Do not end with a period. - -### Body and footers - -Use a body when the reason is not obvious: - -```txt -fix(core): cap max tokens by selected model - -Command Code can return models with lower output limits than the provider-wide cap. -Clamp defaults to the selected model so requests do not exceed upstream limits. -``` - -Breaking changes must be marked with `!` or a `BREAKING CHANGE:` footer: - -```txt -feat(api)!: switch to provider api endpoints - -BREAKING CHANGE: removes support for the legacy internal generate endpoint. -``` diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a9b771d..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Pat Woz - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index b2b6233..967a6f2 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,79 @@ # pi-commandcode-provider -[![CI](https://github.com/patlux/pi-commandcode-provider/actions/workflows/ci.yml/badge.svg)](https://github.com/patlux/pi-commandcode-provider/actions/workflows/ci.yml) -[![Memory benchmark](https://github.com/patlux/pi-commandcode-provider/actions/workflows/memory-benchmark.yml/badge.svg)](https://github.com/patlux/pi-commandcode-provider/actions/workflows/memory-benchmark.yml) +Unofficial [Command Code](https://commandcode.ai) provider for [pi](https://github.com/earendil-works/pi), +written against pi's current provider API. It registers the Command Code +Provider API as the `commandcode` provider and lets pi own authentication, +model persistence, and streaming. -A custom provider for [pi](https://github.com/earendil-works/pi) that connects to the [Command Code](https://commandcode.ai) Provider API. - -> **Disclaimer:** This is an unofficial, community-maintained integration. It is not affiliated with, endorsed by, or supported by Command Code. You need your own Command Code account, API key, and a plan with Provider API access. Command Code's terms, availability, and pricing apply. +> Not affiliated with, endorsed by, or supported by Command Code. You need your +> own account, API key, and a plan with Provider API access. ## Install ```sh -pi install npm:pi-commandcode-provider +pi remove npm:pi-commandcode-provider # if the community package is installed +pi install /home/cat/pi-commandcode-provider ``` -Start or reload pi, then authenticate: +Restart pi or run `/reload`, then `/login` → **Command Code** to store the API +key (or select the subscription flow for browser login). Pick a model with +`/model`. -```txt -/login -``` +## What it registers -Select **Use a subscription**, then **Command Code**. Choose browser login or paste an API key, then select a model with `/model`. +| Piece | Behaviour | +| --- | --- | +| Provider | `commandcode`, name "Command Code", base URL `https://api.commandcode.ai/provider/v1` | +| API | pi's native adapters: `openai-completions` for most models, `anthropic-messages` for `claude-*` | +| Auth | `/login` (browser transfer or pasted key), `$COMMAND_CODE_API_KEY`, `--api-key`, `auth.json` | +| Catalog | Generated CLI catalog as the offline baseline; pi's catalog refresh replaces it with the live `/provider/v1/models` listing | +| Command | `/commandcode-quota` prints credits, plan, usage windows, and the period summary | -## Oh My Pi +Because the model list is registered through pi's own catalog layer, the live +listing is cached in `~/.pi/agent/models-store.json` and refreshed by pi itself +(interactive startup and the `/model` picker), not by a custom cache file. When +the endpoint is unreachable, the persisted catalog stays active; before the +first refresh, the generated baseline is used, so `pi --list-models commandcode` +works offline. -Install the same package in [Oh My Pi](https://github.com/can1357/oh-my-pi): +### Environment variables + +| Variable | Purpose | +| --- | --- | +| `COMMAND_CODE_API_KEY` | API key fallback when no credential is stored | +| `CMD_ZDR=1` | Send `x-cmd-zdr: 1` (zero data retention); `COMMANDCODE_ZDR=1` still works | +| `COMMANDCODE_API_BASE` | Override the Provider API base URL | +| `COMMANDCODE_MODELS_URL` | Override the catalog endpoint | +| `COMMANDCODE_MODELS_TIMEOUT_MS` | Catalog request timeout (default 10 s) | +| `COMMANDCODE_AUTH_TIMEOUT_MS` | Browser login callback timeout (default 120 s) | + +The provider also reads existing keys from `~/.pi/agent/auth.json` and +`~/.commandcode/auth.json` for `/commandcode-quota`. + +## Model metadata + +Reasoning support, effort levels, image input, output limits, and prices come +from the published `command-code` CLI package, which is the same source the +Command Code CLI uses. Regenerate the catalog after a new CLI release: ```sh -omp plugin install pi-commandcode-provider +npm run sync:catalog # latest command-code +npm run sync:catalog -- --version 1.54.0 +npm run sync:catalog -- --check # fail when src/catalog.ts is stale ``` -Restart OMP or run `/reload`, then use `/login` and select **Use a subscription** followed by **Command Code**. - -## Authentication - -### Login dialog - -Run `/login` in pi or OMP. Select **Use a subscription**, then **Command Code**. Press Enter for browser login, type `key` to open a paste prompt, or paste the API key directly. The selected credential is stored in the host's auth file. - -Select Command Code in pi's login dialog - -If automatic transfer from the browser fails, copy the API key shown by Command Code and paste it into the terminal prompt. - -On Oh My Pi, `/login` stores those credentials in OMP's credential store and chat uses them directly. If chat still returns `401 Invalid 'Authorization' header`, restart OMP after `/login` and confirm `/commandcode-quota` shows your account. - -### Environment variable - -```sh -export COMMAND_CODE_API_KEY="user_..." -``` - -### Auth file - -The provider also reads existing credentials from: - -- `~/.commandcode/auth.json` -- `~/.pi/agent/auth.json` -- `~/.omp/agent/auth.json` - -Supported examples: - -```json -{ - "apiKey": "user_..." -} -``` - -```json -{ - "command-code": { - "type": "api", - "key": "user_..." - } -} -``` - -```json -{ - "commandcode": "user_..." -} -``` - -## Usage - -Open `/model` and select one of the models provided by Command Code. Model availability changes over time and is refreshed from the Provider API when the extension loads. - -Other extensions that stream with the active Command Code model, such as background agents or memory workers, use the same connection and the same credentials as the chat, so their requests count against your Command Code usage. - -### Reasoning support - -Reasoning capability and selectable effort levels follow the official CLI catalog independently. Models can therefore be marked as reasoning-capable even when Command Code chooses their depth automatically. Models with explicit effort support register a model-specific `thinkingLevelMap`, so pi and OMP expose only valid levels, including the opt-in `xhigh` and `max` levels. `src/commandcode-catalog-overrides.ts` can add a manual level set for reasoning models that the CLI catalog ships without efforts; it is currently empty because upstream publishes efforts for every selectable model, and the tests fail once upstream publishes levels for a model that still has a manual override. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level for Provider API accounts; the existing Command Code generate transport sends the matching `reasoning_effort` for Go accounts. - -List Command Code models from the terminal: - -```sh -pi --list-models commandcode -``` - -In OMP, use: - -```sh -omp models -``` - -For non-interactive OMP requests, use a provider-qualified model ID shown by `omp models`. For example: - -```sh -omp -p "hello" --model commandcode/deepseek/deepseek-v4-flash -``` - -## Model discovery and offline behavior - -The provider fetches the current model catalog from: - -```txt -https://api.commandcode.ai/provider/v1/models -``` - -The last successful catalog is cached at `/commandcode-models.json`. For pi this is `~/.pi/agent/commandcode-models.json` by default. Compatible hosts such as OMP use their own agent directory. - -When a valid cache exists, the provider registers the cached catalog immediately and refreshes it from the endpoint in the background, so startup does not wait for the network. The refreshed catalog replaces the cached one as soon as it arrives; `/commandcode-status` reports `source: cache` until then. If the endpoint is temporarily unavailable, the cached catalog stays active. On a first start without a cache, the provider waits for the live catalog; if that fails offline, pi still loads, but Command Code models remain unavailable until the connection is restored and `/commandcode-refresh` succeeds. - -While pi is running, use these provider commands without restarting: - -- `/commandcode-refresh` fetches and re-registers the current model catalog. Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active. -- `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning. -- `/commandcode-quota` shows your Command Code account usage and quota in a dashboard-style layout: credits remaining and used with a percentage, monthly/purchased/free sources, the current plan, available usage totals, the API key name, and the 5-hour and weekly usage windows. - -The `commandcode-quota` command reads from the Command Code alpha usage endpoints (the same ones the `cmd` CLI `/usage` command uses): `whoami`, `billing/credits`, `billing/subscriptions`, and `usage/summary`. It authenticates with the same API key the provider already uses. If the command cannot reach those endpoints or an endpoint schema changes, unavailable sections are reported explicitly instead of being displayed as zero usage. Output is plain text (via `ui.notify`) so it works across pi and compatible hosts such as OMP. - -Set `CMD_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header. The legacy `COMMANDCODE_ZDR=1` alias remains supported. - -The following environment variables are intended for tests, local mocks, and compatible API endpoints: - -- `COMMANDCODE_API_BASE` -- `COMMANDCODE_MODELS_URL` -- `COMMANDCODE_MODELS_CACHE` -- `COMMANDCODE_MODELS_TIMEOUT_MS` (defaults to 10 seconds; invalid or non-positive values use the default) - -## Image input - -The provider advertises image input only for models marked with the `image` input modality in the official Command Code CLI model catalog. The capability snapshot currently follows `command-code@1.53.1`; unknown models default to text-only until their upstream metadata is reviewed. A daily GitHub Actions job synchronizes the CLI version, image capabilities, reasoning flags, reasoning efforts, and model-specific output limits with the latest published CLI package and opens or updates a reviewable pull request when they change. Pricing remains manually reviewed because temporary promotions and long-context tiers require explicit review. - -For vision-capable models, Pi's native provider adapters forward image blocks from user messages and tool results using the documented OpenAI or Anthropic message schema. Unknown and text-only models remain marked text-only in Pi. - -## Pricing display - -The Command Code Provider API does not currently include prices in its model catalog. This extension therefore keeps a static table for models with known prices so pi can display estimated request costs. DeepSeek V4 uses time-dependent rates; pi displays the documented off-peak rate, which applies for 17 hours per day. - -Models missing from that table display zero cost in pi. This does **not** mean that Command Code will bill the request at zero. The Command Code Usage page remains authoritative for each request. Check the current [Command Code pricing](https://commandcode.ai/docs/resources/pricing-limits) before relying on the displayed value. - -## Update and remove - -Update installed pi packages: - -```sh -pi update --extensions -``` - -Remove the provider: - -```sh -pi remove npm:pi-commandcode-provider -``` - -For OMP: - -```sh -omp plugin upgrade pi-commandcode-provider -omp plugin uninstall pi-commandcode-provider -``` +Prices are display-only estimates; the Command Code usage page remains +authoritative. ## Development -Start an isolated pi instance with only the current checkout installed and no existing Command Code credentials: - ```sh -npm run pi:isolated +npm install +npm test # unit + extension tests, no network +npm run typecheck ``` -Run `/login` inside pi. Temporary credentials, configuration, and sessions are deleted when pi exits. - -Start the current checkout with your existing pi credentials and only Command Code models in the model picker: - -```sh -npm run pi:authenticated -``` - -Both commands accept additional pi arguments after `--`, for example `npm run pi:authenticated -- --model claude-sonnet-4-6`. - -### Live transport tests - -Keep Go-, GOAT-, and optional Provider-plan test keys in separate secret-manager entries. Pass them through protected files so the keys do not enter shell history: - -```sh -COMMANDCODE_E2E_GO_API_KEY_FILE=/path/to/go-key \ - npm run test:e2e:live:go - -COMMANDCODE_E2E_GOAT_API_KEY_FILE=/path/to/goat-key \ - npm run test:e2e:live:goat - -COMMANDCODE_E2E_PROVIDER_API_KEY_FILE=/path/to/provider-key \ - npm run test:e2e:live:provider - -COMMANDCODE_E2E_GO_API_KEY_FILE=/path/to/go-key \ -COMMANDCODE_E2E_GOAT_API_KEY_FILE=/path/to/goat-key \ - npm run test:e2e:live:all -``` - -Each profile runs with an isolated Pi agent directory and asserts transport selection, reasoning across turns, quota plan identity, abort handling, tool calls, and the packed npm artifact. Go must select `generate` and reject unsupported images; GOAT must select `provider` and complete a live vision request. The profile-specific `*_API_KEY` environment variables are also supported for CI secrets, but key files are preferred for local use. - -The Go profile defaults to DeepSeek V4 Flash; GOAT defaults to Grok 4.6 because its Provider API stream exposes reasoning consistently across consecutive turns. Override them with `COMMANDCODE_E2E_GO_MODEL`, `COMMANDCODE_E2E_GOAT_MODEL`, or `COMMANDCODE_E2E_PROVIDER_MODEL`. The GOAT vision phase defaults to GPT-5.6 Luna and can be overridden with `COMMANDCODE_E2E_GOAT_VISION_MODEL`. A successful live Anthropic `/provider/v1/messages` test requires a paid account whose plan includes the selected Claude model. - -See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup and tests. See [RELEASE.md](RELEASE.md) for the release process. - -## License - -MIT +Tests live in `tests//` next to the module they cover and call the real +production code. `tests/extension/provider.test.ts` drives the extension factory +with a stub `ExtensionAPI` and a local catalog server; no Command Code +credentials are required. diff --git a/RELEASE.md b/RELEASE.md deleted file mode 100644 index 679c520..0000000 --- a/RELEASE.md +++ /dev/null @@ -1,206 +0,0 @@ -# Release Process - -This project uses npm semver releases. - -Recommended flow: - -- publish prereleases with the `next` dist-tag -- smoke-test the npm package directly in pi -- publish stable releases with the `latest` dist-tag -- commit the release on a branch, open a PR, and merge after CI passes -- tag the stable release on `main` after merge -- comment on the related PR or issue after shipping - -## Prerelease flow - -Use `next` for beta/alpha/manual validation builds. - -```sh -npm version prepatch --preid next --no-git-tag-version -npm test -npm run format:check -npm pack --dry-run -npm publish --tag next --access public -``` - -If npm asks for browser or OTP auth, run the publish command manually and complete the npm prompt. - -Verify the registry state: - -```sh -npm view pi-commandcode-provider@next version dist-tags --json -``` - -Expected: - -- `next` points to the prerelease version -- `latest` still points to the previous stable version - -## Test the npm package in pi - -Always test from npm, not the local checkout. - -### 1. Model discovery smoke test - -```sh -PI_SKIP_VERSION_CHECK=1 \ -pi --no-extensions \ - -e npm:pi-commandcode-provider@next \ - --list-models commandcode -``` - -Expected: - -- provider `commandcode` appears -- live Command Code models are listed - -### 2. Manual `/login` test with isolated pi config - -Use temporary pi config and session directories so the test does not touch your real pi auth. - -```sh -export PI_CC_TEST_AGENT_DIR="$(mktemp -d)" -export PI_CC_TEST_SESSION_DIR="$(mktemp -d)" - -export PI_CODING_AGENT_DIR="$PI_CC_TEST_AGENT_DIR" -export PI_CODING_AGENT_SESSION_DIR="$PI_CC_TEST_SESSION_DIR" -export PI_SKIP_VERSION_CHECK=1 - -pi --no-extensions \ - -e npm:pi-commandcode-provider@next \ - --provider commandcode \ - --model deepseek/deepseek-v4-flash -``` - -Inside pi: - -```txt -/login -``` - -Then: - -1. choose **Use a subscription** -2. choose **Command Code** -3. complete the browser auth flow -4. if automatic transfer fails, paste the copied Command Code API key into pi -5. send this message: - -```txt -Reply exactly: manual-npm-ok -``` - -Expected: - -- login succeeds -- a Command Code credential is saved under the temporary `PI_CODING_AGENT_DIR` -- the model replies exactly `manual-npm-ok` - -### 3. Post-login print-mode test - -Using the same exported temp variables from above: - -```sh -pi --no-extensions \ - -e npm:pi-commandcode-provider@next \ - --no-session \ - -p \ - --provider commandcode \ - --model deepseek/deepseek-v4-flash \ - "Reply exactly: manual-npm-ok" -``` - -Expected: - -```txt -manual-npm-ok -``` - -### 4. Cleanup isolated pi config - -Only run this if these variables were created by the test above: - -```sh -rm -rf "$PI_CC_TEST_AGENT_DIR" "$PI_CC_TEST_SESSION_DIR" -unset PI_CC_TEST_AGENT_DIR PI_CC_TEST_SESSION_DIR -unset PI_CODING_AGENT_DIR PI_CODING_AGENT_SESSION_DIR PI_SKIP_VERSION_CHECK -``` - -## Stable release flow - -After the `next` package is verified, set the intended stable version: - -```sh -npm version 0.1.1 --no-git-tag-version -``` - -Replace `0.1.1` with the intended stable version. - -Update `CHANGELOG.md`, then run checks: - -```sh -npm test -npm run format:check -npm pack --dry-run -git diff --check -``` - -Commit on a release branch and open a PR: - -```sh -git checkout -b release/0.1.1 -git add . -git commit -m "Release 0.1.1" -git push origin release/0.1.1 -gh pr create --title "chore(release): publish 0.1.1" --base main -``` - -`main` is branch-protected. The release must go through a PR with passing CI. - -Once CI passes, approve and merge: - -```sh -gh pr review --approve -gh pr merge --squash --delete-branch -``` - -After merge, pull `main` and tag locally: - -```sh -git checkout main -git pull origin main -git tag -a v0.1.1 -m "Release 0.1.1" -git push origin v0.1.1 -``` - -Publish stable locally: - -```sh -npm publish --tag latest --access public -``` - -Publishing is intentionally manual/local; there is no GitHub Actions publish workflow. If npm asks for browser or OTP auth, complete the npm prompt locally. - -Verify npm: - -```sh -npm view pi-commandcode-provider version dist-tags --json -npm view pi-commandcode-provider@0.1.1 version --json -``` - -Expected: - -- `latest` points to the stable version -- the stable version exists on npm - -## GitHub follow-up - -Comment on the related PR and issue after publishing and pushing: - -```sh -gh pr comment --body "Shipped in \`pi-commandcode-provider@0.1.1\` / tag \`v0.1.1\`." - -gh issue comment --body "Shipped in \`pi-commandcode-provider@0.1.1\` / tag \`v0.1.1\`." -``` - -Only comment on PRs or issues actually included in the release. diff --git a/index.ts b/index.ts index 3253d82..d04a639 100644 --- a/index.ts +++ b/index.ts @@ -1,222 +1,137 @@ /** * Command Code provider for pi. * - * Uses Command Code's documented Provider API: - * https://api.commandcode.ai/provider/v1 + * Registers the Provider API catalog as a pi provider and layers pi's native + * auth, model persistence (`models-store.json`), and OpenAI/Anthropic stream + * adapters on top of it. Chat requests therefore use the same code paths as + * built-in providers and need no custom transport. + * + * Provider API reference: https://api.commandcode.ai/provider/v1 */ -import { AssistantMessageEventStream } from "@earendil-works/pi-ai" -import * as piAiCompat from "@earendil-works/pi-ai/compat" -import { streamSimple as streamNativeProvider } from "@earendil-works/pi-ai/compat" -import { - getAgentDir, - type ExtensionAPI, - type ExtensionCommandContext, - type ProviderConfig, -} from "@earendil-works/pi-coding-agent" -import { join } from "node:path" +import type { Api, Model, RefreshModelsContext } from "@earendil-works/pi-ai" +import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent" -import { getConfiguredApiKey } from "./src/api-key.ts" -import { pickCommandCodeApiKey, withResolvedCommandCodeApiKey } from "./src/converters.ts" -import { createStreamCommandCode } from "./src/core.ts" -import { calculateCommandCodeCost } from "./src/cost.ts" +import { discoverApiKey } from "./src/api-key.ts" +import { getApiKey, login, refreshToken } from "./src/auth.ts" import { - apiForModelId, - baseUrlForModel, - DEFAULT_MODELS_URL, - DEFAULT_PROVIDER_API_BASE, + accountApiBase, + fetchLiveCatalog, getModelsTimeoutMs, - inputModalitiesForModel, - loadCachedCommandCodeModels, - loadCommandCodeModels, - MODEL_EFFORTS, - thinkingMetadataForModel, + modelsFromCatalog, + modelsFromLive, + modelsUrl, + PROVIDER_ID, + providerBaseUrl, + providerHeaders, + toProviderModel, type CommandCodeModel, } from "./src/models.ts" -import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts" import { normalizeCommandCodeMessage } from "./src/overflow.ts" -import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" -import { registerCommandCodeQuota } from "./src/quota-command.ts" -import { createCommandCodeRuntime } from "./src/runtime.ts" -import { createCommandCodeTransportRouter } from "./src/transport.ts" +import { fetchCommandCodeQuota } from "./src/quota.ts" +import { formatQuota } from "./src/quota-format.ts" -const COMMAND_CODE_API = "commandcode-custom" -const COMPAT_SOURCE_ID = "pi-commandcode-provider" - -type CompatStreamFunction = ( - model: Parameters[0], - context: Parameters[1], - options?: Parameters[2], -) => AssistantMessageEventStream +type ProviderModelConfig = NonNullable[number] /** - * pi's compat entrypoint exposes `registerApiProvider`; Oh My Pi maps - * `@earendil-works/pi-ai/compat` onto its own pi-ai, which lacks that export - * and registers custom APIs itself inside `registerProvider`. Resolve the - * function at runtime so the extension loads on both hosts. + * pi resolves this env template itself; leaving it unresolved means "not + * configured", so /login credentials and --api-key keep working. */ -function compatApiProviderRegistrar(): ((...args: unknown[]) => unknown) | undefined { - const register = (piAiCompat as { registerApiProvider?: unknown }).registerApiProvider - return typeof register === "function" ? (register as (...args: unknown[]) => unknown) : undefined -} +const API_KEY_ENV_REFERENCE = "$COMMAND_CODE_API_KEY" -function registerCompatApiProvider(stream: CompatStreamFunction): void { - compatApiProviderRegistrar()?.( - { api: COMMAND_CODE_API, stream, streamSimple: stream }, - COMPAT_SOURCE_ID, - ) -} - -/** - * The `apiKey` handed to `registerProvider` means different things per host. - * - * pi parses `$COMMAND_CODE_API_KEY` as an env template: unresolved means - * "not configured", so `/login` credentials and `--api-key` take over, and - * the entry keeps the API-key auth method registered next to OAuth. Without - * it pi composes an OAuth-only provider and drops stored `api_key` - * credentials and `--api-key`. - * - * Oh My Pi has no template notion: an unresolved value stays a literal config - * override that shadows its `/login` credential store and is sent verbatim as - * `Authorization: Bearer $COMMAND_CODE_API_KEY`. There, omit `apiKey` unless - * a real key is configured; OMP then reads env keys and stored credentials - * itself. - * - * Hosts are told apart by the same `registerApiProvider` probe used for the - * compat registry: pi exports it, OMP does not. - */ -function providerApiKey(): string | undefined { - const configured = pickCommandCodeApiKey(getConfiguredApiKey(), undefined) - if (configured) return configured - return compatApiProviderRegistrar() ? "$COMMAND_CODE_API_KEY" : undefined -} - -function commandCodeHeaders(): Record | undefined { - if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") { - return { "x-cmd-zdr": "1" } - } - return undefined -} - -function createProviderConfig( - models: readonly CommandCodeModel[], - apiBase: string, - streamCommandCode: ProviderConfig["streamSimple"], -): ProviderConfig { - const headers = commandCodeHeaders() +/** Models are stored in pi's own catalog cache, which expects full models. */ +function toStoredModel(model: CommandCodeModel, apiBase: string): Model<"openai-completions" | "anthropic-messages"> { + const config = toProviderModel(model, apiBase) return { + ...config, + api: model.api, + provider: PROVIDER_ID, + baseUrl: config.baseUrl ?? apiBase, + } as Model<"openai-completions" | "anthropic-messages"> +} + +export default function commandCodeProvider(pi: ExtensionAPI): void { + const apiBase = providerBaseUrl() + const catalogUrl = modelsUrl() + const catalogTimeoutMs = getModelsTimeoutMs() + const headers = providerHeaders() + const baseline = modelsFromCatalog() + + pi.registerProvider(PROVIDER_ID, { name: "Command Code", baseUrl: apiBase, - apiKey: providerApiKey(), - api: COMMAND_CODE_API, - streamSimple: streamCommandCode, - headers, + apiKey: API_KEY_ENV_REFERENCE, + api: "openai-completions", + ...(headers ? { headers } : {}), + models: baseline.map((model) => toProviderModel(model, apiBase)), oauth: { name: "Command Code", + isSubscription: true, login, refreshToken, - getApiKey: getOAuthApiKey, + getApiKey, }, - models: models.map((model) => ({ - id: model.id, - name: model.name, - api: COMMAND_CODE_API, - baseUrl: baseUrlForModel(apiBase, model.api), - reasoning: model.reasoning, - ...(thinkingMetadataForModel(model.id) ?? {}), - input: [...inputModalitiesForModel(model.id)], - cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - headers, - compat: - model.api === "openai-completions" - ? { - supportsStore: false, - supportsDeveloperRole: false, - supportsReasoningEffort: MODEL_EFFORTS[model.id] !== undefined, - maxTokensField: "max_tokens", - } - : { - supportsEagerToolInputStreaming: false, - supportsLongCacheRetention: false, - supportsCacheControlOnTools: false, - supportsToolReferences: false, - ...(model.reasoning ? { forceAdaptiveThinking: true } : {}), + refreshModels: async (context: RefreshModelsContext): Promise => { + const stored = (context.stored?.models ?? []).filter( + (model: Model) => model.provider === PROVIDER_ID, + ) + + if (context.allowNetwork && !context.signal.aborted) { + try { + const live = await fetchLiveCatalog({ + url: catalogUrl, + timeoutMs: catalogTimeoutMs, + signal: context.signal, + }) + const models = modelsFromLive(live) + await context.publish({ + persist: { + models: models.map((model) => toStoredModel(model, apiBase)), + checkedAt: Date.now(), }, - })), - } -} + }) + return models.map((model) => toProviderModel(model, apiBase)) + } catch { + // An unreachable endpoint keeps the persisted or generated catalog. + } + } -function legacyApiBase(providerApiBase: string): string { - return providerApiBase.replace(/\/provider\/v1\/?$/, "") -} - -export default async function (pi: ExtensionAPI) { - const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_PROVIDER_API_BASE - const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL - const modelsTimeoutMs = getModelsTimeoutMs() - const modelsCachePath = - process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json") - const streamGenerate = createStreamCommandCode({ - createStream: () => new AssistantMessageEventStream(), - calculateCost: calculateCommandCodeCost, - apiBase: legacyApiBase(apiBase), - }) - const resolveStreamOptions = (options?: Parameters[2]) => - withResolvedCommandCodeApiKey(options, getConfiguredApiKey()) - const transport = createCommandCodeTransportRouter({ - createStream: () => new AssistantMessageEventStream(), - streamProvider: (model, context, options) => - streamNativeProvider( - { ...model, api: apiForModelId(model.id), compat: model.compatConfig ?? model.compat }, - context, - resolveStreamOptions(options), - ), - streamGenerate: (model, context, options) => - streamGenerate(model, context, resolveStreamOptions(options)), + if (stored.length > 0) return stored as ProviderModelConfig[] + return baseline.map((model) => toProviderModel(model, apiBase)) + }, }) - // pi dispatches the main chat through the registered provider, but sibling - // extensions that call `streamSimple` from `@earendil-works/pi-ai/compat` - // with a Command Code model resolve `model.api` through the compat - // api-registry, which knows nothing about extension providers. Register the - // custom api there so those calls reach the same transport. The registry - // resolves no credentials for extension providers, so fall back to the - // configured key when the caller passes none or a placeholder. - const compatStream: CompatStreamFunction = (model, context, options) => - transport.stream(model, context, resolveStreamOptions(options)) as AssistantMessageEventStream - registerCompatApiProvider(compatStream) - - pi.on("message_end", async (event, ctx) => { + // pi only auto-compacts when it recognizes the overflow wording; Command Code + // reports context limits in its own phrasing. + pi.on("message_end", (event, ctx) => { if (event.message.role !== "assistant") return const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider) return normalized ? { message: normalized.message } : undefined }) - registerCommandCodeQuota(pi, { - apiBase: legacyApiBase(apiBase), - headers: commandCodeHeaders(), - }) + pi.registerCommand("commandcode-quota", { + description: "Show Command Code account usage and quota", + handler: async (_args, ctx) => { + await ctx.waitForIdle() + const apiKey = discoverApiKey() + if (!apiKey) { + ctx.ui.notify( + "No Command Code API key found. Run /login and select Command Code, or set COMMAND_CODE_API_KEY.", + "warning", + ) + return + } - const runtime = createCommandCodeRuntime(pi, { - endpoint: modelsUrl, - cachePath: modelsCachePath, - loadModels: (signal) => - loadCommandCodeModels({ - url: modelsUrl, - cachePath: modelsCachePath, - timeoutMs: modelsTimeoutMs, - signal, - }), - loadCachedModels: () => loadCachedCommandCodeModels(modelsCachePath), - createProviderConfig: (models) => createProviderConfig(models, apiBase, transport.stream), - getTransport: transport.getTransport, + const result = await fetchCommandCodeQuota({ + apiKey, + baseUrl: accountApiBase(apiBase), + ...(headers ? { headers } : {}), + }) + if (!result.ok) { + ctx.ui.notify(result.error, "error") + return + } + ctx.ui.notify(formatQuota(result.quota), "info") + }, }) - - pi.on("session_shutdown", () => { - runtime.dispose() - }) - - await runtime.initialize() } diff --git a/package-lock.json b/package-lock.json index 8b44448..d2dc74e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "pi-commandcode-provider", - "version": "0.6.4", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-commandcode-provider", - "version": "0.6.4", + "version": "0.1.0", "license": "MIT", "devDependencies": { - "@types/node": "25.6.0", - "prettier": "^3.5.0", - "tsx": "4.22.4", - "typescript": "6.0.3" + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" }, "peerDependencies": { "@earendil-works/pi-ai": "*", @@ -27,7 +28,1100 @@ } } }, - "node_modules/@esbuild/aix-ppc64": { + "node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", + "integrity": "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.71.tgz", + "integrity": "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.73.tgz", + "integrity": "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.16.tgz", + "integrity": "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-login": "^3.972.78", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.78.tgz", + "integrity": "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.83.tgz", + "integrity": "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-ini": "^3.973.16", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.71.tgz", + "integrity": "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.15.tgz", + "integrity": "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/token-providers": "3.1129.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1129.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1129.0.tgz", + "integrity": "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.77.tgz", + "integrity": "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.53.tgz", + "integrity": "sha512-bIrDaMENQmYRBHntOiOheqkiw5+fhKW4Lqb+mS1uqF0VwvdWI22fW2HFgWrng66CmYd+4k8ePlpj38sEfTuMLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", + "integrity": "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz", + "integrity": "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.123.0", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.85.1", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz", + "integrity": "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==", + "dev": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-agent-core": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-tui": "^0.85.1", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/chord": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.85.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "0.28.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.85.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-telemetry": "^0.85.1", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.123.0", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.85.1", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.85.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", @@ -44,7 +1138,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", @@ -61,7 +1155,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", @@ -78,7 +1172,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-x64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", @@ -95,7 +1189,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", @@ -112,7 +1206,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", @@ -129,7 +1223,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/freebsd-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", @@ -146,7 +1240,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/freebsd-x64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", @@ -163,7 +1257,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-arm": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", @@ -180,7 +1274,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", @@ -197,7 +1291,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-ia32": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", @@ -214,7 +1308,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-loong64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-loong64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", @@ -231,7 +1325,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-mips64el": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-mips64el": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", @@ -248,7 +1342,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-ppc64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", @@ -265,7 +1359,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-riscv64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-riscv64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", @@ -282,7 +1376,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-s390x": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-s390x": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", @@ -299,7 +1393,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-x64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", @@ -316,7 +1410,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", @@ -333,7 +1427,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", @@ -350,7 +1444,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/openbsd-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", @@ -367,7 +1461,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", @@ -384,7 +1478,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/openharmony-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openharmony-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", @@ -401,7 +1495,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/sunos-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", @@ -418,7 +1512,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", @@ -435,7 +1529,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-ia32": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", @@ -452,7 +1546,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-x64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", @@ -469,17 +1563,608 @@ "node": ">=18" } }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~6.21.0" } }, - "node_modules/esbuild": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", @@ -521,6 +2206,1618 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz", + "integrity": "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.34.1.tgz", + "integrity": "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -536,26 +3833,317 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "node": ">=18" } }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "dev": true, "license": "MIT", "dependencies": { @@ -571,10 +4159,17 @@ "fsevents": "~2.3.3" } }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -586,11 +4181,43 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 4e5d49c..9404878 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "pi-commandcode-provider", - "version": "0.6.4", - "description": "pi custom provider for Command Code API (commandcode.ai)", + "version": "0.1.0", + "description": "Command Code provider for pi", "type": "module", + "private": true, "keywords": [ "pi-package", "pi-extension", @@ -10,55 +11,11 @@ "provider" ], "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/patlux/pi-commandcode-provider.git" - }, - "homepage": "https://github.com/patlux/pi-commandcode-provider#readme", - "bugs": { - "url": "https://github.com/patlux/pi-commandcode-provider/issues" - }, - "files": [ - "index.ts", - "src/", - "scripts/", - "README.md", - "CHANGELOG.md", - "CONTRIBUTING.md", - "RELEASE.md", - "LICENSE" - ], "scripts": { - "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "node --import tsx --test tests/**/*.test.ts", + "test:unit": "node --import tsx --test tests/*/*.test.ts", "typecheck": "tsc --noEmit", - "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", - "format": "prettier --write '**/*.{ts,mjs,json,md}'", - "pi:isolated": "node scripts/pi-isolated.mjs", - "pi:authenticated": "node scripts/pi-authenticated.mjs", - "check:commandcode-catalog": "tsx .github/scripts/check-commandcode-model-metadata.ts command-code@latest", - "sync:commandcode-catalog": "tsx .github/scripts/check-commandcode-model-metadata.ts command-code@latest --write", - "test:quota": "tsx tests/test-quota.ts && tsx tests/test-quota-command.ts", - "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts", - "test:api-key": "tsx tests/test-api-key.ts", - "test:models": "tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts", - "test:runtime": "tsx tests/test-runtime.ts", - "test:pricing": "tsx tests/test-pricing.ts", - "test:oauth": "tsx tests/test-oauth.ts", - "test:abort": "tsx tests/test-abort.ts", - "test:overflow": "tsx tests/test-overflow.ts", - "test:stream": "tsx tests/test-stream.ts", - "test:retry": "tsx tests/test-retry.ts", - "test:transport": "tsx tests/test-transport.ts", - "test:pi-isolated": "node tests/test-pi-isolated.mjs", - "test:pi-authenticated": "node tests/test-pi-authenticated.mjs", - "test:pi-local": "node tests/test-pi-local.mjs", - "test:smoke": "node tests/test-smoke.mjs", - "test:e2e:live": "node tests/test-live-e2e.mjs", - "test:e2e:live:go": "node scripts/live-e2e-profile.mjs go", - "test:e2e:live:goat": "node scripts/live-e2e-profile.mjs goat", - "test:e2e:live:provider": "node scripts/live-e2e-profile.mjs provider", - "test:e2e:live:all": "node scripts/live-e2e-profile.mjs go goat", - "test:cost": "tsx tests/test-cost.ts" + "sync:catalog": "node scripts/sync-catalog.mjs" }, "pi": { "extensions": [ @@ -66,10 +23,11 @@ ] }, "devDependencies": { - "@types/node": "25.6.0", - "prettier": "^3.5.0", - "tsx": "4.22.4", - "typescript": "6.0.3" + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" }, "peerDependencies": { "@earendil-works/pi-ai": "*", diff --git a/scripts/live-e2e-profile.mjs b/scripts/live-e2e-profile.mjs deleted file mode 100644 index b2cca8d..0000000 --- a/scripts/live-e2e-profile.mjs +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process" -import { readFile } from "node:fs/promises" -import { dirname, resolve } from "node:path" -import { fileURLToPath } from "node:url" - -const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") -const liveTest = resolve(projectDir, "tests", "test-live-e2e.mjs") -const profiles = process.argv.slice(2) - -if ( - profiles.length === 0 || - profiles.some((profile) => profile !== "go" && profile !== "goat" && profile !== "provider") -) { - console.error("Usage: node scripts/live-e2e-profile.mjs [go|goat|provider]") - process.exit(2) -} - -async function credentialFor(profile) { - const prefix = - profile === "go" - ? "COMMANDCODE_E2E_GO" - : profile === "goat" - ? "COMMANDCODE_E2E_GOAT" - : "COMMANDCODE_E2E_PROVIDER" - const direct = process.env[`${prefix}_API_KEY`]?.trim() - const file = process.env[`${prefix}_API_KEY_FILE`] - - if (direct && file) - throw new Error(`${prefix}_API_KEY and ${prefix}_API_KEY_FILE are mutually exclusive`) - if (direct) return direct - if (file) { - const credential = (await readFile(file, "utf-8")).trim() - if (credential) return credential - } - - throw new Error(`Set ${prefix}_API_KEY_FILE (recommended) or ${prefix}_API_KEY`) -} - -function runProfile(profile, apiKey) { - const modelVariable = - profile === "go" - ? "COMMANDCODE_E2E_GO_MODEL" - : profile === "goat" - ? "COMMANDCODE_E2E_GOAT_MODEL" - : "COMMANDCODE_E2E_PROVIDER_MODEL" - const model = - process.env[modelVariable] ?? - (profile === "goat" ? "xai/grok-4.6" : "deepseek/deepseek-v4-flash") - const env = { - ...process.env, - COMMAND_CODE_API_KEY: apiKey, - COMMANDCODE_E2E_MODEL: model, - COMMANDCODE_E2E_PROFILE: profile, - } - delete env.COMMANDCODE_API_KEY - delete env.COMMANDCODE_E2E_GO_API_KEY - delete env.COMMANDCODE_E2E_GOAT_API_KEY - delete env.COMMANDCODE_E2E_PROVIDER_API_KEY - - return new Promise((resolveRun, reject) => { - console.log(`[live-e2e:${profile}] model ${model}`) - const child = spawn(process.execPath, [liveTest], { - cwd: projectDir, - env, - stdio: "inherit", - }) - child.on("error", reject) - child.on("close", (code, signal) => { - if (code === 0) { - resolveRun() - return - } - reject(new Error(`[live-e2e:${profile}] failed (${signal ?? `exit ${code}`})`)) - }) - }) -} - -try { - for (const profile of profiles) { - await runProfile(profile, await credentialFor(profile)) - } -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)) - process.exit(1) -} diff --git a/scripts/pi-authenticated.mjs b/scripts/pi-authenticated.mjs deleted file mode 100644 index 974d590..0000000 --- a/scripts/pi-authenticated.mjs +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process" -import { dirname, resolve } from "node:path" -import { fileURLToPath } from "node:url" - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") -const extensionPath = resolve(repoRoot, "index.ts") - -const env = { - ...process.env, - PI_SKIP_VERSION_CHECK: "1", -} -delete env.COMMAND_CODE_API_KEY -delete env.COMMANDCODE_API_KEY - -const child = spawn( - "pi", - [ - "--no-extensions", - "--extension", - extensionPath, - "--provider", - "commandcode", - "--model", - "gpt-5.6-luna", - "--models", - "commandcode/*", - ...process.argv.slice(2), - ], - { - cwd: repoRoot, - env, - stdio: "inherit", - }, -) - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)) -} - -child.once("error", (error) => { - console.error(`Could not start pi: ${error.message}`) - process.exitCode = 1 -}) - -child.once("exit", (status, signal) => { - if (signal) process.kill(process.pid, signal) - process.exitCode = status ?? 1 -}) diff --git a/scripts/pi-isolated.mjs b/scripts/pi-isolated.mjs deleted file mode 100644 index b66c622..0000000 --- a/scripts/pi-isolated.mjs +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process" -import { mkdir, mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { dirname, join, resolve } from "node:path" -import { fileURLToPath } from "node:url" - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") -const testRoot = await mkdtemp(join(tmpdir(), "pi-commandcode-isolated-")) -const agentDir = join(testRoot, "agent") -const sessionDir = join(testRoot, "sessions") - -await mkdir(agentDir, { mode: 0o700 }) -await mkdir(sessionDir, { mode: 0o700 }) - -const env = { - ...process.env, - HOME: testRoot, - USERPROFILE: testRoot, - PI_CODING_AGENT_DIR: agentDir, - PI_CODING_AGENT_SESSION_DIR: sessionDir, - PI_SKIP_VERSION_CHECK: "1", -} -delete env.COMMAND_CODE_API_KEY -delete env.COMMANDCODE_API_KEY - -let activeChild -let receivedSignal - -function forwardSignal(signal) { - receivedSignal = signal - activeChild?.kill(signal) -} - -const forwardSigint = () => forwardSignal("SIGINT") -const forwardSigterm = () => forwardSignal("SIGTERM") -process.on("SIGINT", forwardSigint) -process.on("SIGTERM", forwardSigterm) - -function runPi(args) { - return new Promise((resolveRun, rejectRun) => { - const child = spawn("pi", args, { cwd: repoRoot, env, stdio: "inherit" }) - activeChild = child - child.once("error", rejectRun) - child.once("exit", (status, signal) => { - activeChild = undefined - resolveRun({ status, signal }) - }) - }) -} - -let result -try { - console.error("Installing the current checkout into an isolated pi environment...") - const install = await runPi(["install", repoRoot, "--no-approve"]) - if (install.status !== 0 || install.signal) { - result = install - } else { - console.error("Starting pi. Temporary auth and sessions will be removed on exit.") - result = await runPi([ - "--no-approve", - "--provider", - "commandcode", - "--model", - "gpt-5.6-luna", - ...process.argv.slice(2), - ]) - } -} finally { - process.removeListener("SIGINT", forwardSigint) - process.removeListener("SIGTERM", forwardSigterm) - await rm(testRoot, { recursive: true, force: true }) - console.error("Removed the isolated pi environment.") -} - -const signal = receivedSignal ?? result?.signal -if (signal) process.kill(process.pid, signal) -process.exitCode = result?.status ?? 1 diff --git a/scripts/sync-catalog.mjs b/scripts/sync-catalog.mjs new file mode 100644 index 0000000..1534ee0 --- /dev/null +++ b/scripts/sync-catalog.mjs @@ -0,0 +1,285 @@ +/** + * 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:"",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() +} diff --git a/src/api-key.ts b/src/api-key.ts index f09e155..10d807b 100644 --- a/src/api-key.ts +++ b/src/api-key.ts @@ -1,69 +1,72 @@ +/** + * Locates a Command Code API key outside of pi's own credential resolution. + * + * pi resolves credentials for chat requests itself; this is only used by the + * `/commandcode-quota` command, which needs the key for account endpoints. + */ + import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" +const API_KEY_ENV_VARS = ["COMMAND_CODE_API_KEY", "COMMANDCODE_API_KEY"] as const + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined -} - -function defaultAuthPaths(home: string): string[] { - return [ - join(home, ".commandcode", "auth.json"), - join(home, ".pi", "agent", "auth.json"), - join(home, ".omp", "agent", "auth.json"), - ] + return typeof value === "string" && value.length > 0 ? value : undefined } function apiKeyFromCredential(value: unknown): string | undefined { if (!isRecord(value)) return undefined - - if (stringValue(value.type) === "oauth") return stringValue(value.access) - if (stringValue(value.type) === "api") return stringValue(value.key) - return stringValue(value.access) ?? stringValue(value.key) + return stringValue(value.key) ?? stringValue(value.access) } -export function getConfiguredApiKey( - options: { - env?: NodeJS.ProcessEnv - authPaths?: readonly string[] - homeDir?: () => string - } = {}, -): string | undefined { +/** pi stores its agent directory override as PI_CODING_AGENT_DIR. */ +export function piAuthPaths(env: NodeJS.ProcessEnv = process.env, home = homedir()): string[] { + const agentDir = env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent") + return [join(agentDir, "auth.json"), join(home, ".commandcode", "auth.json")] +} + +export interface DiscoverApiKeyOptions { + env?: NodeJS.ProcessEnv + authPaths?: readonly string[] + homeDir?: () => string +} + +/** First configured key wins: environment, then pi's and Command Code's auth files. */ +export function discoverApiKey(options: DiscoverApiKeyOptions = {}): string | undefined { const env = options.env ?? process.env - if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY - if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY + for (const name of API_KEY_ENV_VARS) { + const value = env[name] + if (value) return value + } const home = options.homeDir?.() ?? homedir() - const authPaths = options.authPaths ?? defaultAuthPaths(home) - - for (const authPath of authPaths) { + for (const authPath of options.authPaths ?? piAuthPaths(env, home)) { try { if (!existsSync(authPath)) continue const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8")) if (!isRecord(parsed)) continue - const apiKey = stringValue(parsed.apiKey) - if (apiKey) return apiKey - - const commandcode = stringValue(parsed.commandcode) - if (commandcode) return commandcode - - const providerKey = apiKeyFromCredential(parsed.commandcode) - if (providerKey) return providerKey - - const commandCode = stringValue(parsed["command-code"]) + const direct = stringValue(parsed.apiKey) + if (direct) return direct + const commandCode = stringValue(parsed.commandcode) if (commandCode) return commandCode - - const commandCodeKey = apiKeyFromCredential(parsed["command-code"]) - if (commandCodeKey) return commandCodeKey + const credential = apiKeyFromCredential(parsed.commandcode) + if (credential) return credential + const legacy = stringValue(parsed["command-code"]) + if (legacy) return legacy + const legacyCredential = apiKeyFromCredential(parsed["command-code"]) + if (legacyCredential) return legacyCredential } catch { - // Ignore malformed or unreadable auth files. + // Unreadable or malformed auth files must not break the command. } } return undefined } + +export { API_KEY_ENV_VARS } diff --git a/src/auth-server.ts b/src/auth-server.ts index ee9e6de..ab30ea8 100644 --- a/src/auth-server.ts +++ b/src/auth-server.ts @@ -1,8 +1,8 @@ /** - * Local HTTP callback server for the Command Code browser auth flow. + * One-shot localhost callback server for the Command Code browser login. * - * Starts a one-shot server on a CLI-compatible localhost port. The Command Code - * Studio website POSTs the user's API key to /callback after they authenticate. + * The Command Code Studio page POSTs the freshly created API key to + * /callback, which lets `/login` finish without copy and paste. */ import { createServer, type Server } from "node:http" @@ -10,212 +10,151 @@ import type { AddressInfo } from "node:net" const DEFAULT_PORT = 5959 const DEFAULT_PORT_RANGE = 10 +const MAX_BODY_BYTES = 10_000 +const ALLOWED_ORIGINS = [ + "https://commandcode.ai", + "https://staging.commandcode.ai", + "http://localhost:3000", +] +const DEFAULT_ALLOWED_ORIGIN = "https://commandcode.ai" export interface AuthCallback { apiKey: string state: string - userId: string - userName: string - keyName: string } export interface AuthServer { - server: Server port: number waitForCallback: Promise + close: () => void } export interface AuthServerOptions { + expectedState: string startPort?: number portRange?: number - expectedState?: string } -function listenOnAvailablePort( - server: Server, - startPort = DEFAULT_PORT, - range = DEFAULT_PORT_RANGE, -): Promise { +function listen(server: Server, startPort: number, range: number): Promise { return new Promise((resolve, reject) => { let offset = 0 - const tryListen = () => { - const useFallbackPort = startPort === 0 || offset >= range - const port = useFallbackPort ? 0 : startPort + offset - - const onError = (err: NodeJS.ErrnoException) => { - server.off("listening", onListening) - if (err.code === "EADDRINUSE" && !useFallbackPort) { + const attempt = () => { + const fixedPort = range > 0 && offset < range ? startPort + offset : 0 + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE" && fixedPort !== 0) { offset += 1 - tryListen() + attempt() return } - reject(err) - } - - const onListening = () => { - server.off("error", onError) - const address = server.address() as AddressInfo - resolve(address.port) - } - - server.once("error", onError) - server.once("listening", onListening) - server.listen(port, "127.0.0.1") + reject(error) + }) + server.once("listening", () => resolve((server.address() as AddressInfo).port)) + server.listen(fixedPort, "127.0.0.1") } - tryListen() + attempt() }) } -function closeServer(server: Server) { - server.close((err: NodeJS.ErrnoException | undefined) => { - if (err && err.code !== "ERR_SERVER_NOT_RUNNING") { - // There is nowhere useful to report this during auth cleanup. - } +function close(server: Server): void { + server.close(() => { + // Closing is best effort; the process exits or the flow continues regardless. }) } -/** - * Start a local HTTP server that listens for the Command Code Studio - * to POST the API key after the user authenticates in their browser. - * - * The server accepts exactly one valid POST to /callback and then closes. - */ -export async function startAuthServer(options: AuthServerOptions = {}): Promise { - let resolveCallback!: (value: AuthCallback) => void - let rejectCallback!: (error: Error) => void - +export async function startAuthServer(options: AuthServerOptions): Promise { + let settle: (callback: AuthCallback) => void = () => {} + let fail: (error: Error) => void = () => {} const waitForCallback = new Promise((resolve, reject) => { - resolveCallback = resolve - rejectCallback = reject + settle = resolve + fail = reject }) - const server = createServer((req, res) => { - // CORS: allow requests from Command Code domains and localhost for dev. - const origin = req.headers.origin || "" - const allowedOrigins = [ - "http://localhost:3000", - "https://staging.commandcode.ai", - "https://commandcode.ai", - ] - const responseOrigin = allowedOrigins.includes(origin) ? origin : allowedOrigins[0] - const requestedHeaders = req.headers["access-control-request-headers"] - - res.setHeader("Access-Control-Allow-Origin", responseOrigin) - res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS") - res.setHeader( - "Access-Control-Allow-Headers", - typeof requestedHeaders === "string" && requestedHeaders.length > 0 - ? requestedHeaders - : "Content-Type", + const server = createServer((request, response) => { + const origin = request.headers.origin ?? "" + response.setHeader( + "Access-Control-Allow-Origin", + ALLOWED_ORIGINS.includes(origin) ? origin : DEFAULT_ALLOWED_ORIGIN, ) - // Chrome's Private Network Access preflight may require this for an HTTPS - // page posting to a localhost HTTP callback. - res.setHeader("Access-Control-Allow-Private-Network", "true") - res.setHeader("Content-Type", "application/json") + response.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS") + response.setHeader( + "Access-Control-Allow-Headers", + request.headers["access-control-request-headers"]?.toString() || "Content-Type", + ) + // Chrome may require this for an HTTPS page posting to localhost. + response.setHeader("Access-Control-Allow-Private-Network", "true") + response.setHeader("Content-Type", "application/json") - // Handle CORS preflight. - if (req.method === "OPTIONS") { - res.writeHead(204) - res.end() + if (request.method === "OPTIONS") { + response.writeHead(204) + response.end() return } - - if (req.url !== "/callback") { - res.writeHead(404) - res.end(JSON.stringify({ success: false, error: "Not found" })) + if (request.url !== "/callback") { + response.writeHead(404) + response.end(JSON.stringify({ success: false, error: "Not found" })) return } - - if (req.method !== "POST") { - res.writeHead(405) - res.end( - JSON.stringify({ - success: false, - error: "Method not allowed. Use POST.", - }), - ) + if (request.method !== "POST") { + response.writeHead(405) + response.end(JSON.stringify({ success: false, error: "Method not allowed" })) return } let body = "" - req.on("data", (chunk) => { + request.on("data", (chunk) => { body += chunk.toString() - if (body.length > 10_000) req.destroy() + if (body.length > MAX_BODY_BYTES) request.destroy() }) - - req.on("end", () => { + request.on("end", () => { + let parsed: unknown try { - const parsed = JSON.parse(body) as Record - - if (parsed.error) { - res.writeHead(200) - res.end(JSON.stringify({ success: true })) - const description = - typeof parsed.error_description === "string" - ? parsed.error_description - : String(parsed.error) - if (parsed.error === "access_denied") { - rejectCallback(new Error(description || "Authorization was denied by the user")) - } else { - rejectCallback(new Error(description || String(parsed.error))) - } - closeServer(server) - return - } - - const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : "" - const state = typeof parsed.state === "string" ? parsed.state : "" - const userId = typeof parsed.userId === "string" ? parsed.userId : "" - const userName = typeof parsed.userName === "string" ? parsed.userName : "" - const keyName = typeof parsed.keyName === "string" ? parsed.keyName : "" - - if (!apiKey || !state || !userId || !userName || !keyName) { - res.writeHead(400) - res.end( - JSON.stringify({ - success: false, - error: "Missing required fields", - }), - ) - return - } - - if (options.expectedState !== undefined && state !== options.expectedState) { - res.writeHead(403) - res.end(JSON.stringify({ success: false, error: "Invalid state token" })) - return - } - - res.writeHead(200) - res.end(JSON.stringify({ success: true })) - - resolveCallback({ apiKey, state, userId, userName, keyName }) - closeServer(server) + parsed = JSON.parse(body) } catch { - res.writeHead(400) - res.end(JSON.stringify({ success: false, error: "Invalid JSON" })) + response.writeHead(400) + response.end(JSON.stringify({ success: false, error: "Invalid JSON" })) + return + } + if (typeof parsed !== "object" || parsed === null) { + response.writeHead(400) + response.end(JSON.stringify({ success: false, error: "Invalid payload" })) + return } - }) - req.on("error", () => { - res.writeHead(500) - res.end(JSON.stringify({ success: false, error: "Request error" })) + const payload = parsed as Record + if (typeof payload.error === "string" && payload.error.length > 0) { + response.writeHead(200) + response.end(JSON.stringify({ success: true })) + fail(new Error(String(payload.error_description ?? payload.error))) + close(server) + return + } + + const apiKey = typeof payload.apiKey === "string" ? payload.apiKey : "" + const state = typeof payload.state === "string" ? payload.state : "" + if (!apiKey || !state) { + response.writeHead(400) + response.end(JSON.stringify({ success: false, error: "Missing apiKey or state" })) + return + } + if (state !== options.expectedState) { + response.writeHead(403) + response.end(JSON.stringify({ success: false, error: "Invalid state token" })) + return + } + + response.writeHead(200) + response.end(JSON.stringify({ success: true })) + settle({ apiKey, state }) + close(server) + }) + request.on("error", () => { + response.writeHead(500) + response.end(JSON.stringify({ success: false, error: "Request error" })) }) }) - try { - const port = await listenOnAvailablePort( - server, - options.startPort ?? DEFAULT_PORT, - options.portRange ?? DEFAULT_PORT_RANGE, - ) - return { server, port, waitForCallback } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - const error = new Error(`Failed to start auth server: ${message}`) - rejectCallback(error) - throw error - } + const port = await listen(server, options.startPort ?? DEFAULT_PORT, options.portRange ?? DEFAULT_PORT_RANGE) + return { port, waitForCallback, close: () => close(server) } } diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..861abf0 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,194 @@ +/** + * Command Code login for pi's /login flow. + * + * Command Code issues non-expiring API keys, so both flows (browser transfer + * and manual paste) return the key as refresh/access credentials with a + * far-future expiry; `refreshToken` is therefore a no-op. + */ + +import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai" + +import { startAuthServer } from "./auth-server.ts" + +const STUDIO_BASE_URL = "https://commandcode.ai" +const DEFAULT_API_BASE = "https://api.commandcode.ai" +const DEFAULT_AUTH_TIMEOUT_MS = 120_000 +const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 + +function authTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.COMMANDCODE_AUTH_TIMEOUT_MS + if (!raw) return DEFAULT_AUTH_TIMEOUT_MS + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AUTH_TIMEOUT_MS +} + +/** Strips bracketed-paste markers and control characters from terminal input. */ +export function sanitizeApiKey(input: string): string { + const escape = String.fromCharCode(27) + return Array.from( + input + .replaceAll(`${escape}[200~`, "") + .replaceAll(`${escape}[201~`, "") + .replaceAll("[200~", "") + .replaceAll("[201~", ""), + ) + .filter((character) => { + const code = character.charCodeAt(0) + return code > 31 && code !== 127 + }) + .join("") + .trim() +} + +export interface ValidateApiKeyOptions { + apiBase?: string + fetchImpl?: typeof fetch + signal?: AbortSignal +} + +/** A key is valid when the account endpoint accepts it as a bearer token. */ +export async function validateApiKey( + apiKey: string, + options: ValidateApiKeyOptions = {}, +): Promise { + const base = options.apiBase ?? DEFAULT_API_BASE + let response: Response + try { + response = await (options.fetchImpl ?? fetch)(`${base}/alpha/whoami`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: options.signal, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Could not reach Command Code to validate the API key: ${message}`) + } + + if (response.status === 401) throw new Error("Command Code rejected the API key") + if (!response.ok) throw new Error(`Could not validate the Command Code API key (${response.status})`) +} + +export function credentialsFromApiKey(apiKey: string): OAuthCredentials { + return { refresh: apiKey, access: apiKey, expires: Date.now() + TEN_YEARS_MS } +} + +type LoginChoice = "browser" | "prompt" | { apiKey: string } + +async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise { + if (typeof callbacks.onSelect === "function") { + const selected = await callbacks.onSelect({ + message: "Command Code login", + options: [ + { id: "browser", label: "Browser login (recommended)" }, + { id: "key", label: "Paste an API key" }, + ], + }) + if (selected === "key") return "prompt" + if (selected !== undefined) return "browser" + } + + const input = sanitizeApiKey( + await callbacks.onPrompt({ + message: + "Command Code login: press Enter for browser login, type 'key' to paste an API key, or paste the key directly:", + }), + ) + const normalized = input.toLowerCase() + if (!input || normalized === "b" || normalized === "browser") return "browser" + if (normalized === "k" || normalized === "key" || normalized === "api" || normalized === "paste") { + return "prompt" + } + return { apiKey: input } +} + +async function promptForApiKey( + callbacks: OAuthLoginCallbacks, + message: string, +): Promise { + const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message })) + if (!apiKey) throw new Error("No Command Code API key provided") + await validateApiKey(apiKey, { signal: callbacks.signal }) + return credentialsFromApiKey(apiKey) +} + +function generateStateToken(): string { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return Buffer.from(bytes).toString("base64url") +} + +function withTimeout(promise: Promise, timeoutMs: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Browser authentication timed out")), timeoutMs) + const onAbort = () => { + clearTimeout(timer) + reject(new Error("Login cancelled")) + } + signal?.addEventListener("abort", onAbort, { once: true }) + promise.then( + (value) => { + clearTimeout(timer) + signal?.removeEventListener("abort", onAbort) + resolve(value) + }, + (error: unknown) => { + clearTimeout(timer) + signal?.removeEventListener("abort", onAbort) + reject(error) + }, + ) + }) +} + +async function browserLogin(callbacks: OAuthLoginCallbacks): Promise { + const state = generateStateToken() + let server + try { + server = await startAuthServer({ expectedState: state }) + } catch { + return promptForApiKey(callbacks, "Browser login unavailable. Paste your Command Code API key:") + } + + const callbackUrl = `http://localhost:${server.port}/callback` + const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}` + callbacks.onAuth({ url: authUrl }) + + try { + const callback = await withTimeout( + server.waitForCallback, + authTimeoutMs(), + callbacks.signal, + ) + return credentialsFromApiKey(callback.apiKey) + } catch (error) { + server.close() + // Command Code shows the key when the browser cannot reach localhost, so + // fall back to the paste prompt instead of failing the whole login. + if (error instanceof Error && error.message === "Browser authentication timed out") { + return promptForApiKey( + callbacks, + "Automatic transfer timed out. Paste your Command Code API key:", + ) + } + throw error + } +} + +export async function login(callbacks: OAuthLoginCallbacks): Promise { + const choice = await chooseLoginFlow(callbacks) + if (choice === "prompt") { + return promptForApiKey(callbacks, "Paste your Command Code API key:") + } + if (choice === "browser") return browserLogin(callbacks) + + await validateApiKey(choice.apiKey, { signal: callbacks.signal }) + return credentialsFromApiKey(choice.apiKey) +} + +/** Command Code keys do not expire, so refreshing only extends the local expiry. */ +export async function refreshToken(credentials: OAuthCredentials): Promise { + return credentialsFromApiKey(credentials.refresh) +} + +export function getApiKey(credentials: OAuthCredentials): string { + return credentials.access +} diff --git a/src/catalog.ts b/src/catalog.ts new file mode 100644 index 0000000..0887a86 --- /dev/null +++ b/src/catalog.ts @@ -0,0 +1,737 @@ +/** + * Generated by scripts/sync-catalog.mjs from the published Command Code CLI + * package. Do not edit manually: run `npm run sync:catalog` instead. + */ + +export const COMMAND_CODE_CLI_VERSION = "1.54.0" + +export type CommandCodeInputModality = "text" | "image" + +export type CommandCodeReasoningEffort = + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max" + +export interface CommandCodeModelCost { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +export interface CatalogModel { + id: string + name: string + contextWindow: number + efforts: readonly CommandCodeReasoningEffort[] + reasoning: boolean + input: readonly CommandCodeInputModality[] + maxOutputTokens: number + cost: CommandCodeModelCost +} + +export const CATALOG: readonly CatalogModel[] = [ + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro (latest)", + contextWindow: 1000000, + efforts: ["high", "max"], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 }, + }, + { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash (latest)", + contextWindow: 1000000, + efforts: ["high", "max"], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + }, + { + id: "deepseek/deepseek-v4-flash-vision-exp", + name: "DeepSeek V4 Flash Vision (exp)", + contextWindow: 1000000, + efforts: ["high", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + }, + { + id: "deepseek/deepseek-v4-flash-fast", + name: "DeepSeek V4 Flash Fast", + contextWindow: 1000000, + efforts: ["low", "high", "max"], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.28, output: 0.56, cacheRead: 0.07, cacheWrite: 0 }, + }, + { + id: "deepseek/deepseek-v4.1-flash", + name: "DeepSeek V4.1 Flash", + contextWindow: 1000000, + efforts: ["low", "high", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + }, + { + id: "moonshotai/Kimi-K3", + name: "Kimi K3", + contextWindow: 1000000, + efforts: ["low", "high", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, + }, + { + id: "moonshotai/Kimi-K2.7-Code", + name: "Kimi K2.7 Code", + contextWindow: 256000, + efforts: [], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, + }, + { + id: "moonshotai/Kimi-K2.7-Code-Highspeed", + name: "Kimi K2.7 Code HighSpeed", + contextWindow: 262000, + efforts: [], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 }, + }, + { + id: "moonshotai/Kimi-K2.6", + name: "Kimi K2.6", + contextWindow: 256000, + efforts: [], + reasoning: false, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, + }, + { + id: "moonshotai/Kimi-K2.5", + name: "Kimi K2.5", + contextWindow: 256000, + efforts: [], + reasoning: false, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, + }, + { + id: "z-ai/glm-5.3-flash", + name: "GLM-5.3 Flash", + contextWindow: 1048576, + efforts: ["low", "high", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 131072, + cost: { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-5.3", + name: "GLM-5.3", + contextWindow: 1000000, + efforts: ["low", "high", "max"], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-5.2", + name: "GLM-5.2", + contextWindow: 1000000, + efforts: ["high", "max"], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-5.2-Fast", + name: "GLM-5.2 Fast", + contextWindow: 1000000, + efforts: [], + reasoning: false, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 3, output: 10.25, cacheRead: 0.5, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-5.1", + name: "GLM-5.1", + contextWindow: 0, + efforts: [], + reasoning: false, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-5", + name: "GLM-5", + contextWindow: 200000, + efforts: [], + reasoning: false, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, + }, + { + id: "MiniMaxAI/MiniMax-M3", + name: "MiniMax M3", + contextWindow: 1000000, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + }, + { + id: "MiniMaxAI/MiniMax-M2.7", + name: "MiniMax M2.7", + contextWindow: 0, + efforts: [], + reasoning: false, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + }, + { + id: "MiniMaxAI/MiniMax-M2.5", + name: "MiniMax M2.5", + contextWindow: 200000, + efforts: [], + reasoning: false, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 }, + }, + { + id: "xiaomi/mimo-v2.5-pro", + name: "MiMo V2.5 Pro", + contextWindow: 1000000, + efforts: [], + reasoning: false, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }, + }, + { + id: "xiaomi/mimo-v2.5", + name: "MiMo V2.5", + contextWindow: 1000000, + efforts: [], + reasoning: false, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3.8-Max-0902", + name: "Qwen 3.8 Max 0902", + contextWindow: 1000000, + efforts: ["low", "medium", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3.8-Max", + name: "Qwen 3.8 Max", + contextWindow: 1000000, + efforts: ["low", "medium", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, + }, + { + id: "Qwen/Qwen3.8-27B", + name: "Qwen 3.8 27B", + contextWindow: 262144, + efforts: ["low", "medium", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 32768, + cost: { input: 0.4, output: 3, cacheRead: 0.04, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3.8-Flash", + name: "Qwen 3.8 Flash", + contextWindow: 1000000, + efforts: ["low", "medium", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.16, output: 0.47, cacheRead: 0.016, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3.7-Max", + name: "Qwen 3.7 Max", + contextWindow: 1000000, + efforts: [], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, + }, + { + id: "Qwen/Qwen3.7-Plus", + name: "Qwen 3.7 Plus", + contextWindow: 1000000, + efforts: [], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.4, output: 1.6, cacheRead: 0.08, cacheWrite: 0.5 }, + }, + { + id: "Qwen/Qwen3.7-Flash", + name: "Qwen 3.7 Flash", + contextWindow: 1000000, + efforts: [], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.03, output: 0.13, cacheRead: 0.006, cacheWrite: 0.038 }, + }, + { + id: "Qwen/Qwen3.6-Max-Preview", + name: "Qwen 3.6 Max Preview", + contextWindow: 0, + efforts: [], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 1.3, output: 7.8, cacheRead: 0.26, cacheWrite: 1.63 }, + }, + { + id: "Qwen/Qwen3.6-Plus", + name: "Qwen 3.6 Plus", + contextWindow: 0, + efforts: [], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 }, + }, + { + id: "meituan/LongCat-2.0:free", + name: "LongCat 2.0", + contextWindow: 1048576, + efforts: [], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "stepfun/Step-3.7-Flash", + name: "Step 3.7 Flash", + contextWindow: 256000, + efforts: [], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 }, + }, + { + id: "stepfun/Step-3.5-Flash", + name: "Step 3.5 Flash", + contextWindow: 1000000, + efforts: [], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 }, + }, + { + id: "tencent/hy3-paid", + name: "Tencent Hy3", + contextWindow: 262144, + efforts: [], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, + }, + { + id: "tencent/hy4-preview", + name: "Tencent Hy4 Preview", + contextWindow: 1048576, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.834, output: 2.501, cacheRead: 0.042, cacheWrite: 0 }, + }, + { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "Nemotron 3 Ultra", + contextWindow: 1000000, + efforts: [], + reasoning: true, + input: ["text"], + maxOutputTokens: 0, + cost: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 }, + }, + { + id: "thinkingmachines/inkling", + name: "Inkling", + contextWindow: 256000, + efforts: [], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 }, + }, + { + id: "thinkingmachines/inkling-small", + name: "Inkling Small", + contextWindow: 1000000, + efforts: [], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.5, output: 1.2, cacheRead: 0.1, cacheWrite: 0 }, + }, + { + id: "poolside/laguna-s-2.1-free", + name: "Laguna S 2.1", + contextWindow: 256000, + efforts: [], + reasoning: true, + input: ["text"], + maxOutputTokens: 32768, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "inclusionai/ling-3.0-flash-sante:free", + name: "Ling 3.0 Flash Sante", + contextWindow: 262144, + efforts: [], + reasoning: true, + input: ["text"], + maxOutputTokens: 32768, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + contextWindow: 1000000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + contextWindow: 1000000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + }, + { + id: "claude-fable-5-1", + name: "Claude Fable 5.1", + contextWindow: 1000000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, + }, + { + id: "claude-fable-5", + name: "Claude Fable 5", + contextWindow: 1000000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + }, + { + id: "claude-opus-5", + name: "Claude Opus 5", + contextWindow: 1000000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + contextWindow: 1000000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + contextWindow: 1000000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + contextWindow: 200000, + efforts: [], + reasoning: false, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + }, + { + id: "gpt-6-astra", + name: "GPT-6 Astra", + contextWindow: 1050000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + }, + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + contextWindow: 1050000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + { + id: "gpt-5.6-terra", + name: "GPT-5.6 Terra", + contextWindow: 1050000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, + }, + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + contextWindow: 1050000, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + contextWindow: 400000, + efforts: ["low", "medium", "high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + }, + { + id: "gpt-5.4", + name: "GPT-5.4", + contextWindow: 400000, + efforts: ["low", "medium", "high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, + }, + { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + contextWindow: 400000, + efforts: ["low", "medium", "high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, + }, + { + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + contextWindow: 400000, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, + }, + { + id: "google/gemini-3.8-flash", + name: "Gemini 3.8 Flash", + contextWindow: 1000000, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, + }, + { + id: "google/gemini-3.7-flash", + name: "Gemini 3.7 Flash", + contextWindow: 1048576, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0.08334 }, + }, + { + id: "google/gemini-3.6-flash", + name: "Gemini 3.6 Flash", + contextWindow: 1000000, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, + }, + { + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + contextWindow: 1000000, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, + }, + { + id: "google/gemini-3.5-flash-lite", + name: "Gemini 3.5 Flash Lite", + contextWindow: 1000000, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, + }, + { + id: "google/gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", + contextWindow: 1000000, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.25, output: 1.5, cacheRead: 0.03, cacheWrite: 0 }, + }, + { + id: "sakana/fugu-ultra", + name: "Fugu Ultra", + contextWindow: 1000000, + efforts: ["high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + }, + { + id: "meta/muse-spark-1.1", + name: "Muse Spark 1.1", + contextWindow: 1048576, + efforts: ["low", "medium", "high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, + }, + { + id: "meta/muse-spark-1.2", + name: "Muse Spark 1.2", + contextWindow: 1048576, + efforts: ["low", "medium", "high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, + }, + { + id: "meta/muse-spark-1.2-contributor", + name: "Muse Spark 1.2 Contributor", + contextWindow: 1048576, + efforts: ["low", "medium", "high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, + }, + { + id: "meta/muse-spark-1.3", + name: "Muse Spark 1.3", + contextWindow: 1048576, + efforts: ["low", "medium", "high", "xhigh", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, + }, + { + id: "meta/muse-spark-1.3-contributor", + name: "Muse Spark 1.3 Contributor", + contextWindow: 1048576, + efforts: ["low", "medium", "high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, + }, + { + id: "xai/grok-4.5", + name: "Grok 4.5", + contextWindow: 500000, + efforts: ["low", "medium", "high"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, + }, + { + id: "xai/grok-4.6", + name: "Grok 4.6", + contextWindow: 500000, + efforts: ["low", "medium", "high", "xhigh"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, + }, +] diff --git a/src/commandcode-catalog-overrides.ts b/src/commandcode-catalog-overrides.ts deleted file mode 100644 index bdf4e6b..0000000 --- a/src/commandcode-catalog-overrides.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { CommandCodeReasoningEffort } from "./commandcode-catalog.ts" - -/** - * Manual reasoning-effort policy for models the official CLI marks as - * reasoning-capable without publishing selectable efforts. - * - * `src/commandcode-catalog.ts` is generated from the CLI package and must stay - * byte-identical to upstream so the daily drift check works. Entries here are - * merged over the generated catalog at load time and are not touched by - * `npm run sync:commandcode-catalog`. - * - * Add a model only when the effort parameter is known to be accepted by the - * Command Code endpoint; remove it once the CLI catalog ships its own efforts. - * The map is currently empty: upstream published efforts for Meta Muse Spark - * 1.1-1.3 and MiniMax M3, so no manual policy is needed. - */ -export const MODEL_EFFORT_OVERRIDES: Readonly< - Record -> = {} diff --git a/src/commandcode-catalog.ts b/src/commandcode-catalog.ts deleted file mode 100644 index ec71ab1..0000000 --- a/src/commandcode-catalog.ts +++ /dev/null @@ -1,178 +0,0 @@ -export const COMMAND_CODE_CLI_VERSION = "1.53.1" - -export type CommandCodeInputType = "text" | "image" -export type CommandCodeReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" - -/** - * Generated from command-code@1.53.1 by `npm run sync:commandcode-catalog`. - * Do not edit manually. - */ -export const MODEL_INPUT_MODALITIES: Readonly> = { - "claude-fable-5": ["text", "image"], - "claude-fable-5-1": ["text", "image"], - "claude-haiku-4-5-20251001": ["text", "image"], - "claude-opus-4-7": ["text", "image"], - "claude-opus-4-8": ["text", "image"], - "claude-opus-5": ["text", "image"], - "claude-sonnet-4-6": ["text", "image"], - "claude-sonnet-5": ["text", "image"], - "deepseek/deepseek-v4-flash-vision-exp": ["text", "image"], - "deepseek/deepseek-v4.1-flash": ["text", "image"], - "google/gemini-3.1-flash-lite": ["text", "image"], - "google/gemini-3.5-flash": ["text", "image"], - "google/gemini-3.5-flash-lite": ["text", "image"], - "google/gemini-3.6-flash": ["text", "image"], - "google/gemini-3.7-flash": ["text", "image"], - "google/gemini-3.8-flash": ["text", "image"], - "gpt-5.3-codex": ["text", "image"], - "gpt-5.4": ["text", "image"], - "gpt-5.4-mini": ["text", "image"], - "gpt-5.5": ["text", "image"], - "gpt-5.6-luna": ["text", "image"], - "gpt-5.6-sol": ["text", "image"], - "gpt-5.6-terra": ["text", "image"], - "gpt-6-astra": ["text", "image"], - "meta/muse-spark-1.1": ["text", "image"], - "meta/muse-spark-1.2": ["text", "image"], - "meta/muse-spark-1.2-contributor": ["text", "image"], - "meta/muse-spark-1.3": ["text", "image"], - "meta/muse-spark-1.3-contributor": ["text", "image"], - "MiniMaxAI/MiniMax-M3": ["text", "image"], - "moonshotai/Kimi-K2.5": ["text", "image"], - "moonshotai/Kimi-K2.6": ["text", "image"], - "moonshotai/Kimi-K2.7-Code": ["text", "image"], - "moonshotai/Kimi-K2.7-Code-Highspeed": ["text", "image"], - "moonshotai/Kimi-K3": ["text", "image"], - "Qwen/Qwen3.6-Plus": ["text", "image"], - "Qwen/Qwen3.7-Flash": ["text", "image"], - "Qwen/Qwen3.7-Plus": ["text", "image"], - "Qwen/Qwen3.8-27B": ["text", "image"], - "Qwen/Qwen3.8-Flash": ["text", "image"], - "Qwen/Qwen3.8-Max": ["text", "image"], - "Qwen/Qwen3.8-Max-0902": ["text", "image"], - "sakana/fugu-ultra": ["text", "image"], - "stepfun/Step-3.7-Flash": ["text", "image"], - "thinkingmachines/inkling": ["text", "image"], - "thinkingmachines/inkling-small": ["text", "image"], - "xai/grok-4.5": ["text", "image"], - "xai/grok-4.6": ["text", "image"], - "xiaomi/mimo-v2.5": ["text", "image"], - "z-ai/glm-5.3-flash": ["text", "image"], -} - -export const MODEL_REASONING: Readonly> = { - "claude-fable-5": true, - "claude-fable-5-1": true, - "claude-opus-4-7": true, - "claude-opus-4-8": true, - "claude-opus-5": true, - "claude-sonnet-4-6": true, - "claude-sonnet-5": true, - "deepseek/deepseek-v4-flash": true, - "deepseek/deepseek-v4-flash-fast": true, - "deepseek/deepseek-v4-flash-vision-exp": true, - "deepseek/deepseek-v4-pro": true, - "deepseek/deepseek-v4.1-flash": true, - "google/gemini-3.1-flash-lite": true, - "google/gemini-3.5-flash": true, - "google/gemini-3.5-flash-lite": true, - "google/gemini-3.6-flash": true, - "google/gemini-3.7-flash": true, - "google/gemini-3.8-flash": true, - "gpt-5.3-codex": true, - "gpt-5.4": true, - "gpt-5.4-mini": true, - "gpt-5.5": true, - "gpt-5.6-luna": true, - "gpt-5.6-sol": true, - "gpt-5.6-terra": true, - "gpt-6-astra": true, - "inclusionai/ling-3.0-flash-sante:free": true, - "meituan/LongCat-2.0:free": true, - "meta/muse-spark-1.1": true, - "meta/muse-spark-1.2": true, - "meta/muse-spark-1.2-contributor": true, - "meta/muse-spark-1.3": true, - "meta/muse-spark-1.3-contributor": true, - "MiniMaxAI/MiniMax-M3": true, - "moonshotai/Kimi-K2.7-Code": true, - "moonshotai/Kimi-K2.7-Code-Highspeed": true, - "moonshotai/Kimi-K3": true, - "nvidia/nemotron-3-ultra-550b-a55b": true, - "poolside/laguna-s-2.1-free": true, - "Qwen/Qwen3.6-Max-Preview": true, - "Qwen/Qwen3.6-Plus": true, - "Qwen/Qwen3.7-Flash": true, - "Qwen/Qwen3.7-Max": true, - "Qwen/Qwen3.7-Plus": true, - "Qwen/Qwen3.8-27B": true, - "Qwen/Qwen3.8-Flash": true, - "Qwen/Qwen3.8-Max": true, - "Qwen/Qwen3.8-Max-0902": true, - "sakana/fugu-ultra": true, - "stepfun/Step-3.5-Flash": true, - "stepfun/Step-3.7-Flash": true, - "tencent/hy3-paid": true, - "tencent/hy4-preview": true, - "thinkingmachines/inkling": true, - "thinkingmachines/inkling-small": true, - "xai/grok-4.5": true, - "xai/grok-4.6": true, - "z-ai/glm-5.3-flash": true, - "zai-org/GLM-5.2": true, - "zai-org/GLM-5.3": true, -} - -export const MODEL_EFFORTS: Readonly> = { - "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], - "claude-fable-5-1": ["low", "medium", "high", "xhigh", "max"], - "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], - "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], - "claude-opus-5": ["low", "medium", "high", "xhigh", "max"], - "claude-sonnet-4-6": ["low", "medium", "high", "xhigh", "max"], - "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], - "deepseek/deepseek-v4-flash": ["high", "max"], - "deepseek/deepseek-v4-flash-fast": ["low", "high", "max"], - "deepseek/deepseek-v4-flash-vision-exp": ["high", "max"], - "deepseek/deepseek-v4-pro": ["high", "max"], - "deepseek/deepseek-v4.1-flash": ["low", "high", "max"], - "google/gemini-3.1-flash-lite": ["low", "medium", "high"], - "google/gemini-3.5-flash": ["low", "medium", "high"], - "google/gemini-3.5-flash-lite": ["low", "medium", "high"], - "google/gemini-3.6-flash": ["low", "medium", "high"], - "google/gemini-3.7-flash": ["low", "medium", "high"], - "google/gemini-3.8-flash": ["low", "medium", "high"], - "gpt-5.3-codex": ["low", "medium", "high", "xhigh"], - "gpt-5.4": ["low", "medium", "high", "xhigh"], - "gpt-5.4-mini": ["low", "medium", "high"], - "gpt-5.5": ["low", "medium", "high", "xhigh"], - "gpt-5.6-luna": ["low", "medium", "high", "xhigh", "max"], - "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"], - "gpt-5.6-terra": ["low", "medium", "high", "xhigh", "max"], - "gpt-6-astra": ["low", "medium", "high", "xhigh", "max"], - "meta/muse-spark-1.1": ["low", "medium", "high", "xhigh"], - "meta/muse-spark-1.2": ["low", "medium", "high", "xhigh"], - "meta/muse-spark-1.2-contributor": ["low", "medium", "high", "xhigh"], - "meta/muse-spark-1.3": ["low", "medium", "high", "xhigh", "max"], - "meta/muse-spark-1.3-contributor": ["low", "medium", "high", "xhigh"], - "MiniMaxAI/MiniMax-M3": ["low", "medium", "high"], - "moonshotai/Kimi-K3": ["low", "high", "max"], - "Qwen/Qwen3.8-27B": ["low", "medium", "xhigh"], - "Qwen/Qwen3.8-Flash": ["low", "medium", "xhigh"], - "Qwen/Qwen3.8-Max": ["low", "medium", "xhigh"], - "Qwen/Qwen3.8-Max-0902": ["low", "medium", "xhigh"], - "sakana/fugu-ultra": ["high", "xhigh"], - "tencent/hy4-preview": ["low", "medium", "high"], - "xai/grok-4.5": ["low", "medium", "high"], - "xai/grok-4.6": ["low", "medium", "high", "xhigh"], - "z-ai/glm-5.3-flash": ["low", "high", "max"], - "zai-org/GLM-5.2": ["high", "max"], - "zai-org/GLM-5.3": ["low", "high", "max"], -} - -export const MODEL_MAX_OUTPUT_TOKENS: Readonly> = { - "inclusionai/ling-3.0-flash-sante:free": 32_768, - "poolside/laguna-s-2.1-free": 32_768, - "Qwen/Qwen3.8-27B": 32_768, - "z-ai/glm-5.3-flash": 131_072, -} diff --git a/src/converters.ts b/src/converters.ts deleted file mode 100644 index d5e7d91..0000000 --- a/src/converters.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" - -import type { MessageLike, StopReason, ToolLike } from "./types.ts" -import { toJsonSchema } from "./json-schema.ts" - -export { toJsonSchema } from "./json-schema.ts" - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -export function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined -} - -export function recordArray(value: unknown): readonly Record[] { - if (!Array.isArray(value)) return [] - return value.filter(isRecord) -} - -export function recordOrEmpty(value: unknown): Record { - if (isRecord(value)) return value - if (typeof value === "string") { - try { - const parsed: unknown = JSON.parse(value) - if (isRecord(parsed)) return parsed - } catch { - // Some providers stream incomplete JSON argument fragments. - } - } - return {} -} - -export function numberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined -} - -function defaultAuthPaths(home: string): string[] { - return [ - join(home, ".commandcode", "auth.json"), - join(home, ".omp", "agent", "auth.json"), - join(home, ".pi", "agent", "auth.json"), - ] -} - -function apiKeyFromCredentialRecord(value: unknown): string | undefined { - if (!isRecord(value)) return undefined - - const type = stringValue(value.type) - if (type === "api") return stringValue(value.key) - if (type === "oauth") return stringValue(value.access) - - return stringValue(value.key) ?? stringValue(value.access) -} - -function imageParts(value: unknown): readonly Record[] { - if (isRecord(value)) return value.type === "image" ? [value] : [] - return recordArray(value).filter((part) => part.type === "image") -} - -function imageContentError(role: string): Error { - return new Error(`Selected Command Code model does not support image content in ${role}`) -} - -export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void { - for (const message of messages ?? []) { - if (message.role !== "toolResult" && imageParts(message.content).length > 0) { - throw imageContentError(`${message.role} messages`) - } - } -} - -function imageToCommandCode(part: Record): Record { - const data = stringValue(part.data) - const mimeType = stringValue(part.mimeType) - if (!data || !mimeType) - throw new Error("Invalid image content: expected base64 data and mimeType") - - return { - type: "image", - image: `data:${mimeType};base64,${data}`, - mimeType, - } -} - -function userContentToCommandCode(content: unknown, allowImages: boolean): unknown { - if (typeof content === "string") return content - - return recordArray(content).flatMap((part) => { - if (part.type === "text") return [{ type: "text", text: stringValue(part.text) ?? "" }] - if (part.type === "image") { - if (!allowImages) throw imageContentError("user messages") - return [imageToCommandCode(part)] - } - return [] - }) -} - -export function getApiKey( - options: { - env?: NodeJS.ProcessEnv - authPaths?: readonly string[] - homeDir?: () => string - } = {}, -): string | undefined { - const env = options.env ?? process.env - if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY - if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY - - const home = options.homeDir?.() ?? homedir() - const authPaths = options.authPaths ?? defaultAuthPaths(home) - - for (const authPath of authPaths) { - try { - if (!existsSync(authPath)) continue - const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8")) - if (!isRecord(parsed)) continue - - // Legacy: direct apiKey or commandcode field. - const apiKey = stringValue(parsed.apiKey) - if (apiKey) return apiKey - const commandcode = stringValue(parsed.commandcode) - if (commandcode) return commandcode - - // pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"..."}}. - // The official Command Code CLI stores API credentials under "command-code". - const providerKey = - apiKeyFromCredentialRecord(parsed.commandcode) ?? - apiKeyFromCredentialRecord(parsed["command-code"]) - if (providerKey) return providerKey - } catch { - // Ignore malformed or unreadable auth files. - } - } - - return undefined -} - -// Hosts such as OMP may pass a literal env-var name as the "resolved" registry -// key instead of the actual credential. Treat those as unresolved. -export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([ - "$COMMAND_CODE_API_KEY", - "COMMAND_CODE_API_KEY", - "$COMMANDCODE_API_KEY", - "COMMANDCODE_API_KEY", -]) - -function usableCommandCodeApiKey(value: string | undefined): string | undefined { - const trimmed = typeof value === "string" ? value.trim() : undefined - if (!trimmed) return undefined - if (COMMAND_CODE_PLACEHOLDER_KEYS.has(trimmed)) return undefined - return trimmed -} - -/** - * Pick the real API key from a host registry value and/or the env/auth-file - * fallback, never returning a literal placeholder or an empty/whitespace value. - * Pure/testable. - */ -export function pickCommandCodeApiKey( - registryKey: string | undefined, - hostKey: string | undefined, -): string | undefined { - return usableCommandCodeApiKey(registryKey) ?? usableCommandCodeApiKey(hostKey) -} - -/** - * Replace a host-supplied placeholder (or missing key) with the configured - * fallback. Used for both registerProvider and the Provider API stream path. - */ -export function withResolvedCommandCodeApiKey( - options: T | undefined, - configuredKey: string | undefined, -): T | { apiKey?: string } { - const apiKey = pickCommandCodeApiKey(options?.apiKey, configuredKey) - if (options && apiKey === options.apiKey) return options - return { ...options, apiKey } -} - -export function textContent(message: { content?: unknown }): string { - if (typeof message.content === "string") return message.content - if (message.content === null || message.content === undefined) return "" - if (!Array.isArray(message.content)) { - try { - return JSON.stringify(message.content) ?? String(message.content) - } catch { - return String(message.content) - } - } - - return recordArray(message.content) - .filter((part) => part.type === "text") - .map((part) => stringValue(part.text) ?? "") - .join("\n") -} - -export function getEnvironmentInfo(): string { - return `${process.platform}-${process.arch}, Node.js ${process.version}` -} - -export function toolsToJson(tools?: readonly ToolLike[]): unknown[] { - if (!tools) return [] - return tools.map((tool) => ({ - type: "function", - name: tool.name, - description: tool.description, - input_schema: tool.parameters ? toJsonSchema(tool.parameters) : {}, - })) -} - -interface ToolCallState { - callIds: ReadonlySet - resultIds: ReadonlySet -} - -function toolCallState(messages?: readonly MessageLike[]): ToolCallState { - const callIds = new Set() - const resultIds = new Set() - - for (const message of messages ?? []) { - if (message.role === "assistant") { - for (const content of recordArray(message.content)) { - if (content.type === "toolCall") { - const id = stringValue(content.id) - if (id) callIds.add(id) - } - } - } else if (message.role === "toolResult" && message.toolCallId) { - resultIds.add(message.toolCallId) - } - } - - return { callIds, resultIds } -} - -export function messagesToCC( - messages?: readonly MessageLike[], - options: { allowImages?: boolean } = {}, -): unknown[] { - const allowImages = options.allowImages ?? false - if (!allowImages) assertTextOnlyMessages(messages) - - const out: unknown[] = [] - const { callIds, resultIds } = toolCallState(messages) - - for (const message of messages ?? []) { - if (message.role === "user" || message.role === "developer") { - // Hosts such as OMP steer the agent by injecting developer-role messages - // (advisor notes, reminders, nudges) mid-conversation. /alpha/generate - // only accepts user, assistant, and tool roles, so degrade the role to - // user instead of dropping the message. Content and chronological - // position are preserved; system-prompt hoisting would change semantics. - out.push({ - role: "user", - content: userContentToCommandCode(message.content, allowImages), - }) - } else if (message.role === "assistant") { - const parts: unknown[] = [] - const missingResults: unknown[] = [] - for (const content of recordArray(message.content)) { - if (content.type === "text") { - parts.push({ type: "text", text: stringValue(content.text) ?? "" }) - } else if (content.type === "toolCall") { - const toolCallId = stringValue(content.id) ?? "" - const toolName = stringValue(content.name) ?? "" - if (!toolCallId) continue - parts.push({ - type: "tool-call", - toolCallId, - toolName, - input: recordOrEmpty(content.arguments), - }) - if (!resultIds.has(toolCallId)) { - missingResults.push({ - type: "tool-result", - toolCallId, - toolName, - output: { - type: "error-text", - value: "No result — the tool call did not complete (interrupted or lost).", - }, - }) - } - } - } - if (parts.length > 0) out.push({ role: "assistant", content: parts }) - if (missingResults.length > 0) out.push({ role: "tool", content: missingResults }) - } else if (message.role === "toolResult") { - if (!message.toolCallId || !callIds.has(message.toolCallId)) continue - const images = imageParts(message.content) - const text = textContent(message) - const outputText = - text || - (images.length > 0 && !allowImages ? "[Image omitted: model does not support images]" : "") - out.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: message.toolCallId, - toolName: message.toolName, - output: message.isError - ? { type: "error-text", value: outputText } - : { type: "text", value: outputText }, - }, - ], - }) - - if (images.length > 0 && allowImages) { - out.push({ - role: "user", - content: images.map(imageToCommandCode), - }) - } - } - } - return out -} - -export function parseStreamEventLine(line: string): unknown | undefined { - let trimmed = line.trim() - if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined - if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim() - if (!trimmed || trimmed === "[DONE]") return undefined - - try { - const parsed: unknown = JSON.parse(trimmed) - return parsed - } catch { - return undefined - } -} - -export function mapFinishReason(reason: unknown): StopReason { - if (reason === "tool-calls") return "toolUse" - if ( - reason === "length" || - reason === "max_tokens" || - reason === "max-tokens" || - reason === "max_output_tokens" - ) { - return "length" - } - return "stop" -} - -function promptPartToText(value: unknown, depth = 0): string { - if (depth > 10) return "" - if (typeof value === "string") return value - if (Array.isArray(value)) - return value - .map((v) => promptPartToText(v, depth + 1)) - .filter(Boolean) - .join("\n") - if (!isRecord(value)) return "" - const text = stringValue(value.text) - if (text) return text - const content = promptPartToText(value.content, depth + 1) - if (content) return content - return "" -} - -export function systemPromptToText(value: unknown): string { - if (value === undefined || value === null) return "" - if (typeof value === "string") return value - if (Array.isArray(value)) - return value - .map((v) => promptPartToText(v, 0)) - .filter(Boolean) - .join("\n\n") - return promptPartToText(value, 0) -} diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index 0d16e70..0000000 --- a/src/core.ts +++ /dev/null @@ -1,828 +0,0 @@ -/** - * Testable Command Code provider core. - * - * The runtime imports live in index.ts; this module takes injected stream/cost - * dependencies so tests can exercise the real serialization and stream parser. - */ - -import { randomUUID } from "node:crypto" - -import { COMMAND_CODE_CLI_VERSION } from "./commandcode-catalog.ts" -import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts" -import { modelSupportsImageInput } from "./models.ts" -import { - getApiKey, - getEnvironmentInfo, - isRecord, - assertTextOnlyMessages, - mapFinishReason, - messagesToCC, - numberValue, - parseStreamEventLine, - pickCommandCodeApiKey, - recordOrEmpty, - stringValue, - toolsToJson, - systemPromptToText, -} from "./converters.ts" -import type { - AssistantMessageEventStreamLike, - AssistantMessageLike, - ContextLike, - CoreDependencies, - ErrorReason, - ModelLike, - StopReason, - StreamOptions, - TerminalReason, - TextContent, - ToolCallContent, - Usage, -} from "./types.ts" - -export * from "./converters.ts" -export * from "./overflow.ts" -export * from "./types.ts" - -export const DEFAULT_API_BASE = "https://api.commandcode.ai" -export { COMMAND_CODE_CLI_VERSION } - -const DEFAULT_GENERATE_MAX_TOKENS = 64_000 -const DEFAULT_MAX_RETRIES = 0 -const DEFAULT_MAX_RETRY_DELAY_MS = 60_000 -const BASE_RETRY_DELAY_MS = 500 - -function isRetryableStatus(status: number): boolean { - return status === 429 || (status >= 500 && status < 600) -} - -function parseRetryAfterSeconds(value: string | null): number | undefined { - if (!value) return undefined - const seconds = Number(value) - if (Number.isFinite(seconds) && seconds >= 0) return seconds - const date = Date.parse(value) - if (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000) - return undefined -} - -function effectiveMaxRetryDelayMs(value: number | undefined): number { - if (value === undefined) return DEFAULT_MAX_RETRY_DELAY_MS - if (value === 0) return Number.POSITIVE_INFINITY - return value -} - -function retryDelayMs( - attempt: number, - retryAfterHeader: string | null, - maxDelayMs: number, -): number { - const retryAfterMs = parseRetryAfterSeconds(retryAfterHeader) - if (retryAfterMs !== undefined) { - if (retryAfterMs * 1000 > maxDelayMs) return -1 - return retryAfterMs * 1000 - } - const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt - const jitter = exponential * 0.2 * Math.random() - return Math.min(exponential + jitter, maxDelayMs) -} - -function defaultUsage(): Usage { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - } -} - -function commandCodeUsage(event: Record): Record | undefined { - return isRecord(event.totalUsage) ? event.totalUsage : undefined -} - -function commandCodeInputTokenDetails( - usage: Record, -): Record | undefined { - return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined -} - -function headersToRecord(headers: Headers): Record { - const out: Record = {} - headers.forEach((value, key) => { - out[key] = value - }) - return out -} - -function abortError(message = "The operation was aborted"): DOMException { - return new DOMException(message, "AbortError") -} - -function timeoutError(timeoutMs: number | undefined): Error { - return new Error( - timeoutMs === undefined - ? "Command Code API request timed out" - : `Command Code API request timed out after ${timeoutMs}ms`, - ) -} - -function successStopReason(reason: TerminalReason): StopReason { - if (reason === "length" || reason === "toolUse") return reason - return "stop" -} - -function generateMaxTokens(model: ModelLike, options?: StreamOptions): number { - return Math.min( - options?.maxTokens ?? model.maxTokens, - model.maxTokens, - DEFAULT_GENERATE_MAX_TOKENS, - ) -} - -function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): string | undefined { - const level = options?.reasoning - if (!level || level === "off" || !model.reasoning) return undefined - - const effortMap = model.thinking?.effortMap ?? model.thinkingLevelMap - const mapped = effortMap?.[level] - return typeof mapped === "string" && mapped !== "off" ? mapped : undefined -} - -function isUuid(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) -} - -export function projectSlugFromPath(pathName: string): string { - const slug = pathName - .toLowerCase() - .replace(/^[a-z]:/i, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - return slug || "project" -} - -export function createStreamCommandCode(deps: CoreDependencies) { - const apiBase = deps.apiBase ?? DEFAULT_API_BASE - const fetchImpl = deps.fetchImpl ?? fetch - const cwd = deps.cwd ?? (() => process.cwd()) - const now = deps.now ?? (() => Date.now()) - const uuid = deps.uuid ?? (() => randomUUID()) - const delay = - deps.delay ?? - ((ms: number, signal: AbortSignal) => { - if (signal.aborted) return Promise.reject(abortError()) - return new Promise((resolve, reject) => { - const id = setTimeout(() => { - signal.removeEventListener("abort", onAbort) - resolve() - }, ms) - const onAbort = () => { - clearTimeout(id) - reject(abortError()) - } - signal.addEventListener("abort", onAbort, { once: true }) - }) - }) - - function raceAbort(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortError()) - - return new Promise((resolve, reject) => { - const onAbort = () => reject(abortError()) - signal.addEventListener("abort", onAbort, { once: true }) - promise.then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort) - reject(error) - }, - ) - }) - } - - function raceAbortWithTimeout( - promise: Promise, - controller: AbortController, - timeoutMs: number | undefined, - ): Promise { - if (timeoutMs === undefined) return raceAbort(promise, controller.signal) - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - controller.abort() - reject(timeoutError(timeoutMs)) - }, timeoutMs) - raceAbort(promise, controller.signal).then( - (value) => { - clearTimeout(timer) - resolve(value) - }, - (error: unknown) => { - clearTimeout(timer) - reject(error) - }, - ) - }) - } - - return function streamCommandCode( - model: ModelLike, - context: ContextLike, - options?: StreamOptions, - ): AssistantMessageEventStreamLike { - const stream = deps.createStream() - - async function run() { - // Some hosts pass a literal env-var reference instead of resolving it. - const apiKey = pickCommandCodeApiKey( - options?.apiKey, - getApiKey({ - env: deps.env, - authPaths: deps.authPaths, - homeDir: deps.homeDir, - }), - ) - - if (!apiKey) { - const msg: AssistantMessageLike = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: defaultUsage(), - stopReason: "error", - errorMessage: - "No Command Code API key. Run /login and select Command Code, set COMMAND_CODE_API_KEY (or legacy COMMANDCODE_API_KEY), or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json", - timestamp: now(), - } - stream.push({ type: "error", reason: "error", error: msg }) - stream.end() - return - } - - const output: AssistantMessageLike = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: defaultUsage(), - stopReason: "stop", - timestamp: now(), - } - - const controller = new AbortController() - let reader: ReadableStreamDefaultReader | undefined - let textBlock: TextContent | undefined - let currentTextIdx = -1 - let thinkingIdx = -1 - const streamingToolCalls = new Map< - string, - { contentIndex: number; toolCall: ToolCallContent; partialArgs: string } - >() - let finished = false - - const abortUpstream = () => { - if (!controller.signal.aborted) controller.abort() - try { - reader?.cancel().catch(() => undefined) - } catch { - // Reader cancellation is best-effort. - } - } - - if (options?.signal?.aborted) { - abortUpstream() - } else { - options?.signal?.addEventListener("abort", abortUpstream, { - once: true, - }) - } - - const endTextBlock = () => { - if (!textBlock) return - stream.push({ - type: "text_end", - contentIndex: currentTextIdx, - content: textBlock.text, - partial: output, - }) - textBlock = undefined - currentTextIdx = -1 - } - - const endThinking = () => { - if (thinkingIdx < 0) return - const tc = output.content[thinkingIdx] - if (tc && tc.type === "thinking") { - stream.push({ - type: "thinking_end", - contentIndex: thinkingIdx, - content: (tc as { thinking: string }).thinking, - partial: output, - }) - } - thinkingIdx = -1 - } - - const handleEvent = (event: unknown) => { - if (!isRecord(event)) return - - switch (event.type) { - case "text-delta": { - endThinking() - if (!textBlock) { - textBlock = { type: "text", text: "" } - output.content.push(textBlock) - currentTextIdx = output.content.length - 1 - stream.push({ - type: "text_start", - contentIndex: currentTextIdx, - partial: output, - }) - } - const delta = stringValue(event.text) ?? "" - textBlock.text += delta - stream.push({ - type: "text_delta", - contentIndex: currentTextIdx, - delta, - partial: output, - }) - break - } - - case "reasoning-start": { - endTextBlock() - break - } - - case "reasoning-delta": { - endTextBlock() - const delta = stringValue(event.text) ?? "" - if (thinkingIdx < 0) { - output.content.push({ type: "thinking", thinking: delta }) - thinkingIdx = output.content.length - 1 - stream.push({ - type: "thinking_start", - contentIndex: thinkingIdx, - partial: output, - }) - } else { - const tc = output.content[thinkingIdx] - if (tc && tc.type === "thinking") { - ;(tc as { thinking: string }).thinking += delta - } - } - stream.push({ - type: "thinking_delta", - contentIndex: thinkingIdx, - delta, - partial: output, - }) - break - } - - case "reasoning-end": { - endThinking() - break - } - - case "tool-result": { - break - } - - case "tool-input-start": { - endTextBlock() - endThinking() - const id = stringValue(event.id) - if (!id || streamingToolCalls.has(id)) break - - const toolCall: ToolCallContent = { - type: "toolCall", - id, - name: stringValue(event.toolName) ?? "", - arguments: {}, - } - output.content.push(toolCall) - const contentIndex = output.content.length - 1 - streamingToolCalls.set(id, { contentIndex, toolCall, partialArgs: "" }) - stream.push({ - type: "toolcall_start", - contentIndex, - partial: output, - }) - break - } - - case "tool-input-delta": { - const id = stringValue(event.id) - const delta = stringValue(event.delta) - if (!id || delta === undefined) break - const active = streamingToolCalls.get(id) - if (!active) break - - active.partialArgs += delta - active.toolCall.arguments = recordOrEmpty(active.partialArgs) - stream.push({ - type: "toolcall_delta", - contentIndex: active.contentIndex, - delta, - partial: output, - }) - break - } - - case "tool-input-end": { - break - } - - case "tool-call": { - endTextBlock() - endThinking() - const id = stringValue(event.toolCallId) ?? "" - const active = streamingToolCalls.get(id) - const toolCall: ToolCallContent = active?.toolCall ?? { - type: "toolCall", - id, - name: stringValue(event.toolName) ?? "", - arguments: {}, - } - toolCall.name = stringValue(event.toolName) ?? toolCall.name - toolCall.arguments = recordOrEmpty(event.input ?? event.args ?? event.arguments) - - let contentIndex: number - if (active) { - contentIndex = active.contentIndex - streamingToolCalls.delete(id) - } else { - output.content.push(toolCall) - contentIndex = output.content.length - 1 - stream.push({ - type: "toolcall_start", - contentIndex, - partial: output, - }) - } - stream.push({ - type: "toolcall_end", - contentIndex, - toolCall, - partial: output, - }) - break - } - - case "finish": { - const rawFinishReason = stringValue(event.rawFinishReason) - if ( - rawFinishReason && - /^(?:network|connection|upstream)[-_\s]?error$/i.test(rawFinishReason) - ) { - throw new Error( - `Provider finished with reason "${rawFinishReason}" — upstream connection failed mid-stream`, - ) - } - const usage = commandCodeUsage(event) - if (usage) { - const details = commandCodeInputTokenDetails(usage) - const totalInput = numberValue(usage.inputTokens) ?? 0 - const input = numberValue(details?.noCacheTokens) - const cacheRead = numberValue(details?.cacheReadTokens) ?? 0 - const cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0 - output.usage.input = input ?? Math.max(0, totalInput - cacheRead - cacheWrite) - output.usage.output = numberValue(usage.outputTokens) ?? 0 - output.usage.cacheRead = cacheRead - output.usage.cacheWrite = cacheWrite - output.usage.totalTokens = - output.usage.input + - output.usage.output + - output.usage.cacheRead + - output.usage.cacheWrite - deps.calculateCost(model, output.usage) - } - output.stopReason = mapFinishReason(event.finishReason) - finished = true - break - } - - case "abort": { - throw abortError("Request aborted") - } - - case "error": { - const message = - commandCodeErrorMessage(event.error) ?? - commandCodeErrorMessage(event.message) ?? - "Stream error" - output.stopReason = "error" - output.errorMessage = message - throw new Error(message) - } - } - } - - try { - stream.push({ type: "start", partial: output }) - if (controller.signal.aborted) throw abortError("Aborted") - - const workingDir = cwd() - const threadId = options?.sessionId - ? isUuid(options.sessionId) - ? options.sessionId - : undefined - : uuid() - const reasoningEffort = mappedReasoningEffort(model, options) - const timeoutMs = options?.timeoutMs - - const allowImages = modelSupportsImageInput(model.id) - if (!allowImages) assertTextOnlyMessages(context.messages) - - let body: unknown = { - config: { - workingDir, - date: new Date(now()).toISOString().split("T")[0], - environment: getEnvironmentInfo(), - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], - }, - memory: null, - taste: null, - skills: null, - params: { - model: model.id, - messages: messagesToCC(context.messages, { allowImages }), - tools: toolsToJson(context.tools), - system: systemPromptToText(context.systemPrompt), - max_tokens: generateMaxTokens(model, options), - stream: true, - ...(options?.temperature !== undefined ? { temperature: options.temperature } : {}), - ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), - }, - threadId, - } - - const payloadController = new AbortController() - const onPayloadAbort = () => payloadController.abort() - controller.signal.addEventListener("abort", onPayloadAbort, { once: true }) - let nextBody: unknown - try { - nextBody = await raceAbortWithTimeout( - Promise.resolve(options?.onPayload?.(body, model)), - payloadController, - timeoutMs, - ) - } finally { - controller.signal.removeEventListener("abort", onPayloadAbort) - } - if (nextBody !== undefined) body = nextBody - - const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES - const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs) - const requestHeaders = { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "x-command-code-version": COMMAND_CODE_CLI_VERSION, - "x-cli-environment": "production", - "x-project-slug": projectSlugFromPath(workingDir), - "x-taste-learning": "true", - ...(options?.sessionId ? { "x-session-id": options.sessionId } : {}), - "User-Agent": "cli", - ...options?.headers, - } - const bodyStr = JSON.stringify(body) - - let response!: Response - retryLoop: for (let attempt = 0; ; attempt++) { - const attemptController = new AbortController() - let attemptTimedOut = false - let attemptTimeoutId: ReturnType | undefined - - const clearAttemptTimeout = () => { - if (attemptTimeoutId !== undefined) { - clearTimeout(attemptTimeoutId) - attemptTimeoutId = undefined - } - } - - if (timeoutMs !== undefined) { - attemptTimeoutId = setTimeout(() => { - attemptTimedOut = true - attemptController.abort() - }, timeoutMs) - } - const onOuterAbort = () => attemptController.abort() - controller.signal.addEventListener("abort", onOuterAbort, { once: true }) - const raceAttempt = (promise: Promise): Promise => - raceAbort(promise, attemptController.signal).catch((error: unknown) => { - if (attemptTimedOut) throw timeoutError(timeoutMs) - throw error - }) - - try { - try { - response = await fetchImpl(`${apiBase}/alpha/generate`, { - method: "POST", - headers: requestHeaders, - body: bodyStr, - signal: attemptController.signal, - }) - } catch (fetchError: unknown) { - if (controller.signal.aborted) throw abortError("Aborted") - if (attemptTimedOut) { - if (attempt < maxRetries) continue retryLoop - throw timeoutError(timeoutMs) - } - throw fetchError - } - - // --- HTTP-level retry --- - if (!response.ok && isRetryableStatus(response.status)) { - const retryAfter = response.headers.get("retry-after") - const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs) - if (waitMs < 0) { - const requestedSeconds = parseRetryAfterSeconds(retryAfter) ?? 0 - const capLabel = - maxRetryDelayMs === Number.POSITIVE_INFINITY ? "disabled" : `${maxRetryDelayMs}ms` - throw new Error(`Retry-After delay ${requestedSeconds}s exceeds max ${capLabel}`) - } - if (attempt < maxRetries) { - await response.text().catch(() => "") - if (waitMs > 0) await delay(waitMs, controller.signal) - continue retryLoop - } - } - - try { - await raceAttempt( - Promise.resolve( - options?.onResponse?.( - { - status: response.status, - headers: headersToRecord(response.headers), - }, - model, - ), - ), - ) - } catch (error: unknown) { - if (attemptTimedOut && attempt < maxRetries) continue retryLoop - throw error - } - - if (!response.ok) { - const errBody = await raceAttempt(response.text().catch(() => "")) - let errorDetail: string | undefined - try { - const parsedBody: unknown = JSON.parse(errBody) - errorDetail = commandCodeErrorMessage(parsedBody) - } catch { - // Preserve useful plain-text provider errors only after secret - // redaction; upstream/proxy bodies may echo credentials. - } - const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500) - const detail = redactCommandCodeErrorText( - errorDetail ?? (safeBody || "Provider returned an error"), - ) - throw new Error(`Command Code API error ${response.status}: ${detail}`) - } - - // --- Read response stream --- - reader = response.body?.getReader() - if (!reader) throw new Error("No response body") - - const decoder = new TextDecoder() - let buffer = "" - - try { - readLoop: for (;;) { - if (controller.signal.aborted) throw abortError("Aborted") - const { done, value } = await raceAbort(reader.read(), attemptController.signal) - if (done) { - if (buffer.trim()) handleEvent(parseStreamEventLine(buffer)) - if (!finished) { - throw new Error( - "Stream ended unexpectedly before completion (no finish event) — response was truncated", - ) - } - break - } - if (controller.signal.aborted) throw abortError("Aborted") - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - - for (const line of lines) { - if (controller.signal.aborted) throw abortError("Aborted") - handleEvent(parseStreamEventLine(line)) - if (finished) break readLoop - } - } - } catch (streamError: unknown) { - // Stream-level error (e.g. API returned 200 OK but sent an error event) - // or per-attempt timeout during stream reading. - await reader.cancel().catch(() => {}) - try { - reader.releaseLock() - } catch {} - reader = undefined - - if ( - controller.signal.aborted || - (streamError instanceof Error && streamError.name === "AbortError") - ) { - throw streamError - } - - // Never retry after visible content was emitted (including timeout mid-stream). - const canRetry = output.content.length === 0 && attempt < maxRetries - if (canRetry) { - output.content.length = 0 - textBlock = undefined - currentTextIdx = -1 - thinkingIdx = -1 - output.stopReason = "stop" - output.errorMessage = undefined - finished = false - const waitMs = attemptTimedOut ? 0 : retryDelayMs(attempt, null, maxRetryDelayMs) - if (waitMs > 0) await delay(waitMs, controller.signal) - continue retryLoop - } - if (attemptTimedOut) throw timeoutError(timeoutMs) - throw streamError - } - - // Stream completed successfully. - endTextBlock() - endThinking() - - stream.push({ - type: "done", - reason: successStopReason(output.stopReason), - message: output, - }) - stream.end() - break retryLoop - } finally { - controller.signal.removeEventListener("abort", onOuterAbort) - clearAttemptTimeout() - } - } - } catch (error: unknown) { - const reason: ErrorReason = - controller.signal.aborted || (error instanceof Error && error.name === "AbortError") - ? "aborted" - : "error" - output.stopReason = reason - output.errorMessage = - reason === "aborted" - ? "Request aborted" - : redactCommandCodeErrorText(error instanceof Error ? error.message : String(error)) - stream.push({ type: "error", reason, error: output }) - stream.end() - } finally { - options?.signal?.removeEventListener("abort", abortUpstream) - try { - await reader?.cancel() - } catch { - // Reader may already be closed/cancelled. - } - try { - reader?.releaseLock() - } catch { - // Reader may already be released/cancelled by the abort path. - } - } - } - - run().catch((error: unknown) => { - const msg: AssistantMessageLike = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: defaultUsage(), - stopReason: "error", - errorMessage: redactCommandCodeErrorText( - error instanceof Error ? error.message : String(error), - ), - timestamp: now(), - } - stream.push({ type: "error", reason: "error", error: msg }) - stream.end() - }) - - return stream - } -} diff --git a/src/cost.ts b/src/cost.ts deleted file mode 100644 index 55c1bd0..0000000 --- a/src/cost.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Local cost calculation for Command Code usage. - * - * Mirrors pi-ai's `calculateCost` arithmetic exactly. The provider ships its - * own copy because Oh My Pi's legacy pi-ai shim does not export - * `calculateCost`, which broke extension installation there (issue #24). - * `tests/test-cost.ts` locks this implementation to the pi-ai original. - */ - -import type { ModelLike, Usage } from "./types.ts" - -export function calculateCommandCodeCost(model: ModelLike, usage: Usage): void { - const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite - let rates = model.cost - let matchedThreshold = -1 - for (const tier of model.cost.tiers ?? []) { - if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { - rates = tier - matchedThreshold = tier.inputTokensAbove - } - } - - const longWrite = usage.cacheWrite1h ?? 0 - const shortWrite = usage.cacheWrite - longWrite - usage.cost.input = (rates.input / 1_000_000) * usage.input - usage.cost.output = (rates.output / 1_000_000) * usage.output - usage.cost.cacheRead = (rates.cacheRead / 1_000_000) * usage.cacheRead - usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1_000_000 - usage.cost.total = - usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite -} diff --git a/src/json-schema.ts b/src/json-schema.ts deleted file mode 100644 index a2ffc02..0000000 --- a/src/json-schema.ts +++ /dev/null @@ -1,382 +0,0 @@ -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined -} - -function booleanValue(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined -} - -type JsonSchemaValue = boolean | Record - -const JSON_SCHEMA_TYPES = new Set([ - "array", - "boolean", - "integer", - "null", - "number", - "object", - "string", -]) - -const LEGACY_KINDS = new Set([ - "any", - "array", - "boolean", - "enum", - "integer", - "intersect", - "intersection", - "literal", - "never", - "null", - "nullable", - "number", - "object", - "optional", - "string", - "undefined", - "union", - "unknown", -]) - -const LEGACY_FIELDS = new Set([ - "element", - "kind", - "inner", - "optional", - "value", - "values", - "variants", - "wrapped", -]) - -const SCHEMA_MAP_FIELDS = new Set([ - "$defs", - "definitions", - "dependentSchemas", - "patternProperties", - "properties", -]) - -const SCHEMA_ARRAY_FIELDS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]) - -const SCHEMA_VALUE_FIELDS = new Set([ - "additionalItems", - "additionalProperties", - "contains", - "contentSchema", - "else", - "if", - "items", - "not", - "propertyNames", - "then", - "unevaluatedItems", - "unevaluatedProperties", -]) - -const SCHEMA_KEYWORDS = new Set([ - "$anchor", - "$comment", - "$defs", - "$dynamicAnchor", - "$dynamicRef", - "$id", - "$ref", - "$schema", - "$vocabulary", - "additionalItems", - "additionalProperties", - "allOf", - "anyOf", - "const", - "contains", - "contentEncoding", - "contentMediaType", - "contentSchema", - "default", - "definitions", - "dependentRequired", - "dependentSchemas", - "description", - "else", - "enum", - "examples", - "exclusiveMaximum", - "exclusiveMinimum", - "format", - "if", - "items", - "maxContains", - "maxItems", - "maxLength", - "maxProperties", - "maximum", - "minContains", - "minItems", - "minLength", - "minProperties", - "minimum", - "multipleOf", - "not", - "oneOf", - "pattern", - "patternProperties", - "prefixItems", - "properties", - "propertyNames", - "readOnly", - "required", - "title", - "type", - "unevaluatedItems", - "unevaluatedProperties", - "uniqueItems", - "writeOnly", -]) - -function stringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined - const values = value.filter((item): item is string => typeof item === "string") - return values.length === value.length ? values : undefined -} - -function validSchemaType(value: unknown): boolean { - if (typeof value === "string") return JSON_SCHEMA_TYPES.has(value) - if (!Array.isArray(value) || value.length === 0) return false - return value.every((item) => typeof item === "string" && JSON_SCHEMA_TYPES.has(item)) -} - -function legacyKind(schema: Record): string | undefined { - const explicitKind = stringValue(schema.kind)?.toLowerCase() - if (explicitKind && LEGACY_KINDS.has(explicitKind)) return explicitKind - - const type = stringValue(schema.type) - const normalized = type?.toLowerCase() - if (!normalized || !LEGACY_KINDS.has(normalized)) return undefined - if (!validSchemaType(type) || Object.keys(schema).some((key) => LEGACY_FIELDS.has(key))) { - return normalized - } - return undefined -} - -function looksLikeJsonSchema(schema: Record): boolean { - if (Object.keys(schema).length === 0) return true - if (schema.type !== undefined && !validSchemaType(schema.type)) return false - return Object.keys(schema).some((key) => SCHEMA_KEYWORDS.has(key)) -} - -function isOptionalSchema(schema: unknown): boolean { - if (!isRecord(schema)) return false - if (booleanValue(schema.optional) === true) return true - - const kind = legacyKind(schema) - if (kind === "optional") return true - if (kind !== "union") return false - - const variants = Array.isArray(schema.variants) - ? schema.variants - : Array.isArray(schema.anyOf) - ? schema.anyOf - : [] - return variants.some((variant) => legacyKind(isRecord(variant) ? variant : {}) === "undefined") -} - -function schemaValue(value: unknown, seen: WeakSet): JsonSchemaValue { - if (typeof value === "boolean") return value - if (!isRecord(value)) return {} - return convertSchema(value, seen) -} - -function setSchemaProperty(target: Record, key: string, value: unknown): void { - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - value, - writable: true, - }) -} - -function schemaMap(value: unknown, seen: WeakSet): Record { - if (!isRecord(value)) return {} - const out: Record = {} - for (const [key, item] of Object.entries(value)) { - setSchemaProperty(out, key, schemaValue(item, seen)) - } - return out -} - -function schemaArray(value: unknown, seen: WeakSet): unknown[] { - if (!Array.isArray(value)) return [] - return value.map((item) => schemaValue(item, seen)) -} - -function isSchemaValue(value: unknown): value is JsonSchemaValue { - return typeof value === "boolean" || isRecord(value) -} - -function copySchemaObject( - source: Record, - seen: WeakSet, - legacy: boolean, - forcedType?: string, -): JsonSchemaValue { - const out: Record = {} - - for (const [key, value] of Object.entries(source)) { - if (legacy && LEGACY_FIELDS.has(key)) continue - if (key === "nullable" || (forcedType !== undefined && key === "type")) continue - - if (key === "required") { - const required = stringArray(value) - if (required) out.required = required - } else if (SCHEMA_MAP_FIELDS.has(key)) { - out[key] = schemaMap(value, seen) - } else if (SCHEMA_ARRAY_FIELDS.has(key)) { - out[key] = schemaArray(value, seen) - } else if (SCHEMA_VALUE_FIELDS.has(key)) { - out[key] = - Array.isArray(value) && key === "items" - ? schemaArray(value, seen) - : schemaValue(value, seen) - } else { - out[key] = value - } - } - - if (forcedType !== undefined) out.type = forcedType - if (booleanValue(source.nullable) === true) return makeNullable(out) - return out -} - -function makeNullable(schema: Record): Record { - const type = schema.type - if (typeof type === "string") { - if (type === "null") return schema - return { ...schema, type: [type, "null"] } - } - if (Array.isArray(type) && !type.includes("null")) { - return { ...schema, type: [...type, "null"] } - } - if (Array.isArray(schema.anyOf)) { - return { ...schema, anyOf: [...schema.anyOf, { type: "null" }] } - } - return { anyOf: [schema, { type: "null" }] } -} - -function legacyVariants(schema: Record): unknown[] { - if (Array.isArray(schema.variants)) return schema.variants - if (Array.isArray(schema.anyOf)) return schema.anyOf - return [] -} - -function convertLegacySchema( - source: Record, - kind: string, - seen: WeakSet, -): JsonSchemaValue { - if (kind === "optional") return schemaValue(source.wrapped ?? source.inner, seen) - if (kind === "nullable") { - const wrapped = schemaValue(source.wrapped ?? source.inner, seen) - return typeof wrapped === "boolean" ? wrapped : makeNullable(wrapped) - } - if (kind === "undefined" || kind === "never" || kind === "any" || kind === "unknown") return {} - - if (kind === "union" || kind === "intersect" || kind === "intersection") { - const variants = legacyVariants(source) - .map((variant) => schemaValue(variant, seen)) - .filter( - (variant) => - isSchemaValue(variant) && - (typeof variant === "boolean" || Object.keys(variant).length > 0), - ) - if (variants.length === 0) return copySchemaObject(source, seen, true) - if (variants.length === 1) return variants[0] ?? {} - - const out = copySchemaObject(source, seen, true) - if (typeof out !== "boolean") out[kind === "union" ? "anyOf" : "allOf"] = variants - return out - } - - if (kind === "object") { - const converted = copySchemaObject(source, seen, true, "object") - if (typeof converted === "boolean") return converted - const out = converted - const sourceProperties = isRecord(source.properties) ? source.properties : undefined - if (!sourceProperties) return out - - const properties: Record = {} - const optional = stringArray(source.optional) ?? [] - for (const [key, value] of Object.entries(sourceProperties)) { - setSchemaProperty(properties, key, schemaValue(value, seen)) - } - out.properties = properties - - const explicitRequired = stringArray(source.required) - const required = - explicitRequired ?? - Object.entries(sourceProperties) - .filter(([key, value]) => !optional.includes(key) && !isOptionalSchema(value)) - .map(([key]) => key) - if (required.length > 0) out.required = required - else delete out.required - return out - } - - if (kind === "array") { - const converted = copySchemaObject(source, seen, true, "array") - if (typeof converted === "boolean") return converted - const out = converted - if (!("items" in source) && "element" in source) out.items = schemaValue(source.element, seen) - return out - } - - if (kind === "enum") { - const converted = copySchemaObject(source, seen, true) - if (typeof converted === "boolean") return converted - const out = converted - if (!("enum" in out) && Array.isArray(source.values)) out.enum = source.values - return out - } - - if (kind === "literal") { - const converted = copySchemaObject(source, seen, true) - if (typeof converted === "boolean") return converted - const out = converted - if (!("const" in out) && "value" in source) out.const = source.value - return out - } - - const scalarType = - kind === "string" || - kind === "number" || - kind === "boolean" || - kind === "integer" || - kind === "null" - ? kind - : undefined - return scalarType ? copySchemaObject(source, seen, true, scalarType) : {} -} - -function convertSchema(source: Record, seen: WeakSet): JsonSchemaValue { - if (seen.has(source)) return {} - seen.add(source) - try { - const kind = legacyKind(source) - if (kind) return convertLegacySchema(source, kind, seen) - if (!looksLikeJsonSchema(source)) return {} - return copySchemaObject(source, seen, false) - } finally { - seen.delete(source) - } -} - -export function toJsonSchema(schema: unknown): unknown { - if (typeof schema === "boolean") return schema - if (!isRecord(schema)) return {} - return convertSchema(schema, new WeakSet()) -} diff --git a/src/models.ts b/src/models.ts index 0d8ffe9..da0ff85 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,47 +1,29 @@ -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" -import { dirname } from "node:path" +/** + * Command Code model catalog: merging the generated CLI catalog with the live + * Provider API listing into pi provider model definitions. + */ -import { MODEL_EFFORT_OVERRIDES } from "./commandcode-catalog-overrides.ts" -import { - MODEL_EFFORTS as CATALOG_MODEL_EFFORTS, - MODEL_INPUT_MODALITIES, - MODEL_MAX_OUTPUT_TOKENS, - MODEL_REASONING, - type CommandCodeInputType, - type CommandCodeReasoningEffort, -} from "./commandcode-catalog.ts" +import type { ProviderConfig } from "@earendil-works/pi-coding-agent" -/** Upstream CLI efforts with the manual overrides merged over them. */ -export const MODEL_EFFORTS: Readonly> = { - ...CATALOG_MODEL_EFFORTS, - ...MODEL_EFFORT_OVERRIDES, -} +import { CATALOG, type CatalogModel, type CommandCodeModelCost } from "./catalog.ts" -export { MODEL_INPUT_MODALITIES, MODEL_MAX_OUTPUT_TOKENS, MODEL_REASONING } -export type { CommandCodeInputType } +export type ProviderModelConfig = NonNullable[number] +export const PROVIDER_ID = "commandcode" export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1" export const DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models` export const DEFAULT_MODELS_TIMEOUT_MS = 10_000 -const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 -const MODEL_CACHE_VERSION = 1 +/** Used when the CLI catalog publishes no model-specific output limit. */ +export const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 + +/** The CLI omits context windows for a few models; pi still needs a usable one. */ +export const DEFAULT_CONTEXT_WINDOW = 200_000 export type CommandCodeApi = "openai-completions" | "anthropic-messages" - -const TEXT_INPUT_ONLY = ["text"] as const - -export function inputModalitiesForModel(modelId: string): readonly CommandCodeInputType[] { - return MODEL_INPUT_MODALITIES[modelId] ?? TEXT_INPUT_ONLY -} - -export function modelSupportsImageInput(modelId: string): boolean { - return inputModalitiesForModel(modelId).includes("image") -} - export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" -const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [ +export const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [ "off", "minimal", "low", @@ -51,7 +33,23 @@ const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [ "max", ] -export function thinkingLevelMapForEfforts( +/** Command Code proxies Claude through its Anthropic-compatible endpoint. */ +export function apiForModelId(id: string): CommandCodeApi { + return id.startsWith("claude-") ? "anthropic-messages" : "openai-completions" +} + +/** + * pi appends "/v1/messages" to the Anthropic base URL, so Claude models point + * at the provider root while OpenAI-compatible models keep the "/v1" suffix. + */ +export function baseUrlForApi(providerApiBase: string, api: CommandCodeApi): string { + const normalized = providerApiBase.replace(/\/+$/, "") + if (api !== "anthropic-messages") return normalized + return normalized.endsWith("/v1") ? normalized.slice(0, -"/v1".length) : normalized +} + +/** Level map for pi's picker; unsupported levels must be `null`, not omitted. */ +export function thinkingLevelMapFor( efforts: readonly string[], ): Partial> { const map: Partial> = {} @@ -62,45 +60,6 @@ export function thinkingLevelMapForEfforts( return map } -export interface ThinkingMetadata { - thinkingLevelMap: Partial> - thinking?: { - mode: "effort" - effortMap: Partial> - efforts: readonly CommandCodeReasoningEffort[] - } -} - -export function thinkingMetadataForModel(modelId: string): ThinkingMetadata | undefined { - const efforts = MODEL_EFFORTS[modelId] - if (efforts) { - return { - thinkingLevelMap: thinkingLevelMapForEfforts(efforts), - thinking: { - mode: "effort", - effortMap: Object.fromEntries(efforts.map((effort) => [effort, effort])), - efforts, - }, - } - } - if (!isReasoningModel(modelId)) return undefined - return { thinkingLevelMap: thinkingLevelMapForEfforts([]) } -} - -function isReasoningModel(modelId: string): boolean { - return MODEL_REASONING[modelId] === true -} - -function maxOutputTokensForModel(modelId: string, contextLength: number): number { - return Math.min(contextLength, MODEL_MAX_OUTPUT_TOKENS[modelId] ?? DEFAULT_MAX_OUTPUT_TOKENS) -} - -interface ApiModel { - id: string - name: string - contextLength: number -} - export interface CommandCodeModel { id: string name: string @@ -108,40 +67,76 @@ export interface CommandCodeModel { reasoning: boolean contextWindow: number maxTokens: number + input: ("text" | "image")[] + efforts: readonly string[] + cost: CommandCodeModelCost } -export function apiForModelId(id: string): CommandCodeApi { - return id.startsWith("claude-") ? "anthropic-messages" : "openai-completions" +export interface LiveModel { + id: string + name: string + contextWindow: number } -export function baseUrlForModel(apiBase: string, api: CommandCodeApi): string { - const normalized = apiBase.replace(/\/+$/g, "") - if (api !== "anthropic-messages") return normalized - return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized +const CATALOG_BY_ID = new Map(CATALOG.map((model) => [model.id, model])) + +export function catalogEntryFor(id: string): CatalogModel | undefined { + return CATALOG_BY_ID.get(id) } -interface FetchCommandCodeModelsOptions { - url?: string - fetchImpl?: typeof fetch - signal?: AbortSignal - timeoutMs?: number +function maxOutputTokensFor(entry: CatalogModel | undefined, contextWindow: number): number { + const published = entry?.maxOutputTokens ?? 0 + const limit = published > 0 ? published : DEFAULT_MAX_OUTPUT_TOKENS + return Math.min(contextWindow, limit) } -interface LoadCommandCodeModelsOptions extends FetchCommandCodeModelsOptions { - cachePath: string +function modelFromEntry(entry: CatalogModel, name: string, publishedContext: number): CommandCodeModel { + const contextWindow = publishedContext > 0 ? publishedContext : DEFAULT_CONTEXT_WINDOW + return { + id: entry.id, + name, + api: apiForModelId(entry.id), + reasoning: entry.reasoning, + contextWindow, + maxTokens: maxOutputTokensFor(entry, contextWindow), + input: [...entry.input], + efforts: entry.efforts, + cost: entry.cost, + } } -export interface LoadCommandCodeModelsResult { - models: readonly CommandCodeModel[] - source: "live" | "cache" | "empty" - warning?: string +/** Static baseline so the catalog survives an offline start before any refresh. */ +export function modelsFromCatalog(): CommandCodeModel[] { + return CATALOG.map((entry) => modelFromEntry(entry, entry.name, entry.contextWindow)) +} + +/** + * Models the live endpoint serves without CLI metadata stay text-only and + * unpriced; a later CLI release fills them in via `npm run sync:catalog`. + */ +export function modelsFromLive(live: readonly LiveModel[]): CommandCodeModel[] { + return live.map((model) => { + const entry = catalogEntryFor(model.id) + if (entry) return modelFromEntry(entry, entry.name, model.contextWindow) + return { + id: model.id, + name: model.name, + api: apiForModelId(model.id), + reasoning: false, + contextWindow: model.contextWindow, + maxTokens: maxOutputTokensFor(undefined, model.contextWindow), + input: ["text"], + efforts: [], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + } + }) } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } -function stringField(record: Record, key: string): string { +function requiredString(record: Record, key: string): string { const value = record[key] if (typeof value !== "string" || value.length === 0) { throw new Error(`Expected ${key} to be a non-empty string`) @@ -149,13 +144,7 @@ function stringField(record: Record, key: string): string { return value } -function booleanField(record: Record, key: string): boolean { - const value = record[key] - if (typeof value !== "boolean") throw new Error(`Expected ${key} to be a boolean`) - return value -} - -function positiveNumberField(record: Record, key: string): number { +function requiredPositiveNumber(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`) @@ -163,250 +152,119 @@ function positiveNumberField(record: Record, key: string): numb return value } -function parseApiModel(value: unknown): ApiModel { - if (!isRecord(value)) throw new Error("Expected model entry to be an object") +export function parseLiveCatalog(value: unknown): LiveModel[] { + if (!isRecord(value)) throw new Error("Expected the models response to be an object") + if (value.object !== "list") throw new Error("Expected the models response object to be 'list'") + if (!Array.isArray(value.data)) throw new Error("Expected the models response data to be an array") - return { - id: stringField(value, "id"), - name: stringField(value, "name"), - contextLength: positiveNumberField(value, "context_length"), - } -} - -function parseCachedModel(value: unknown): CommandCodeModel { - if (!isRecord(value)) throw new Error("Expected cached model entry to be an object") - - const id = stringField(value, "id") - booleanField(value, "reasoning") - positiveNumberField(value, "maxTokens") - const contextWindow = positiveNumberField(value, "contextWindow") - return { - id, - name: stringField(value, "name"), - api: apiForModelId(id), - reasoning: isReasoningModel(id), - contextWindow, - maxTokens: maxOutputTokensForModel(id, contextWindow), - } -} - -function requireModels(models: readonly CommandCodeModel[]): readonly CommandCodeModel[] { + const models = value.data.map((entry) => { + if (!isRecord(entry)) throw new Error("Expected each model entry to be an object") + return { + id: requiredString(entry, "id"), + name: requiredString(entry, "name"), + contextWindow: requiredPositiveNumber(entry, "context_length"), + } + }) 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) -} - -function abortError(reason: unknown): Error { - if (reason instanceof Error) return reason - return new DOMException("The operation was aborted", "AbortError") -} - -function configuredTimeoutMs(timeoutMs: number | undefined): number { - return timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DEFAULT_MODELS_TIMEOUT_MS -} - export function getModelsTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.COMMANDCODE_MODELS_TIMEOUT_MS if (!raw) return DEFAULT_MODELS_TIMEOUT_MS - const parsed = Number(raw) - return configuredTimeoutMs(parsed) + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MODELS_TIMEOUT_MS } -class ModelDiscoveryTimeoutError extends Error { - constructor(timeoutMs: number) { - super(`Command Code model discovery timed out after ${timeoutMs}ms`) - this.name = "ModelDiscoveryTimeoutError" - } +export interface FetchLiveCatalogOptions { + url?: string + timeoutMs?: number + signal?: AbortSignal + fetchImpl?: typeof fetch } -function runWithTimeout( - operation: (signal: AbortSignal) => Promise, - timeoutMs: number, - externalSignal: AbortSignal | undefined, -): Promise { - const controller = new AbortController() - let timer: ReturnType | undefined - let settled = false - let onExternalAbort: (() => void) | undefined - - return new Promise((resolve, reject) => { - const cleanup = () => { - if (timer !== undefined) clearTimeout(timer) - if (onExternalAbort && externalSignal) { - externalSignal.removeEventListener("abort", onExternalAbort) - } - } - - const resolveOnce = (value: T) => { - if (settled) return - settled = true - cleanup() - resolve(value) - } - - const rejectOnce = (error: unknown) => { - if (settled) return - settled = true - cleanup() - reject(error) - } - - const abort = (reason: unknown) => { - const error = abortError(reason) - controller.abort(error) - rejectOnce(error) - } - - if (externalSignal?.aborted) { - abort(externalSignal.reason) - return - } - - onExternalAbort = () => abort(externalSignal?.reason) - externalSignal?.addEventListener("abort", onExternalAbort, { once: true }) - timer = setTimeout(() => abort(new ModelDiscoveryTimeoutError(timeoutMs)), timeoutMs) - - Promise.resolve() - .then(() => operation(controller.signal)) - .then(resolveOnce, rejectOnce) - }) -} - -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'") - - const data = value.data - if (!Array.isArray(data)) throw new Error("Expected models response data to be an array") - - return data.map(parseApiModel).map((model) => ({ - id: model.id, - name: `${model.name} (CC)`, - api: apiForModelId(model.id), - reasoning: isReasoningModel(model.id), - contextWindow: model.contextLength, - maxTokens: maxOutputTokensForModel(model.id, model.contextLength), - })) -} - -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 { - const url = options.url ?? DEFAULT_MODELS_URL +export async function fetchLiveCatalog( + options: FetchLiveCatalogOptions = {}, +): Promise { const fetchImpl = options.fetchImpl ?? fetch - const body: unknown = await runWithTimeout( - async (signal) => { - const response = await fetchImpl(url, { - headers: { - accept: "application/json", - }, - signal, - }) - - if (!response.ok) { - throw new Error( - `Failed to fetch Command Code models: ${response.status} ${response.statusText}`, - ) - } - - return await response.json() - }, - configuredTimeoutMs(options.timeoutMs), - options.signal, - ) - 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) -} - -/** Reads the cached catalog without touching the network; empty when missing or invalid. */ -export async function loadCachedCommandCodeModels( - cachePath: string, -): Promise { - try { - return await readCommandCodeModelsCache(cachePath) - } catch { - return [] - } -} - -async function writeCommandCodeModelsCache( - cachePath: string, - models: readonly CommandCodeModel[], -): Promise { - await mkdir(dirname(cachePath), { recursive: true }) - const temporaryPath = `${cachePath}.${process.pid}.tmp` + const timeoutMs = options.timeoutMs ?? DEFAULT_MODELS_TIMEOUT_MS + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new Error("Model discovery timed out")), timeoutMs) + const onExternalAbort = () => controller.abort(options.signal?.reason) + options.signal?.addEventListener("abort", onExternalAbort, { once: true }) try { - await writeFile( - temporaryPath, - `${JSON.stringify({ version: MODEL_CACHE_VERSION, models }, null, 2)}\n`, - { encoding: "utf-8", mode: 0o600 }, - ) - await rename(temporaryPath, cachePath) + const response = await fetchImpl(options.url ?? DEFAULT_MODELS_URL, { + headers: { accept: "application/json" }, + signal: controller.signal, + }) + if (!response.ok) { + throw new Error(`Failed to fetch the Command Code catalog: ${response.status}`) + } + return parseLiveCatalog(await response.json()) } finally { - try { - await rm(temporaryPath, { force: true }) - } catch { - // Best-effort cleanup must not hide the original cache write error. - } + clearTimeout(timer) + options.signal?.removeEventListener("abort", onExternalAbort) } } -export async function loadCommandCodeModels( - options: LoadCommandCodeModelsOptions, -): Promise { - const cachePath = options.cachePath +export function providerBaseUrl(env: NodeJS.ProcessEnv = process.env): string { + return env.COMMANDCODE_API_BASE ?? DEFAULT_PROVIDER_API_BASE +} - try { - const models = await fetchCommandCodeModels(options) +export function modelsUrl(env: NodeJS.ProcessEnv = process.env): string { + return env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL +} - 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) { - if (options.signal?.aborted) throw abortError(options.signal.reason ?? liveError) +/** Account endpoints (whoami, billing, usage) live above the /provider/v1 namespace. */ +export function accountApiBase(providerApiBase: string): string { + return providerApiBase.replace(/\/provider\/v1\/?$/, "") +} - 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 /commandcode-refresh succeeds.`, - } - } +/** + * `zdr` asks Command Code for zero data retention; the documented header is + * opt-in via `CMD_ZDR`, with the older alias still accepted. + */ +export function providerHeaders(env: NodeJS.ProcessEnv = process.env): Record | undefined { + if (env.CMD_ZDR === "1" || env.COMMANDCODE_ZDR === "1") return { "x-cmd-zdr": "1" } + return undefined +} + +export function toProviderModel(model: CommandCodeModel, apiBase: string): ProviderModelConfig { + const thinking = + model.reasoning && model.efforts.length > 0 + ? { thinkingLevelMap: thinkingLevelMapFor(model.efforts) } + : model.reasoning + ? { thinkingLevelMap: thinkingLevelMapFor([]) } + : {} + + const compat: ProviderModelConfig["compat"] = + model.api === "openai-completions" + ? { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: model.efforts.length > 0, + maxTokensField: "max_tokens", + } + : { + supportsEagerToolInputStreaming: false, + supportsLongCacheRetention: false, + supportsCacheControlOnTools: false, + supportsToolReferences: false, + ...(model.reasoning ? { forceAdaptiveThinking: true } : {}), + } + + return { + id: model.id, + name: model.name, + api: model.api, + baseUrl: baseUrlForApi(apiBase, model.api), + reasoning: model.reasoning, + ...thinking, + input: [...model.input], + cost: { ...model.cost }, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + compat, } } diff --git a/src/oauth.ts b/src/oauth.ts deleted file mode 100644 index 470ebd3..0000000 --- a/src/oauth.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Command Code OAuth provider for pi's /login flow. - * - * Implements two API key retrieval flows: - * 1. Browser-assisted login opens Command Code Studio and waits for the - * website to POST the API key back to a local callback server. - * 2. Direct API key login prompts the user to paste a Studio API key. - * - * If browser transfer fails, the user can still paste the API key manually. - * The API key is stored in pi's auth.json as OAuth credentials. - * - * Since Command Code API keys don't expire, we store them as - * OAuth credentials with a far-future expiry. - */ - -import { randomBytes } from "node:crypto" -import { startAuthServer } from "./auth-server.ts" - -const STUDIO_BASE_URL = "https://commandcode.ai" -const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire -const DEFAULT_AUTH_TIMEOUT_MS = 120_000 -const DEFAULT_API_BASE = "https://api.commandcode.ai" - -export interface OAuthLoginCallbacks { - onAuth(params: { url: string }): void - onPrompt(params: { message: string }): Promise -} - -export interface OAuthCredentials { - refresh: string - access: string - expires: number -} - -class AuthTimeoutError extends Error { - constructor() { - super("Browser authentication timed out") - this.name = "AuthTimeoutError" - } -} - -function generateStateToken(): string { - return randomBytes(32).toString("base64url") -} - -function getAuthTimeoutMs(): number { - const raw = process.env.COMMANDCODE_AUTH_TIMEOUT_MS - if (!raw) return DEFAULT_AUTH_TIMEOUT_MS - - const parsed = Number(raw) - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AUTH_TIMEOUT_MS -} - -function withTimeout(promise: Promise, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new AuthTimeoutError()), timeoutMs) - - promise.then( - (value) => { - clearTimeout(timer) - resolve(value) - }, - (error) => { - clearTimeout(timer) - reject(error) - }, - ) - }) -} - -function credentialsFromApiKey(apiKey: string): OAuthCredentials { - return { - refresh: apiKey, - access: apiKey, - expires: Date.now() + TEN_YEARS_MS, - } -} - -/** - * Remove common terminal paste wrappers/control chars and surrounding whitespace. - */ -export function sanitizeApiKey(input: string): string { - const esc = String.fromCharCode(27) - return Array.from( - input - .replaceAll(`${esc}[200~`, "") - .replaceAll(`${esc}[201~`, "") - .replaceAll("[200~", "") - .replaceAll("[201~", ""), - ) - .filter((char) => { - const code = char.charCodeAt(0) - return code > 31 && code !== 127 - }) - .join("") - .trim() -} - -export async function validateApiKey( - apiKey: string, - options: { fetchImpl?: typeof fetch; apiBase?: string } = {}, -): Promise { - let response: Response - try { - response = await (options.fetchImpl ?? fetch)( - `${options.apiBase ?? DEFAULT_API_BASE}/alpha/whoami`, - { - headers: { Authorization: `Bearer ${apiKey}` }, - }, - ) - } catch (error) { - throw new Error( - `Could not validate the Command Code API key: ${error instanceof Error ? error.message : String(error)}`, - ) - } - - if (response.status === 401) throw new Error("Invalid Command Code API key") - if (!response.ok) { - throw new Error(`Could not validate the Command Code API key (${response.status})`) - } -} - -async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) { - const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message })) - if (!apiKey) throw new Error("No Command Code API key provided") - await validateApiKey(apiKey) - return credentialsFromApiKey(apiKey) -} - -type LoginChoice = { type: "browser" } | { type: "prompt" } | { type: "apiKey"; apiKey: string } - -async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise { - const input = sanitizeApiKey( - await callbacks.onPrompt({ - message: - "Command Code login: press Enter for browser login, type 'key' to paste an API key, or paste the API key directly:", - }), - ) - const normalized = input.toLowerCase() - - if (!input || normalized === "1" || normalized === "b" || normalized === "browser") { - return { type: "browser" } - } - - if ( - normalized === "2" || - normalized === "k" || - normalized === "key" || - normalized === "api" || - normalized === "paste" - ) { - return { type: "prompt" } - } - - return { type: "apiKey", apiKey: input } -} - -async function browserLogin(callbacks: OAuthLoginCallbacks): Promise { - const stateToken = generateStateToken() - let authServer - try { - authServer = await startAuthServer({ expectedState: stateToken }) - } catch { - return promptForApiKey( - callbacks, - "Could not start browser auth. Paste your Command Code API key:", - ) - } - - const callbackUrl = `http://localhost:${authServer.port}/callback` - const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(stateToken)}` - - // Tell pi to open the browser. - callbacks.onAuth({ url: authUrl }) - - // Wait for the Command Code Studio to POST the API key back. If the browser - // cannot reach localhost (Command Code shows "Copy your API key"), fall back - // to pi's prompt so the user can paste the key from the browser. - let callback: { apiKey: string; state: string } - try { - callback = await withTimeout(authServer.waitForCallback, getAuthTimeoutMs()) - } catch (error) { - authServer.server.close() - if (error instanceof AuthTimeoutError) { - return promptForApiKey( - callbacks, - "Automatic transfer failed or timed out. Paste your Command Code API key:", - ) - } - throw error - } - - return credentialsFromApiKey(callback.apiKey) -} - -/** - * Starts the login flow for Command Code. - * - * Returns OAuth credentials where access == refresh == the user's API key. - * The keys don't expire, so we set a far-future expiry. - */ -export async function login(callbacks: OAuthLoginCallbacks): Promise { - const choice = await chooseLoginFlow(callbacks) - - if (choice.type === "apiKey") { - await validateApiKey(choice.apiKey) - return credentialsFromApiKey(choice.apiKey) - } - if (choice.type === "prompt") { - return promptForApiKey(callbacks, "Paste your Command Code API key:") - } - - return browserLogin(callbacks) -} - -/** - * Command Code API keys don't expire, so "refresh" is a no-op. - * Returns the same credentials with an updated far-future expiry. - */ -export async function refreshToken(credentials: OAuthCredentials): Promise { - return credentialsFromApiKey(credentials.refresh) -} - -/** - * Returns the access token (API key) from OAuth credentials. - */ -export function getApiKey(credentials: OAuthCredentials): string { - return credentials.access -} diff --git a/src/overflow.ts b/src/overflow.ts index 3e3e106..121be84 100644 --- a/src/overflow.ts +++ b/src/overflow.ts @@ -1,55 +1,59 @@ -const COMMAND_CODE_PROVIDER = "commandcode" -const CONTEXT_OVERFLOW_PREFIX = "context_length_exceeded:" +/** + * Command Code overflow and error-text handling. + * + * Command Code reports context limit failures in provider-specific wording, so + * pi cannot recognize them as overflow and therefore cannot compact and retry. + * The message_end handler rewrites only those messages to pi's generic + * `context_length_exceeded` prefix. + */ -const COMMAND_CODE_OVERFLOW_PATTERNS = [ - /\b(?:context[_\s-]*(?:length|window)|model[_\s-]*context[_\s-]*window)[_\s-]*(?:exceeded|overflow(?:ed)?|too[_\s-]*(?:large|long))\b/i, +const CONTEXT_OVERFLOW_PREFIX = "context_length_exceeded" +const CONTEXT_OVERFLOW_PREFIX_PATTERN = /context_length_exceeded/i + +const OVERFLOW_PATTERNS = [ /\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b[\s\S]{0,120}\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long)|(?:maximum|limit)\s+(?:reached|exceeded|hit))\b/i, /\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long))\b[\s\S]{0,120}\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b/i, - /\b(?:prompt|input|context)\b[\s\S]{0,32}\btoo\s+(?:large|long)\b/i, - /\b(?:prompt|input)[_\s-]*too[_\s-]*(?:large|long)\b/i, - /\b(?:prompt|input)[_\s-]*tokens?[_\s-]*(?:limit|maximum|max)[_\s-]*(?:exceeded|reached)\b/i, - /\b(?:prompt|input)[_\s-]*(?:tokens?|length|size)\b[\s\S]{0,120}\b(?:limit|maximum)\b[\s\S]{0,40}\b(?:exceed(?:ed|s)?|reached|hit)\b/i, + /\b(?:prompt|input)\b[\s\S]{0,32}\btoo\s+(?:large|long)\b/i, /\b(?:maximum|limit)[_\s-]+(?:allowed[_\s-]+)?(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?)\b/i, ] +/** Errors that need pi's normal retry path, never compaction. */ const NON_OVERFLOW_PATTERNS = [ /\brate[_\s-]*limit\b/i, /\btoo\s+many\s+requests\b/i, /\b(?:capacity|quota|throttl(?:e|ed|ing)?|concurren(?:cy|t)|overloaded)\b/i, /\b(?:service|temporarily)\s+unavailable\b/i, /\bstatus(?:[_\s-]*code)?\s*[:=]\s*429\b/i, -] - -const CONTEXT_OVERFLOW_PREFIX_PATTERN = /context_length_exceeded/i - -const HTTP_RATE_LIMIT_STATUS_PATTERNS = [ - /\b(?:api\s+error|http|status(?:[_\s-]*code)?|status[_\s-]*code)\s*[:(]?\s*429\b/i, - /["']?(?:status|status[_\s-]*code)["']?\s*:\s*429\b/i, + /["']?status(?:[_\s-]*code)?["']?\s*:\s*429\b/i, ] const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi const CREDENTIAL_PATTERN = - /\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*[^\s,;)]+/gi + /\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*(?!Bearer\b)[^\s,;)]+/gi const USER_TOKEN_PATTERN = /\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi const QUERY_SECRET_PATTERN = /([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|password)=)[^&#\s]+/gi -const STANDALONE_SECRET_PATTERN = - /\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b|\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g +/** API keys can appear inside provider error bodies; never surface them. */ export function redactCommandCodeErrorText(value: string): string { return value .replace(BEARER_PATTERN, "Bearer [redacted]") .replace(CREDENTIAL_PATTERN, (match) => { - const separatorIndex = match.search(/[=:]/) - return separatorIndex < 0 ? "[redacted]" : `${match.slice(0, separatorIndex + 1)}[redacted]` + const separator = match.search(/[=:]/) + return separator < 0 ? "[redacted]" : `${match.slice(0, separator + 1)}[redacted]` }) .replace(USER_TOKEN_PATTERN, "[redacted]") .replace(QUERY_SECRET_PATTERN, "$1[redacted]") - .replace(STANDALONE_SECRET_PATTERN, "[redacted]") } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null +export function normalizeCommandCodeErrorMessage( + errorMessage: string | undefined, +): string | undefined { + if (!errorMessage) return undefined + if (CONTEXT_OVERFLOW_PREFIX_PATTERN.test(errorMessage)) return undefined + if (NON_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) return undefined + if (!OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) return undefined + return `${CONTEXT_OVERFLOW_PREFIX}: ${errorMessage}` } export interface CommandCodeMessageLike { @@ -59,62 +63,14 @@ export interface CommandCodeMessageLike { errorMessage?: string } -export function commandCodeErrorMessage(value: unknown): string | undefined { - if (typeof value === "string") return value - if (!isRecord(value)) return undefined - - const record = value - const parts: string[] = [] - for (const key of [ - "message", - "errorMessage", - "error", - "detail", - "details", - "code", - "type", - "reason", - ]) { - const part = commandCodeErrorMessage(record[key]) - if (part && !parts.includes(part)) parts.push(part) - } - - for (const key of ["status", "statusCode", "httpStatus"]) { - const status = record[key] - if (typeof status === "string" || typeof status === "number") { - const statusPart = `status: ${status}` - if (!parts.includes(statusPart)) parts.push(statusPart) - } - } - - return parts.length > 0 ? redactCommandCodeErrorText(parts.join(": ")) : undefined -} - -export function normalizeCommandCodeErrorMessage( - errorMessage: string | undefined, -): string | undefined { - if (!errorMessage) return undefined - if (CONTEXT_OVERFLOW_PREFIX_PATTERN.test(errorMessage)) return undefined - if (NON_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) return undefined - if (HTTP_RATE_LIMIT_STATUS_PATTERNS.some((pattern) => pattern.test(errorMessage))) - return undefined - if (!COMMAND_CODE_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) - return undefined - - return `${CONTEXT_OVERFLOW_PREFIX} ${errorMessage}` -} - export function normalizeCommandCodeMessage( message: T, modelProvider?: string, ): { message: T & { errorMessage: string } } | undefined { if (message.role !== "assistant" || message.stopReason !== "error") return undefined - if (message.provider !== COMMAND_CODE_PROVIDER && modelProvider !== COMMAND_CODE_PROVIDER) { - return undefined - } + if (message.provider !== "commandcode" && modelProvider !== "commandcode") return undefined const errorMessage = normalizeCommandCodeErrorMessage(message.errorMessage) if (!errorMessage) return undefined - return { message: { ...message, errorMessage } } } diff --git a/src/pricing.ts b/src/pricing.ts deleted file mode 100644 index 583e294..0000000 --- a/src/pricing.ts +++ /dev/null @@ -1,231 +0,0 @@ -export interface CommandCodeModelCostRates { - input: number - output: number - cacheRead: number - cacheWrite: number -} - -export interface CommandCodeModelCostTier extends CommandCodeModelCostRates { - inputTokensAbove: number -} - -export interface CommandCodeModelCost extends CommandCodeModelCostRates { - tiers?: readonly CommandCodeModelCostTier[] -} - -export interface TemporaryPricing { - models: readonly string[] - expiresOn: string - description: string -} - -export const PRICING_SOURCE_URL = "https://commandcode.ai/docs/resources/pricing-limits" -export const PRICING_LAST_VERIFIED = "2026-09-01" - -export const ZERO_MODEL_COST: CommandCodeModelCost = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -} - -/** - * Display prices in USD per million tokens. - * - * Context-dependent rates use pi's request-wide input pricing tiers. The - * highest threshold exceeded by input + cache reads + cache writes applies to - * the full request. The Command Code usage page remains authoritative for the - * amount billed for an individual request. - */ -export const MODEL_COSTS: Readonly> = { - // Free models - "poolside/laguna-s-2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - - // Open and open-weight models - "tencent/hy3-paid": { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, - "tencent/hy4-preview": { input: 0.834, output: 2.501, cacheRead: 0.042, cacheWrite: 0 }, - "moonshotai/Kimi-K3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "moonshotai/Kimi-K2.7-Code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "moonshotai/Kimi-K2.7-Code-Highspeed": { - input: 1.9, - output: 8, - cacheRead: 0.38, - cacheWrite: 0, - }, - "moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - "z-ai/glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "zai-org/GLM-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "zai-org/GLM-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "zai-org/GLM-5.2-Fast": { input: 3, output: 10.25, cacheRead: 0.5, cacheWrite: 0 }, - "zai-org/GLM-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "zai-org/GLM-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, - "MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "MiniMaxAI/MiniMax-M2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 }, - // DeepSeek V4 uses time-dependent rates. Display the documented off-peak - // rates, which apply for 17 hours per day; the Usage page remains authoritative. - "deepseek/deepseek-v4-pro": { - input: 0.66, - output: 1.98, - cacheRead: 0.022, - cacheWrite: 0, - }, - "deepseek/deepseek-v4-flash": { - input: 0.22, - output: 0.66, - cacheRead: 0.007, - cacheWrite: 0, - }, - "deepseek/deepseek-v4-flash-vision-exp": { - input: 0.22, - output: 0.66, - cacheRead: 0.007, - cacheWrite: 0, - }, - "deepseek/deepseek-v4-flash-fast": { - input: 0.28, - output: 0.56, - cacheRead: 0.07, - cacheWrite: 0, - }, - "Qwen/Qwen3.8-Max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, - "Qwen/Qwen3.8-27B": { input: 0.4, output: 3, cacheRead: 0.04, cacheWrite: 0 }, - "Qwen/Qwen3.8-Flash": { input: 0.16, output: 0.47, cacheRead: 0.016, cacheWrite: 0 }, - "Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, - "Qwen/Qwen3.7-Plus": { - input: 0.4, - output: 1.6, - cacheRead: 0.08, - cacheWrite: 0.5, - tiers: [ - { - inputTokensAbove: 256_000, - input: 1.2, - output: 4.8, - cacheRead: 0.24, - cacheWrite: 1.5, - }, - ], - }, - "Qwen/Qwen3.7-Flash": { - input: 0.03, - output: 0.13, - cacheRead: 0.006, - cacheWrite: 0.038, - tiers: [ - { - inputTokensAbove: 32_000, - input: 0.1, - output: 0.4, - cacheRead: 0.02, - cacheWrite: 0.125, - }, - { - inputTokensAbove: 256_000, - input: 0.2, - output: 0.8, - cacheRead: 0.04, - cacheWrite: 0.25, - }, - ], - }, - "Qwen/Qwen3.6-Max-Preview": { - input: 1.3, - output: 7.8, - cacheRead: 0.26, - cacheWrite: 1.63, - }, - "Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - "stepfun/Step-3.7-Flash": { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 }, - "stepfun/Step-3.5-Flash": { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 }, - // Permanent discounted rates. - "xiaomi/mimo-v2.5-pro": { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }, - "xiaomi/mimo-v2.5": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - "nvidia/nemotron-3-ultra-550b-a55b": { - input: 0.6, - output: 2.4, - cacheRead: 0.12, - cacheWrite: 0, - }, - "sakana/fugu-ultra": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "thinkingmachines/inkling": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 }, - "thinkingmachines/inkling-small": { - input: 0.5, - output: 1.2, - cacheRead: 0.1, - cacheWrite: 0, - }, - "meta/muse-spark-1.1": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "meta/muse-spark-1.2": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "meta/muse-spark-1.2-contributor": { - input: 0.1, - output: 0.2, - cacheRead: 0.002, - cacheWrite: 0, - }, - - // Anthropic - "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-haiku-4-5-20251001": { - input: 1, - output: 5, - cacheRead: 0.1, - cacheWrite: 1.25, - }, - - // OpenAI - "gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, - "gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - - // Google and xAI - "google/gemini-3.7-flash": { - input: 1.5, - output: 7.5, - cacheRead: 0.15, - cacheWrite: 0.08334, - }, - "google/gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - "google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "google/gemini-3.5-flash-lite": { - input: 0.3, - output: 2.5, - cacheRead: 0.03, - cacheWrite: 0, - }, - "google/gemini-3.1-flash-lite": { - input: 0.25, - output: 1.5, - cacheRead: 0.03, - cacheWrite: 0, - }, - "xai/grok-4.5": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "xai/grok-4.6": { - input: 2, - output: 6, - cacheRead: 0.5, - cacheWrite: 0, - tiers: [ - { - inputTokensAbove: 200_000, - input: 4, - output: 12, - cacheRead: 1, - cacheWrite: 0, - }, - ], - }, -} - -export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [] diff --git a/src/quota-command.ts b/src/quota-command.ts deleted file mode 100644 index 81a8779..0000000 --- a/src/quota-command.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { getConfiguredApiKey } from "./api-key.ts" -import { pickCommandCodeApiKey } from "./converters.ts" -import { fetchCommandCodeQuota, redactValue } from "./quota.ts" -import { formatQuota } from "./quota-format.ts" - -export interface QuotaCommandContext { - waitForIdle?: () => Promise - modelRegistry?: { - getApiKeyForProvider?: (provider: string) => Promise - } - ui: { - notify(message: string, type?: "info" | "warning" | "error"): void - } -} - -interface QuotaCommandApi { - registerCommand( - name: string, - options: { - description: string - handler: (args: string, ctx: QuotaCommandContext) => Promise - }, - ): void -} - -interface RegisterQuotaCommandOptions { - apiBase: string - headers?: Record - getConfiguredKey?: () => string | undefined - fetchQuota?: typeof fetchCommandCodeQuota -} - -export function registerCommandCodeQuota( - pi: QuotaCommandApi, - options: RegisterQuotaCommandOptions, -): void { - const getConfiguredKey = options.getConfiguredKey ?? getConfiguredApiKey - const fetchQuota = options.fetchQuota ?? fetchCommandCodeQuota - - pi.registerCommand("commandcode-quota", { - description: "Show Command Code account usage and quota", - handler: async (_args, ctx) => { - await ctx.waitForIdle?.() - const registryKey = await ctx.modelRegistry?.getApiKeyForProvider?.("commandcode") - const apiKey = pickCommandCodeApiKey(registryKey, getConfiguredKey()) - if (!apiKey) { - ctx.ui.notify( - "Command Code quota requires an API key. Run /login and select Command Code, or set COMMAND_CODE_API_KEY.", - "warning", - ) - return - } - - const result = await fetchQuota({ - apiKey, - baseUrl: options.apiBase, - extraHeaders: options.headers, - }) - if (!result.ok) { - ctx.ui.notify(redactValue(result.error.message), "error") - return - } - ctx.ui.notify(formatQuota(result.quota), "info") - }, - }) -} diff --git a/src/quota-format.ts b/src/quota-format.ts index 3541ca9..2472e84 100644 --- a/src/quota-format.ts +++ b/src/quota-format.ts @@ -1,143 +1,87 @@ -import type { - CommandCodeCredits, - CommandCodeQuota, - CommandCodeSubscription, - CommandCodeWindowLimit, -} from "./quota-types.ts" +/** Plain-text rendering of a Command Code quota snapshot for `ui.notify`. */ -export function formatWindowLimits( - limits: readonly CommandCodeWindowLimit[], - now: () => number = Date.now, -): string[] { - const labels: Record = { - fiveHour: "5-hour", - weekly: "Weekly", - } +import type { Quota } from "./quota.ts" - return limits.map((limit) => { - const used = limit.used.toFixed(2) - const cap = limit.cap.toFixed(2) - const percent = limit.cap > 0 ? Math.round((limit.used / limit.cap) * 100) : 0 - const reset = limit.resetAt === null ? "" : ` (resets ${formatResetClock(limit.resetAt, now)})` - return `${labels[limit.window]}: ${used} / ${cap} credits (${percent}% used)${reset}` - }) +const WINDOW_LABELS = { fiveHour: "5-hour", weekly: "Weekly" } as const +const UNAVAILABLE_LABELS: Record = { + credits: "credits", + subscription: "plan", + usage: "usage", } -function formatResetClock(resetAtSeconds: number, now: () => number): string { +function formatClock(resetAtSeconds: number, now: number): string { const date = new Date(resetAtSeconds * 1000) if (Number.isNaN(date.getTime())) return "unknown" - const diffMs = date.getTime() - now() + const diffMs = date.getTime() - now if (diffMs <= 0) return "soon" const minutes = Math.ceil(diffMs / 60_000) if (minutes < 60) return `in ${minutes}m` const hours = Math.floor(minutes / 60) const remainingMinutes = minutes % 60 - if (hours < 24) { - return remainingMinutes > 0 ? `in ${hours}h ${remainingMinutes}m` : `in ${hours}h` - } + if (hours < 24) return remainingMinutes > 0 ? `in ${hours}h ${remainingMinutes}m` : `in ${hours}h` const days = Math.floor(hours / 24) return days === 1 ? "in 1 day" : `in ${days} days` } -function creditsDetail(credits: CommandCodeCredits | null): string | undefined { - if (!credits) return undefined - const parts = [ - `monthly $${credits.monthlyCredits.toFixed(2)}`, - `purchased $${credits.purchasedCredits.toFixed(2)}`, - ] - if (credits.freeCredits > 0) parts.push(`free $${credits.freeCredits.toFixed(2)}`) - return `Sources: ${parts.join(" / ")}` +function formatDate(value: string | null): string | null { + if (!value) return null + const timestamp = /^\d+$/.test(value) ? Number(value) : Date.parse(value) + if (!Number.isFinite(timestamp)) return value + const milliseconds = timestamp < 1e12 ? timestamp * 1000 : timestamp + return new Date(milliseconds).toISOString() } -function parsePeriodEnd(value: string): Date | null { - const trimmed = value.trim() - const timestamp = /^\d+$/.test(trimmed) ? Number(trimmed) : Date.parse(trimmed) - if (!Number.isFinite(timestamp) || timestamp < 0) return null - const milliseconds = timestamp >= 1e12 ? timestamp : timestamp * 1000 - const date = new Date(milliseconds) - return Number.isNaN(date.getTime()) ? null : date -} +export function formatQuota(quota: Quota, now: () => number = Date.now): string { + const lines: string[] = ["Command Code usage"] + lines.push("") + lines.push(`Account: ${quota.account.login}`) + if (quota.account.keyName) lines.push(`API key: ${quota.account.keyName}`) -function subscriptionLine( - subscription: CommandCodeSubscription, - now: () => number = Date.now, -): string { - const plan = (subscription.planId ?? "Unknown").replace(/[_-]+/g, " ").trim() - const status = subscription.status ? ` (${subscription.status})` : "" - let renewal = "" - if (subscription.currentPeriodEnd) { - const end = parsePeriodEnd(subscription.currentPeriodEnd) - if (end) { - const diffMs = end.getTime() - now() - const days = Math.ceil(diffMs / 86_400_000) - const dateStr = end.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }) - if (days > 0) { - renewal = ` · renews ${dateStr} (${days}d)` - } else if (days === 0) { - renewal = ` · renews ${dateStr} (today)` - } else { - renewal = ` · renewed ${dateStr}` - } + if (quota.subscription) { + const plan = quota.subscription.planId ?? "unknown" + const status = quota.subscription.status ? ` (${quota.subscription.status})` : "" + lines.push(`Plan: ${plan}${status}`) + const start = formatDate(quota.subscription.currentPeriodStart) + const end = formatDate(quota.subscription.currentPeriodEnd) + if (start && end) lines.push(`Period: ${start} → ${end}`) + } else { + lines.push("Plan: unavailable") + } + + if (quota.credits) { + const { monthlyCredits, purchasedCredits, freeCredits, remainingCredits } = quota.credits + lines.push("") + lines.push(`Credits remaining: ${remainingCredits.toFixed(2)}`) + lines.push( + `Sources: monthly ${monthlyCredits.toFixed(2)} · purchased ${purchasedCredits.toFixed(2)} · free ${freeCredits.toFixed(2)}`, + ) + for (const limit of quota.credits.windowLimits) { + const percent = limit.cap > 0 ? Math.round((limit.used / limit.cap) * 100) : 0 + const reset = limit.resetAt === null ? "" : ` (resets ${formatClock(limit.resetAt, now())})` + lines.push( + `${WINDOW_LABELS[limit.window]}: ${limit.used.toFixed(2)} / ${limit.cap.toFixed(2)} credits (${percent}% used)${reset}`, + ) } + } else { + lines.push("") + lines.push("Credits: unavailable") } - return `Plan: ${plan}${status}${renewal}` -} - -function formatTokens(tokens: number): string { - if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B` - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M` - if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k` - return String(tokens) -} - -export function formatQuota(quota: CommandCodeQuota, now: () => number = Date.now): string { - const lines: string[] = [] - const remaining = quota.credits?.remainingCredits ?? 0 - const spent = quota.summary?.totalCost ?? 0 - const pool = remaining + spent - - if (quota.credits || quota.summary) { - lines.push("Credits") - lines.push(` Remaining: $${remaining.toFixed(2)} of $${pool.toFixed(2)}`) - lines.push(` Used: $${spent.toFixed(2)}`) - lines.push(` ${pool > 0 ? Math.round((spent / pool) * 100) : 0}% used`) - } - - const detail = creditsDetail(quota.credits) - if (detail) lines.push(detail) - if (quota.subscription) lines.push(subscriptionLine(quota.subscription, now)) if (quota.summary) { lines.push("") - lines.push(quota.subscription?.currentPeriodStart ? "Usage (billing period)" : "Usage") - lines.push(` Cost: $${quota.summary.totalCost.toFixed(2)}`) - lines.push(` Requests: ${quota.summary.totalCount.toLocaleString("en-US")}`) - if (quota.summary.totalTokens !== undefined) { - lines.push(` Tokens: ${formatTokens(quota.summary.totalTokens)}`) - } + const tokens = quota.summary.totalTokens + lines.push( + `Usage this period: $${quota.summary.totalCost.toFixed(2)} over ${quota.summary.totalCount} requests${tokens === undefined ? "" : ` (${tokens} tokens)`}`, + ) + } else { + lines.push("Usage this period: unavailable") } - lines.push("") - lines.push("Account") - lines.push(` ${quota.account.keyName ?? quota.account.login}`) - - const limits = quota.credits?.windowLimits ?? [] - if (limits.length > 0) { + if (quota.unavailable.length > 0) { + const labels = quota.unavailable.map((section) => UNAVAILABLE_LABELS[section] ?? section) lines.push("") - lines.push("Usage windows:") - lines.push(...formatWindowLimits(limits, now).map((line) => ` ${line}`)) + lines.push(`Unavailable: ${labels.join(", ")}`) } - if ((quota.unavailable?.length ?? 0) > 0) { - lines.push("") - lines.push(`Unavailable: ${quota.unavailable?.join(", ")}`) - } - - lines.push("") - lines.push("Full detail: https://commandcode.ai/usage") return lines.join("\n") } diff --git a/src/quota-types.ts b/src/quota-types.ts deleted file mode 100644 index 07f670e..0000000 --- a/src/quota-types.ts +++ /dev/null @@ -1,47 +0,0 @@ -export interface CommandCodeWindowLimit { - window: "fiveHour" | "weekly" - used: number - cap: number - resetAt: number | null -} - -export interface CommandCodeCredits { - monthlyCredits: number - purchasedCredits: number - freeCredits: number - remainingCredits: number - windowLimits: CommandCodeWindowLimit[] -} - -export interface CommandCodeSubscription { - planId: string | null - status: string | null - currentPeriodStart: string | null - currentPeriodEnd: string | null -} - -export interface CommandCodeUsageSummary { - totalCost: number - totalCount: number - totalTokens?: number -} - -export type CommandCodeQuotaSection = "credits" | "subscription" | "usage" - -export interface CommandCodeQuota { - account: { - login: string - orgId: string | null - keyName?: string - } - credits: CommandCodeCredits | null - subscription: CommandCodeSubscription | null - summary: CommandCodeUsageSummary | null - unavailable?: readonly CommandCodeQuotaSection[] -} - -export type CommandCodeQuotaErrorKind = "config" | "http" | "network" | "timeout" - -export type CommandCodeQuotaResult = - | { ok: true; quota: CommandCodeQuota } - | { ok: false; error: { message: string; kind: CommandCodeQuotaErrorKind } } diff --git a/src/quota.ts b/src/quota.ts index 44bec78..6b80e60 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -1,34 +1,66 @@ +/** + * Command Code account usage for the `/commandcode-quota` command. + * + * Reads the same alpha endpoints the Command Code CLI uses. Sections that the + * account or plan does not expose are reported as unavailable instead of being + * displayed as zero usage. + */ + import { redactCommandCodeErrorText } from "./overflow.ts" -import type { - CommandCodeCredits, - CommandCodeQuotaResult, - CommandCodeQuotaSection, - CommandCodeSubscription, - CommandCodeUsageSummary, - CommandCodeWindowLimit, -} from "./quota-types.ts" export const DEFAULT_API_BASE = "https://api.commandcode.ai" export const QUOTA_TIMEOUT_MS = 15_000 -interface FetchOptions { +export interface QuotaWindowLimit { + window: "fiveHour" | "weekly" + used: number + cap: number + resetAt: number | null +} + +export interface QuotaCredits { + monthlyCredits: number + purchasedCredits: number + freeCredits: number + remainingCredits: number + windowLimits: readonly QuotaWindowLimit[] +} + +export interface QuotaSubscription { + planId: string | null + status: string | null + currentPeriodStart: string | null + currentPeriodEnd: string | null +} + +export interface QuotaSummary { + totalCost: number + totalCount: number + totalTokens?: number +} + +export interface QuotaAccount { + login: string + keyName?: string + orgId: string | null +} + +export interface Quota { + account: QuotaAccount + credits: QuotaCredits | null + subscription: QuotaSubscription | null + summary: QuotaSummary | null + unavailable: readonly string[] +} + +export type QuotaResult = { ok: true; quota: Quota } | { ok: false; error: string } + +export interface FetchQuotaOptions { apiKey: string baseUrl?: string + headers?: Record fetchImpl?: typeof fetch timeoutMs?: number - extraHeaders?: Record -} - -interface HttpErrorShape { - __httpError: true - message: string - status: number - body: string -} - -interface QuotaErrorShape { - __quotaError: true - kind: "timeout" | "network" } function isRecord(value: unknown): value is Record { @@ -43,18 +75,8 @@ function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined } -function timestampValue(value: unknown): string | undefined { - const text = stringValue(value) - if (text) return text - const number = numberValue(value) - return number === undefined ? undefined : String(number) -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function normalizeResetAt(value: unknown): number | null { +/** Command Code sends epoch seconds or ISO timestamps depending on the field. */ +export function normalizeResetAt(value: unknown): number | null { let timestamp: number | undefined if (typeof value === "number" && Number.isFinite(value)) timestamp = value if (typeof value === "string" && value.length > 0) { @@ -65,13 +87,11 @@ function normalizeResetAt(value: unknown): number | null { return timestamp >= 1e12 ? Math.round(timestamp / 1000) : timestamp } -export function windowLimitsFromCredits(value: unknown): CommandCodeWindowLimit[] { +export function parseWindowLimits(value: unknown): QuotaWindowLimit[] { if (!isRecord(value)) return [] - const limits: CommandCodeWindowLimit[] = [] - for (const [window, entry] of [ - ["fiveHour", value.fiveHour], - ["weekly", value.weekly], - ] as const) { + const limits: QuotaWindowLimit[] = [] + for (const window of ["fiveHour", "weekly"] as const) { + const entry = value[window] if (!isRecord(entry)) continue const used = numberValue(entry.used) const cap = numberValue(entry.cap) @@ -81,7 +101,7 @@ export function windowLimitsFromCredits(value: unknown): CommandCodeWindowLimit[ return limits } -function parseCredits(value: unknown): CommandCodeCredits | null { +export function parseCredits(value: unknown): QuotaCredits | null { if (!isRecord(value) || !isRecord(value.credits)) return null const credits = value.credits const monthlyCredits = numberValue(credits.monthlyCredits) @@ -98,17 +118,17 @@ function parseCredits(value: unknown): CommandCodeCredits | null { purchasedCredits: purchased, freeCredits: free, remainingCredits: monthly + purchased + free, - windowLimits: windowLimitsFromCredits(value.windowLimits), + windowLimits: parseWindowLimits(value.windowLimits), } } -function parseSubscription(value: unknown): CommandCodeSubscription | null { +export function parseSubscription(value: unknown): QuotaSubscription | null { if (!isRecord(value) || !isRecord(value.data)) return null const data = value.data const planId = stringValue(data.planId) const status = stringValue(data.status) - const currentPeriodStart = timestampValue(data.currentPeriodStart) - const currentPeriodEnd = timestampValue(data.currentPeriodEnd) + const currentPeriodStart = stringValue(data.currentPeriodStart) ?? undefined + const currentPeriodEnd = stringValue(data.currentPeriodEnd) ?? undefined if (!planId && !status && !currentPeriodStart && !currentPeriodEnd) return null return { planId: planId ?? null, @@ -118,7 +138,7 @@ function parseSubscription(value: unknown): CommandCodeSubscription | null { } } -function parseSummary(value: unknown): CommandCodeUsageSummary | null { +export function parseSummary(value: unknown): QuotaSummary | null { if (!isRecord(value)) return null const totalCost = numberValue(value.totalCost) const totalCount = numberValue(value.totalCount) @@ -127,11 +147,7 @@ function parseSummary(value: unknown): CommandCodeUsageSummary | null { return { totalCost, totalCount, ...(totalTokens === undefined ? {} : { totalTokens }) } } -function parseWhoami(value: unknown): { - login: string - orgId: string | null - keyName?: string -} | null { +export function parseAccount(value: unknown): QuotaAccount | null { if (!isRecord(value)) return null const org = isRecord(value.org) ? value.org : undefined const user = isRecord(value.user) ? value.user : undefined @@ -139,9 +155,12 @@ function parseWhoami(value: unknown): { (org ? stringValue(org.login) : undefined) ?? (user ? (stringValue(user.userName) ?? stringValue(user.name)) : undefined) if (!login) return null - const orgId = org ? stringValue(org.id) : undefined const keyName = user ? (stringValue(user.keyName) ?? stringValue(user.displayName)) : undefined - return { login, orgId: orgId ?? null, ...(keyName ? { keyName } : {}) } + return { + login, + orgId: (org ? stringValue(org.id) : undefined) ?? null, + ...(keyName ? { keyName } : {}), + } } function buildUrl(path: string, params: Record): string { @@ -150,186 +169,7 @@ function buildUrl(path: string, params: Record): str if (value) search.set(key, value) } const query = search.toString() - return `${path}${query ? `?${query}` : ""}` -} - -function isHttpError(value: unknown): value is HttpErrorShape { - return ( - isRecord(value) && - value.__httpError === true && - typeof value.message === "string" && - typeof value.status === "number" && - typeof value.body === "string" - ) -} - -function isQuotaError(value: unknown): value is QuotaErrorShape { - return ( - isRecord(value) && - value.__quotaError === true && - (value.kind === "timeout" || value.kind === "network") - ) -} - -function isBlockingHttpError(error: HttpErrorShape): boolean { - return error.status === 401 || error.status === 403 -} - -function httpFailure(error: HttpErrorShape, context: string): CommandCodeQuotaResult { - const detail = error.body.trim().slice(0, 200) - return { - ok: false, - error: { - kind: "http", - message: redactValue( - `${context} request failed (${error.status}): ${detail || error.message}`, - ), - }, - } -} - -class QuotaTimeoutError extends Error {} - -export async function fetchCommandCodeQuota( - options: FetchOptions, -): Promise { - if (!options.apiKey) { - return { ok: false, error: { message: "No Command Code API key found", kind: "config" } } - } - - const baseUrl = options.baseUrl ?? DEFAULT_API_BASE - const fetchImpl = options.fetchImpl ?? fetch - const timeoutMs = options.timeoutMs ?? QUOTA_TIMEOUT_MS - const overallController = new AbortController() - const overallTimer = setTimeout(() => overallController.abort(), timeoutMs) - const headers = { - accept: "application/json", - Authorization: `Bearer ${options.apiKey}`, - ...options.extraHeaders, - } - - const request = async (path: string): Promise => { - if (overallController.signal.aborted) throw new QuotaTimeoutError() - try { - const response = await fetchImpl(`${baseUrl}${path}`, { - method: "GET", - headers, - signal: overallController.signal, - }) - if (!response.ok) { - return { - __httpError: true, - message: - response.status === 401 || response.status === 403 - ? "Command Code rejected the API key" - : response.statusText, - status: response.status, - body: await response.text().catch(() => ""), - } satisfies HttpErrorShape - } - return await response.json() - } catch (error) { - if (overallController.signal.aborted) throw new QuotaTimeoutError() - throw error - } - } - - const safeRequest = async (path: string): Promise => { - try { - return await request(path) - } catch (error) { - return { - __quotaError: true, - kind: error instanceof QuotaTimeoutError ? "timeout" : "network", - } satisfies QuotaErrorShape - } - } - - try { - const whoamiRaw = await request("/alpha/whoami") - if (isHttpError(whoamiRaw)) return httpFailure(whoamiRaw, "whoami") - const account = parseWhoami(whoamiRaw) - if (!account) { - return { - ok: false, - error: { kind: "http", message: "Command Code returned an unrecognized account response" }, - } - } - - const orgId = account.orgId ?? undefined - const [creditsRaw, subscriptionRaw] = await Promise.all([ - safeRequest(buildUrl("/alpha/billing/credits", { orgId })), - safeRequest(buildUrl("/alpha/billing/subscriptions", { orgId })), - ]) - if (isHttpError(creditsRaw) && isBlockingHttpError(creditsRaw)) { - return httpFailure(creditsRaw, "credits") - } - if (isHttpError(subscriptionRaw) && isBlockingHttpError(subscriptionRaw)) { - return httpFailure(subscriptionRaw, "subscription") - } - - const unavailable: CommandCodeQuotaSection[] = [] - const credits = - isHttpError(creditsRaw) || isQuotaError(creditsRaw) ? null : parseCredits(creditsRaw) - if (!credits) unavailable.push("credits") - const subscription = - isHttpError(subscriptionRaw) || isQuotaError(subscriptionRaw) - ? null - : parseSubscription(subscriptionRaw) - if (!subscription) unavailable.push("subscription") - - const summaryRaw = await safeRequest( - buildUrl("/alpha/usage/summary", { - orgId, - since: subscription?.currentPeriodStart ?? undefined, - }), - ) - if (isHttpError(summaryRaw) && isBlockingHttpError(summaryRaw)) { - return httpFailure(summaryRaw, "summary") - } - const summary = - isHttpError(summaryRaw) || isQuotaError(summaryRaw) ? null : parseSummary(summaryRaw) - if (!summary) unavailable.push("usage") - - if (!credits && !subscription && !summary) { - return { - ok: false, - error: { - kind: overallController.signal.aborted ? "timeout" : "http", - message: overallController.signal.aborted - ? "Command Code quota request timed out" - : "Command Code returned no recognized usage data for the account", - }, - } - } - - return { - ok: true, - quota: { - account, - credits, - subscription, - summary, - ...(unavailable.length > 0 ? { unavailable } : {}), - }, - } - } catch (error) { - if (error instanceof QuotaTimeoutError || overallController.signal.aborted) { - return { - ok: false, - error: { message: "Command Code quota request timed out", kind: "timeout" }, - } - } - return { - ok: false, - error: { - message: redactValue(`Failed to fetch Command Code quota: ${errorMessage(error)}`), - kind: "network", - }, - } - } finally { - clearTimeout(overallTimer) - } + return query ? `${path}?${query}` : path } export function redactValue(value: string): string { @@ -340,3 +180,114 @@ export function redactValue(value: string): string { ) .trim() } + +interface HttpResponse { + status: number + ok: boolean + body: unknown + text: string +} + +async function getJson( + url: string, + headers: Record, + fetchImpl: typeof fetch, + signal: AbortSignal, +): Promise { + const response = await fetchImpl(url, { method: "GET", headers, signal }) + if (!response.ok) { + const text = await response.text().catch(() => "") + return { status: response.status, ok: false, body: undefined, text } + } + const text = await response.text().catch(() => "") + let body: unknown + try { + body = JSON.parse(text) + } catch { + body = undefined + } + return { status: response.status, ok: true, body, text } +} + +export async function fetchCommandCodeQuota(options: FetchQuotaOptions): Promise { + if (!options.apiKey) { + return { ok: false, error: "No Command Code API key found" } + } + + const baseUrl = options.baseUrl ?? DEFAULT_API_BASE + const fetchImpl = options.fetchImpl ?? fetch + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new Error("Quota request timed out")), options.timeoutMs ?? QUOTA_TIMEOUT_MS) + const headers = { + accept: "application/json", + Authorization: `Bearer ${options.apiKey}`, + ...options.headers, + } + + const request = (path: string, params: Record = {}) => + getJson(`${baseUrl}${buildUrl(path, params)}`, headers, fetchImpl, controller.signal) + + try { + const whoami = await request("/alpha/whoami") + if (whoami.status === 401 || whoami.status === 403) { + return { ok: false, error: "Command Code rejected the API key" } + } + if (!whoami.ok) { + return { ok: false, error: redactValue(`whoami request failed (${whoami.status})`) } + } + const account = parseAccount(whoami.body) + if (!account) { + return { ok: false, error: "Command Code returned an unrecognized account response" } + } + + const orgId = account.orgId ?? undefined + const unavailable: string[] = [] + const section = async ( + name: string, + parse: (value: unknown) => T | null, + response: HttpResponse, + ): Promise => { + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + unavailable.push(name) + return null + } + unavailable.push(name) + return null + } + const parsed = parse(response.body) + if (!parsed) unavailable.push(name) + return parsed + } + + const [creditsResponse, subscriptionResponse] = await Promise.all([ + request("/alpha/billing/credits", { orgId }), + request("/alpha/billing/subscriptions", { orgId }), + ]) + const credits = await section("credits", parseCredits, creditsResponse) + const subscription = await section("subscription", parseSubscription, subscriptionResponse) + + const summaryResponse = await request("/alpha/usage/summary", { + orgId, + since: subscription?.currentPeriodStart ?? undefined, + }) + const summary = await section("usage", parseSummary, summaryResponse) + + if (!credits && !subscription && !summary) { + return { ok: false, error: "Command Code returned no recognized usage data for the account" } + } + + return { ok: true, quota: { account, credits, subscription, summary, unavailable } } + } catch (error) { + const timedOut = controller.signal.aborted + const message = error instanceof Error ? error.message : String(error) + return { + ok: false, + error: redactValue( + timedOut ? "Command Code quota request timed out" : `Quota request failed: ${message}`, + ), + } + } finally { + clearTimeout(timer) + } +} diff --git a/src/runtime.ts b/src/runtime.ts deleted file mode 100644 index eb5e842..0000000 --- a/src/runtime.ts +++ /dev/null @@ -1,320 +0,0 @@ -import type { CommandCodeModel, LoadCommandCodeModelsResult } from "./models.ts" - -export interface CommandCodeUi { - notify(message: string, type?: "info" | "warning" | "error"): void -} - -export interface CommandCodeCommandContext { - ui: CommandCodeUi - waitForIdle?: () => Promise -} - -export interface CommandCodeRuntimeApi< - TProviderConfig, - TContext extends CommandCodeCommandContext, -> { - registerProvider(name: string, config: TProviderConfig): void - registerCommand( - name: string, - options: { - description: string - handler: (args: string, ctx: TContext) => Promise - }, - ): void -} - -export interface CommandCodeRuntimeOptions { - endpoint: string - cachePath: string - loadModels: (signal: AbortSignal) => Promise - /** Cached catalog only; resolves to an empty list when no valid cache exists. */ - loadCachedModels: () => Promise - createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig - getTransport?: () => "unknown" | "provider" | "generate" - now?: () => number - logWarning?: (message: string) => void -} - -export interface CommandCodeRuntimeStatus { - transport: "unknown" | "provider" | "generate" - source: LoadCommandCodeModelsResult["source"] - modelCount: number - lastSuccess?: number - lastAttempt?: number - cachePath: string - endpoint: string - warning?: string - refreshing: boolean -} - -export interface CommandCodeRefreshResult { - refreshed: boolean - source: CommandCodeRuntimeStatus["source"] - modelCount: number - warning?: string -} - -const REDACTED = "[redacted]" - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function redactUrl(value: string): string { - try { - const url = new URL(value) - return `${url.protocol}//${url.host}${url.pathname}` - } catch { - return REDACTED - } -} - -export function redactDiagnosticText(value: string): string { - const redactedUrls = value.replace(/https?:\/\/[^\s)]+/gi, (match) => redactUrl(match)) - return redactedUrls - .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`) - .replace(/\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi, REDACTED) - .replace(/\b(?:api[-_ ]?key|token|secret|password)\s*[=:]\s*[^\s,;)]+/gi, (match) => { - const separator = match.match(/\s*[=:]\s*/)?.[0] ?? "=" - return `${match.slice(0, match.indexOf(separator))}${separator}${REDACTED}` - }) -} - -export function redactEndpoint(value: string): string { - return redactUrl(value) -} - -function formatTimestamp(timestamp: number | undefined): string { - return timestamp === undefined ? "never" : new Date(timestamp).toISOString() -} - -export function formatCommandCodeStatus(status: CommandCodeRuntimeStatus): string { - const lines = [ - `transport: ${status.transport}`, - `source: ${status.source}`, - `model count: ${status.modelCount}`, - `last success: ${formatTimestamp(status.lastSuccess)}`, - `last attempt: ${formatTimestamp(status.lastAttempt)}`, - `cache path: ${status.cachePath}`, - `endpoint: ${redactEndpoint(status.endpoint)}`, - `refresh: ${status.refreshing ? "in progress" : "idle"}`, - ] - - lines.push(`warning: ${status.warning ? redactDiagnosticText(status.warning) : "none"}`) - return lines.join("\n") -} - -export class CommandCodeRuntime { - private readonly now: () => number - private readonly logWarning: (message: string) => void - private status: CommandCodeRuntimeStatus - private providerRegistered = false - private refreshPromise: Promise | undefined - private readonly shutdown = new AbortController() - - constructor( - private readonly pi: CommandCodeRuntimeApi, - private readonly options: CommandCodeRuntimeOptions, - ) { - this.now = options.now ?? Date.now - this.logWarning = options.logWarning ?? ((message) => console.warn(`[commandcode] ${message}`)) - const initialStatus: CommandCodeRuntimeStatus = { - transport: "unknown", - source: "empty", - modelCount: 0, - cachePath: options.cachePath, - endpoint: options.endpoint, - refreshing: false, - } - this.status = { ...initialStatus } - } - - getStatus(): CommandCodeRuntimeStatus { - return { - ...this.status, - transport: this.options.getTransport?.() ?? "unknown", - } - } - - /** - * Registers the cached catalog immediately and refreshes it in the - * background so host startup does not wait for the network. Without a - * valid cache the live refresh is awaited so models are available at once. - */ - async initialize(): Promise { - this.registerCommands() - - const cached = await this.options.loadCachedModels() - if (cached.length === 0) { - await this.refresh() - return - } - - this.pi.registerProvider("commandcode", this.options.createProviderConfig(cached)) - this.providerRegistered = true - this.status = { - ...this.status, - source: "cache", - modelCount: cached.length, - lastSuccess: this.now(), - } - void this.refresh() - } - - /** Aborts any background refresh so a stopping host does not wait for the network. */ - dispose(): void { - this.shutdown.abort(new Error("Command Code provider shut down")) - } - - refresh(): Promise { - if (this.refreshPromise) return this.refreshPromise - - const refreshPromise = this.refreshCatalog().finally(() => { - if (this.refreshPromise === refreshPromise) this.refreshPromise = undefined - }) - this.refreshPromise = refreshPromise - return refreshPromise - } - - private async refreshCatalog(): Promise { - this.status = { - ...this.status, - lastAttempt: this.now(), - refreshing: true, - } - - try { - const loaded = await this.options.loadModels(this.shutdown.signal) - const warning = loaded.warning ? redactDiagnosticText(loaded.warning) : undefined - - const shouldRegister = - !this.providerRegistered || - loaded.source === "live" || - (this.status.modelCount === 0 && loaded.models.length > 0) - - if (shouldRegister) { - this.pi.registerProvider("commandcode", this.options.createProviderConfig(loaded.models)) - this.providerRegistered = true - - if (loaded.models.length === 0) { - const preservedWarning = warning ?? "Model catalog refresh returned no models" - this.status = { - ...this.status, - source: loaded.source, - modelCount: 0, - warning: preservedWarning, - refreshing: false, - } - this.warn(preservedWarning) - return { - refreshed: false, - source: loaded.source, - modelCount: 0, - warning: preservedWarning, - } - } - - this.status = { - ...this.status, - source: loaded.source, - modelCount: loaded.models.length, - lastSuccess: this.now(), - warning, - refreshing: false, - } - if (warning) this.warn(warning) - return { - refreshed: true, - source: loaded.source, - modelCount: loaded.models.length, - warning, - } - } - - const preservedWarning = warning ?? "Model catalog refresh returned no models" - this.status = { - ...this.status, - warning: preservedWarning, - refreshing: false, - } - this.warn(preservedWarning) - return { - refreshed: false, - source: this.status.source, - modelCount: this.status.modelCount, - warning: preservedWarning, - } - } catch (error) { - if (this.shutdown.signal.aborted) { - this.status = { ...this.status, refreshing: false } - return { - refreshed: false, - source: this.status.source, - modelCount: this.status.modelCount, - } - } - const warning = redactDiagnosticText( - `Could not refresh the Command Code model catalog: ${errorMessage(error)}`, - ) - this.status = { - ...this.status, - warning, - refreshing: false, - } - this.warn(warning) - return { - refreshed: false, - source: this.status.source, - modelCount: this.status.modelCount, - warning, - } - } - } - - private warn(message: string): void { - try { - this.logWarning(redactDiagnosticText(message)) - } catch { - // Diagnostics must never make a catalog refresh fail. - } - } - - private registerCommands(): void { - this.pi.registerCommand("commandcode-refresh", { - description: "Refresh the Command Code model catalog", - handler: async (_args, ctx) => { - await ctx.waitForIdle?.() - const result = await this.refresh() - if (result.refreshed) { - ctx.ui.notify( - `Command Code model catalog refreshed (${result.modelCount} models from ${result.source}).`, - "info", - ) - } else { - ctx.ui.notify( - `Command Code model catalog unchanged (${result.modelCount} models remain available).${result.warning ? ` ${result.warning}` : ""}`, - "warning", - ) - } - }, - }) - - this.pi.registerCommand("commandcode-status", { - description: "Show redacted Command Code provider diagnostics", - handler: async (_args, ctx) => { - const status = this.getStatus() - ctx.ui.notify(formatCommandCodeStatus(status), status.warning ? "warning" : "info") - }, - }) - } -} - -export function createCommandCodeRuntime< - TProviderConfig, - TContext extends CommandCodeCommandContext, ->( - pi: CommandCodeRuntimeApi, - options: CommandCodeRuntimeOptions, -): CommandCodeRuntime { - return new CommandCodeRuntime(pi, options) -} diff --git a/src/transport.ts b/src/transport.ts deleted file mode 100644 index c30171c..0000000 --- a/src/transport.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { - AssistantMessageEvent, - AssistantMessageEventStreamLike, - ContextLike, - ModelLike, - StreamOptions, -} from "./types.ts" - -export type CommandCodeTransport = "unknown" | "provider" | "generate" - -interface TransportDependencies { - createStream: () => AssistantMessageEventStreamLike - streamProvider: ( - model: ModelLike, - context: ContextLike, - options?: StreamOptions, - ) => AssistantMessageEventStreamLike - streamGenerate: ( - model: ModelLike, - context: ContextLike, - options?: StreamOptions, - ) => AssistantMessageEventStreamLike -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -async function isUpgradeRequired(response: Response): Promise { - if (response.status !== 403) return false - - try { - const body: unknown = await response.clone().json() - if (!isRecord(body)) return false - const error = isRecord(body.error) ? body.error : body - return error.code === "upgrade_required" - } catch { - return false - } -} - -export function createCommandCodeTransportRouter(deps: TransportDependencies) { - let transport: CommandCodeTransport = "unknown" - let apiKey: string | undefined - - function pipe( - source: AssistantMessageEventStreamLike, - target: AssistantMessageEventStreamLike, - ): Promise { - return (async () => { - for await (const event of source) target.push(event) - })() - } - - return { - getTransport(): CommandCodeTransport { - return transport - }, - - reset(): void { - transport = "unknown" - apiKey = undefined - }, - - stream( - model: ModelLike, - context: ContextLike, - options?: StreamOptions, - ): AssistantMessageEventStreamLike { - if (options?.apiKey !== apiKey) { - apiKey = options?.apiKey - transport = "unknown" - } - const requestApiKey = options?.apiKey - if (transport === "generate") return deps.streamGenerate(model, context, options) - - const output = deps.createStream() - let upgradeRequired = false - const fetchImpl = options?.fetch ?? fetch - const providerOptions: StreamOptions = { - ...options, - fetch: async (input, init) => { - const response = await fetchImpl(input, init) - if (await isUpgradeRequired(response)) upgradeRequired = true - return response - }, - onResponse: async (response, responseModel) => { - if (upgradeRequired) return - await options?.onResponse?.(response, responseModel) - }, - } - - const run = async () => { - const providerStream = deps.streamProvider(model, context, providerOptions) - - for await (const event of providerStream) { - if (!upgradeRequired) { - if (apiKey === requestApiKey) transport = "provider" - output.push(event) - } - } - - if (upgradeRequired) { - if (apiKey === requestApiKey) transport = "generate" - await pipe(deps.streamGenerate(model, context, options), output) - } - output.end() - } - - run().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error) - output.push({ - type: "error", - reason: "error", - error: { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "error", - errorMessage: message, - timestamp: Date.now(), - }, - }) - output.end() - }) - - return output - }, - } -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index d10f823..0000000 --- a/src/types.ts +++ /dev/null @@ -1,210 +0,0 @@ -export type StopReason = "stop" | "length" | "toolUse" -export type ErrorReason = "error" | "aborted" -export type TerminalReason = StopReason | ErrorReason - -export interface UsageCost { - input: number - output: number - cacheRead: number - cacheWrite: number - total: number -} - -export interface Usage { - input: number - output: number - cacheRead: number - cacheWrite: number - cacheWrite1h?: number - totalTokens: number - cost: UsageCost -} - -export interface TextContent { - type: "text" - text: string -} - -export interface ThinkingContent { - type: "thinking" - thinking: string -} - -export interface ToolCallContent { - type: "toolCall" - id: string - name: string - arguments: Record -} - -export type AssistantContent = TextContent | ThinkingContent | ToolCallContent - -export interface AssistantMessageLike { - role: "assistant" - content: AssistantContent[] - api: unknown - provider: string - model: string - usage: Usage - stopReason: TerminalReason - errorMessage?: string - timestamp: number -} - -export interface ModelCostRates { - input: number - output: number - cacheRead: number - cacheWrite: number -} - -export interface ModelCostTier extends ModelCostRates { - inputTokensAbove: number -} - -export interface ModelCost extends ModelCostRates { - tiers?: readonly ModelCostTier[] -} - -export interface ModelLike { - id: string - api: unknown - provider: string - maxTokens: number - cost: ModelCost - reasoning?: boolean - thinkingLevelMap?: Partial> - thinking?: { - mode?: "effort" - effortMap?: Partial> - efforts?: readonly string[] - } -} - -export interface MessageLike { - role: string - content?: unknown - toolCallId?: string - toolName?: string - isError?: boolean -} - -export interface ToolLike { - name: string - description?: string - parameters?: unknown -} - -export interface ContextLike { - systemPrompt?: string - messages?: readonly MessageLike[] - tools?: readonly ToolLike[] -} - -export interface ProviderResponseInfo { - status: number - headers: Record -} - -export interface StreamOptions { - apiKey?: string - signal?: AbortSignal - headers?: Record - fetch?: typeof fetch - maxTokens?: number - temperature?: number - sessionId?: string - /** Resolved pi thinking level; forwarded only through the model's map. */ - reasoning?: string - onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise - onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise - /** - * HTTP request timeout in milliseconds. - * Applied per-attempt; on timeout the request is retried if retries remain. - */ - timeoutMs?: number - /** - * Maximum retry attempts for transient HTTP errors (429, 5xx). - * Default: 0 (pi agent-level retry handles visible retries when unset). - */ - maxRetries?: number - /** - * Maximum delay in milliseconds to wait for a retry when the server requests - * a long wait via Retry-After. If the server's requested delay exceeds this - * value, the request fails immediately. Default: 60000 (60 seconds). - * Set to 0 to disable the cap. - */ - maxRetryDelayMs?: number -} - -export type AssistantMessageEvent = - | { type: "start"; partial: AssistantMessageLike } - | { type: "text_start"; contentIndex: number; partial: AssistantMessageLike } - | { - type: "text_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "text_end" - contentIndex: number - content: string - partial: AssistantMessageLike - } - | { - type: "thinking_start" - contentIndex: number - partial: AssistantMessageLike - } - | { - type: "thinking_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "thinking_end" - contentIndex: number - content: string - partial: AssistantMessageLike - } - | { - type: "toolcall_start" - contentIndex: number - partial: AssistantMessageLike - } - | { - type: "toolcall_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "toolcall_end" - contentIndex: number - toolCall: ToolCallContent - partial: AssistantMessageLike - } - | { type: "done"; reason: StopReason; message: AssistantMessageLike } - | { type: "error"; reason: ErrorReason; error: AssistantMessageLike } - -export interface AssistantMessageEventStreamLike extends AsyncIterable { - push(event: AssistantMessageEvent): void - end(): void -} - -export interface CoreDependencies { - createStream: () => AssistantMessageEventStreamLike - calculateCost: (model: ModelLike, usage: Usage) => void - apiBase?: string - fetchImpl?: typeof fetch - authPaths?: readonly string[] - env?: NodeJS.ProcessEnv - cwd?: () => string - now?: () => number - uuid?: () => string - homeDir?: () => string - /** Injectable delay for retry backoff. Defaults to setTimeout. */ - delay?: (ms: number, signal: AbortSignal) => Promise -} diff --git a/tests/auth/auth-server.test.ts b/tests/auth/auth-server.test.ts new file mode 100644 index 0000000..3a1e9fb --- /dev/null +++ b/tests/auth/auth-server.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { startAuthServer } from "../../src/auth-server.ts" + +const STATE = "state-token-123" + +function callbackUrl(port: number): string { + return `http://127.0.0.1:${port}/callback` +} + +test("callback POST completes the login and closes the server", async () => { + const server = await startAuthServer({ expectedState: STATE, startPort: 0, portRange: 0 }) + + const response = await fetch(callbackUrl(server.port), { + method: "POST", + headers: { "content-type": "application/json", origin: "https://commandcode.ai" }, + body: JSON.stringify({ apiKey: "user_abc", state: STATE, userId: "u1", userName: "tester", keyName: "cli" }), + }) + + assert.equal(response.status, 200) + assert.equal(response.headers.get("access-control-allow-origin"), "https://commandcode.ai") + assert.deepEqual(await server.waitForCallback, { apiKey: "user_abc", state: STATE }) +}) + +test("browser preflight is answered for the Command Code origin", async () => { + const server = await startAuthServer({ expectedState: STATE, startPort: 0, portRange: 0 }) + + try { + const response = await fetch(callbackUrl(server.port), { + method: "OPTIONS", + headers: { + origin: "https://commandcode.ai", + "access-control-request-headers": "content-type", + }, + }) + + assert.equal(response.status, 204) + assert.equal(response.headers.get("access-control-allow-private-network"), "true") + assert.equal(response.headers.get("access-control-allow-headers"), "content-type") + } finally { + server.close() + } +}) + +test("a mismatched state token is rejected without completing the login", async () => { + const server = await startAuthServer({ expectedState: STATE, startPort: 0, portRange: 0 }) + let settled = false + void server.waitForCallback.then(() => { + settled = true + }) + + try { + const response = await fetch(callbackUrl(server.port), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey: "user_abc", state: "other-state" }), + }) + + assert.equal(response.status, 403) + assert.equal(settled, false) + } finally { + server.close() + } +}) + +test("an access_denied payload fails the wait instead of hanging", async () => { + const server = await startAuthServer({ expectedState: STATE, startPort: 0, portRange: 0 }) + + const rejection = assert.rejects(server.waitForCallback, /User cancelled/) + await fetch(callbackUrl(server.port), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ error: "access_denied", error_description: "User cancelled" }), + }) + + await rejection +}) diff --git a/tests/auth/auth.test.ts b/tests/auth/auth.test.ts new file mode 100644 index 0000000..d3e3df6 --- /dev/null +++ b/tests/auth/auth.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai" + +import { + credentialsFromApiKey, + getApiKey, + login, + refreshToken, + sanitizeApiKey, + validateApiKey, +} from "../../src/auth.ts" + +/** Records the callback surface pi supplies so flows can be driven deterministically. */ +function createCallbacks(options: { + select?: string | undefined + prompt: string | string[] +}): { callbacks: OAuthLoginCallbacks; authUrls: string[]; prompts: string[] } { + const authUrls: string[] = [] + const prompts: string[] = [] + const answers = Array.isArray(options.prompt) ? [...options.prompt] : [options.prompt] + + return { + authUrls, + prompts, + callbacks: { + onAuth: (info) => authUrls.push(info.url), + onDeviceCode: () => {}, + onSelect: async () => options.select, + onPrompt: async (prompt) => { + prompts.push(prompt.message) + const answer = answers.shift() + if (answer === undefined) throw new Error("No prompt answer configured") + return answer + }, + }, + } +} + +/** Replaces global fetch for one test and restores it afterwards. */ +async function withFetch(stub: typeof fetch, run: () => Promise): Promise { + const original = globalThis.fetch + globalThis.fetch = stub + try { + await run() + } finally { + globalThis.fetch = original + } +} + +test("sanitizeApiKey removes paste markers, control characters and padding", () => { + const escape = String.fromCharCode(27) + + assert.equal(sanitizeApiKey(` user_abc${escape}[200~def[201~\n`), "user_abcdef") + assert.equal(sanitizeApiKey("user_abc\t\r\n"), "user_abc") +}) + +test("validateApiKey rejects invalid keys and accepts valid ones", async () => { + await assert.rejects( + validateApiKey("user_bad", { fetchImpl: async () => new Response("{}", { status: 401 }) }), + /rejected the API key/, + ) + await assert.rejects( + validateApiKey("user_bad", { fetchImpl: async () => new Response("", { status: 500 }) }), + /\(500\)/, + ) + await validateApiKey("user_good", { + fetchImpl: async (input) => { + assert.equal(String(input), "https://api.commandcode.ai/alpha/whoami") + return new Response(JSON.stringify({ success: true }), { status: 200 }) + }, + }) +}) + +test("credentialsFromApiKey keeps the key valid for a decade", () => { + const credentials = credentialsFromApiKey("user_abc") + + assert.equal(credentials.refresh, "user_abc") + assert.equal(credentials.access, "user_abc") + assert.ok(credentials.expires > Date.now() + 9 * 365 * 24 * 60 * 60 * 1000) + assert.equal(getApiKey(credentials), "user_abc") +}) + +test("refreshToken returns non-expiring credentials unchanged", async () => { + const refreshed = await refreshToken(credentialsFromApiKey("user_abc") as OAuthCredentials) + + assert.equal(refreshed.access, "user_abc") + assert.ok(refreshed.expires > Date.now() + 9 * 365 * 24 * 60 * 60 * 1000) +}) + +test("login with a selected key option prompts and validates the pasted key", async () => { + const { callbacks, prompts } = createCallbacks({ select: "key", prompt: "user_abc" }) + + await withFetch(async () => new Response("{}", { status: 200 }), async () => { + const credentials = await login(callbacks) + assert.equal(credentials.access, "user_abc") + }) + assert.deepEqual(prompts, ["Paste your Command Code API key:"]) +}) + +test("login accepts a pasted key without opening the selector flow", async () => { + const { callbacks } = createCallbacks({ prompt: "user_abc" }) + + await withFetch(async () => new Response("{}", { status: 200 }), async () => { + const credentials = await login(callbacks) + assert.equal(credentials.access, "user_abc") + }) +}) + +test("login rejects a key the account endpoint refuses", async () => { + const { callbacks } = createCallbacks({ prompt: "user_nope" }) + + await withFetch(async () => new Response("{}", { status: 401 }), async () => { + await assert.rejects(login(callbacks), /rejected the API key/) + }) +}) + +test("browser login falls back to a pasted key when the callback never arrives", async () => { + const { callbacks, authUrls, prompts } = createCallbacks({ select: "browser", prompt: "user_abc" }) + process.env.COMMANDCODE_AUTH_TIMEOUT_MS = "30" + + try { + await withFetch(async () => new Response("{}", { status: 200 }), async () => { + const credentials = await login(callbacks) + assert.equal(credentials.access, "user_abc") + }) + } finally { + delete process.env.COMMANDCODE_AUTH_TIMEOUT_MS + } + + assert.equal(authUrls.length, 1) + assert.match(authUrls[0] ?? "", /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?callback=/) + assert.match(prompts.at(-1) ?? "", /Automatic transfer timed out/) +}) diff --git a/tests/extension/provider.test.ts b/tests/extension/provider.test.ts new file mode 100644 index 0000000..c602071 --- /dev/null +++ b/tests/extension/provider.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict" +import { createServer, type Server } from "node:http" +import type { AddressInfo } from "node:net" +import { test } from "node:test" + +import type { RefreshModelsContext } from "@earendil-works/pi-ai" +import type { + ExtensionAPI, + ExtensionCommandContext, + ProviderConfig, +} from "@earendil-works/pi-coding-agent" + +import commandCodeProvider from "../../index.ts" +import { CATALOG } from "../../src/catalog.ts" + +/** Minimal ExtensionAPI stand-in that records what the extension registers. */ +function createStubPi(): { + pi: ExtensionAPI + provider: { id: string; config: ProviderConfig } | undefined + commands: Map Promise> + handlers: Map unknown> +} { + const state = { + pi: undefined as unknown as ExtensionAPI, + provider: undefined as { id: string; config: ProviderConfig } | undefined, + commands: new Map Promise>(), + handlers: new Map unknown>(), + } + + state.pi = { + registerProvider: (id: string, config: ProviderConfig) => { + state.provider = { id, config } + }, + registerCommand: (name: string, options: { handler: (args: string, ctx: ExtensionCommandContext) => Promise }) => { + state.commands.set(name, options.handler) + }, + on: (event: string, handler: (event: unknown, ctx: unknown) => unknown) => { + state.handlers.set(event, handler) + }, + } as unknown as ExtensionAPI + + return state +} + +function createRefreshContext(options: { + allowNetwork: boolean + stored?: { models: unknown[] } +}): { context: RefreshModelsContext; published: unknown[] } { + const published: unknown[] = [] + const context = { + allowNetwork: options.allowNetwork, + signal: new AbortController().signal, + stored: options.stored, + publish: async (publication: unknown) => { + published.push(publication) + return true + }, + } as unknown as RefreshModelsContext + return { context, published } +} + +/** Serves the Provider API catalog from a local port for one test. */ +async function withCatalogServer( + payload: unknown, + run: (url: string) => Promise, +): Promise { + const server: Server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }) + response.end(JSON.stringify(payload)) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const { port } = server.address() as AddressInfo + + try { + await run(`http://127.0.0.1:${port}/provider/v1/models`) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + +test("extension registers the Command Code provider with the generated catalog", () => { + const stub = createStubPi() + commandCodeProvider(stub.pi) + const provider = stub.provider + + assert.equal(provider?.id, "commandcode") + assert.equal(provider?.config.name, "Command Code") + assert.equal(provider?.config.baseUrl, "https://api.commandcode.ai/provider/v1") + assert.equal(provider?.config.apiKey, "$COMMAND_CODE_API_KEY") + assert.equal(provider?.config.models?.length, CATALOG.length) + assert.equal(provider?.config.oauth?.name, "Command Code") + assert.ok(provider?.config.oauth?.isSubscription) + + const claude = provider?.config.models?.find((model) => model.id === "claude-sonnet-4-6") + assert.equal(claude?.api, "anthropic-messages") + assert.equal(claude?.baseUrl, "https://api.commandcode.ai/provider") + + const deepseek = provider?.config.models?.find((model) => model.id === "deepseek/deepseek-v4.1-flash") + assert.equal(deepseek?.api, "openai-completions") + assert.deepEqual(deepseek?.input, ["text", "image"]) +}) + +test("refreshModels merges the live catalog and persists it for offline starts", async () => { + await withCatalogServer( + { + object: "list", + data: [ + { + id: "deepseek/deepseek-v4.1-flash", + object: "model", + name: "DeepSeek V4.1 Flash", + context_length: 1_000_000, + }, + { id: "vendor/fresh", object: "model", name: "Fresh Model", context_length: 64_000 }, + ], + }, + async (url) => { + process.env.COMMANDCODE_MODELS_URL = url + try { + // The endpoint is read when the extension loads, like pi does at startup. + const stub = createStubPi() + commandCodeProvider(stub.pi) + const provider = stub.provider + const { context, published } = createRefreshContext({ allowNetwork: true }) + const models = await provider?.config.refreshModels?.(context) + + assert.deepEqual(models?.map((model) => model.id), [ + "deepseek/deepseek-v4.1-flash", + "vendor/fresh", + ]) + const persisted = published[0] as { persist?: { models: { id: string; provider: string }[] } } + assert.deepEqual(persisted.persist?.models.map((model) => model.id), [ + "deepseek/deepseek-v4.1-flash", + "vendor/fresh", + ]) + assert.equal(persisted.persist?.models[0]?.provider, "commandcode") + } finally { + delete process.env.COMMANDCODE_MODELS_URL + } + }, + ) +}) + +test("refreshModels keeps the generated catalog when the endpoint is unreachable", async () => { + process.env.COMMANDCODE_MODELS_URL = "http://127.0.0.1:1/provider/v1/models" + try { + const stub = createStubPi() + commandCodeProvider(stub.pi) + const { context, published } = createRefreshContext({ allowNetwork: true }) + const models = await stub.provider?.config.refreshModels?.(context) + + assert.equal(models?.length, CATALOG.length) + assert.deepEqual(published, []) + } finally { + delete process.env.COMMANDCODE_MODELS_URL + } +}) + +test("refreshModels restores the persisted catalog without network access", async () => { + const stub = createStubPi() + commandCodeProvider(stub.pi) + const provider = stub.provider + + const stored = [{ id: "stored/model", provider: "commandcode", name: "Stored" }] + const { context } = createRefreshContext({ allowNetwork: false, stored: { models: stored } }) + const models = await provider?.config.refreshModels?.(context) + + assert.deepEqual(models, stored) +}) + +test("message_end rewrites Command Code context overflow errors", () => { + const stub = createStubPi() + commandCodeProvider(stub.pi) + + const result = stub.handlers.get("message_end")?.( + { + message: { + role: "assistant", + provider: "commandcode", + stopReason: "error", + errorMessage: "prompt is too long: 210000 tokens > 200000 maximum", + }, + }, + { model: { provider: "commandcode" } }, + ) as { message?: { errorMessage?: string } } | undefined + + assert.match(result?.message?.errorMessage ?? "", /^context_length_exceeded: /) +}) + +test("/commandcode-quota reports the account snapshot", async () => { + const stub = createStubPi() + commandCodeProvider(stub.pi) + + const originalFetch = globalThis.fetch + const originalKey = process.env.COMMAND_CODE_API_KEY + process.env.COMMAND_CODE_API_KEY = "user_abc" + globalThis.fetch = (async (input: Parameters[0]) => { + const url = String(input) + if (url.includes("/alpha/whoami")) { + return new Response(JSON.stringify({ user: { userName: "shark-cat" } }), { status: 200 }) + } + if (url.includes("/alpha/billing/credits")) { + return new Response( + JSON.stringify({ credits: { monthlyCredits: 12.5, purchasedCredits: 0, freeCredits: 0 } }), + { status: 200 }, + ) + } + if (url.includes("/alpha/billing/subscriptions")) { + return new Response(JSON.stringify({ data: { planId: "go", status: "active" } }), { status: 200 }) + } + return new Response(JSON.stringify({ totalCost: 1.23, totalCount: 45 }), { status: 200 }) + }) as typeof fetch + + const notifications: { message: string; type?: string }[] = [] + const ctx = { + waitForIdle: async () => {}, + ui: { notify: (message: string, type?: string) => notifications.push({ message, type }) }, + } as unknown as ExtensionCommandContext + + try { + await stub.commands.get("commandcode-quota")?.("", ctx) + } finally { + globalThis.fetch = originalFetch + if (originalKey === undefined) delete process.env.COMMAND_CODE_API_KEY + else process.env.COMMAND_CODE_API_KEY = originalKey + } + + assert.equal(notifications.length, 1) + assert.equal(notifications[0]?.type, "info") + assert.match(notifications[0]?.message ?? "", /Account: shark-cat/) + assert.match(notifications[0]?.message ?? "", /Credits remaining: 12\.50/) +}) diff --git a/tests/fixtures/advisory-injector-extension.ts b/tests/fixtures/advisory-injector-extension.ts deleted file mode 100644 index f88d5ed..0000000 --- a/tests/fixtures/advisory-injector-extension.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Minimal OMP extension used by tests/test-omp-compat.mjs. - * - * Appends a custom advisor message to the session on session start, mirroring - * how the OMP Advisor runtime injects steering notes. OMP converts custom - * messages to `role: "developer"` LLM messages before handing them to the - * provider. Types are declared inline so the fixture has no runtime or - * typecheck dependency on OMP packages. - */ - -interface AdvisorInjectorSendMessage { - ( - message: { - customType: string - content: string - display: boolean - attribution: string - }, - options?: { triggerTurn?: boolean }, - ): void -} - -interface AdvisorInjectorApi { - on: (event: "session_start", handler: () => void | Promise) => void - sendMessage: AdvisorInjectorSendMessage -} - -export default function advisoryInjectorExtension(pi: AdvisorInjectorApi): void { - pi.on("session_start", async () => { - pi.sendMessage( - { - customType: "advisor", - content: - '\nStop and correct the benchmark.\n', - display: true, - attribution: "agent", - }, - { triggerTurn: false }, - ) - }) -} diff --git a/tests/fixtures/commandcode-model-ids.json b/tests/fixtures/commandcode-model-ids.json deleted file mode 100644 index a6f9aa7..0000000 --- a/tests/fixtures/commandcode-model-ids.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "fetchedAt": "2026-09-01T21:28:23.974Z", - "source": "https://api.commandcode.ai/provider/v1/models", - "modelIds": [ - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-fable-5-1", - "claude-fable-5", - "claude-opus-5", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-haiku-4-5-20251001", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.4", - "gpt-5.3-codex", - "gpt-5.4-mini", - "deepseek/deepseek-v4-pro", - "deepseek/deepseek-v4-flash", - "deepseek/deepseek-v4-flash-vision-exp", - "deepseek/deepseek-v4-flash-fast", - "moonshotai/Kimi-K3", - "moonshotai/Kimi-K2.7-Code", - "moonshotai/Kimi-K2.7-Code-Highspeed", - "moonshotai/Kimi-K2.6", - "moonshotai/Kimi-K2.5", - "z-ai/glm-5.3-flash", - "zai-org/GLM-5.3", - "zai-org/GLM-5.2", - "zai-org/GLM-5.2-Fast", - "zai-org/GLM-5.1", - "zai-org/GLM-5", - "MiniMaxAI/MiniMax-M3", - "MiniMaxAI/MiniMax-M2.7", - "MiniMaxAI/MiniMax-M2.5", - "xiaomi/mimo-v2.5-pro", - "xiaomi/mimo-v2.5", - "Qwen/Qwen3.8-Max", - "Qwen/Qwen3.8-27B", - "Qwen/Qwen3.8-Flash", - "Qwen/Qwen3.7-Max", - "Qwen/Qwen3.7-Plus", - "Qwen/Qwen3.7-Flash", - "Qwen/Qwen3.6-Max-Preview", - "Qwen/Qwen3.6-Plus", - "stepfun/Step-3.7-Flash", - "stepfun/Step-3.5-Flash", - "tencent/hy3-paid", - "tencent/hy4-preview", - "google/gemini-3.7-flash", - "google/gemini-3.6-flash", - "google/gemini-3.5-flash", - "google/gemini-3.5-flash-lite", - "google/gemini-3.1-flash-lite", - "sakana/fugu-ultra", - "nvidia/nemotron-3-ultra-550b-a55b", - "thinkingmachines/inkling", - "thinkingmachines/inkling-small", - "poolside/laguna-s-2.1-free", - "meta/muse-spark-1.1", - "meta/muse-spark-1.2", - "meta/muse-spark-1.2-contributor", - "xai/grok-4.5", - "xai/grok-4.6" - ] -} diff --git a/tests/fixtures/commandcode-pricing.json b/tests/fixtures/commandcode-pricing.json deleted file mode 100644 index 0fe4534..0000000 --- a/tests/fixtures/commandcode-pricing.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "verifiedAt": "2026-09-01", - "source": "https://commandcode.ai/docs/resources/pricing-limits", - "tierPolicy": "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.", - "tiers": { - "Qwen/Qwen3.7-Plus": [[256000, 1.2, 4.8, 0.24, 1.5]], - "Qwen/Qwen3.7-Flash": [ - [32000, 0.1, 0.4, 0.02, 0.125], - [256000, 0.2, 0.8, 0.04, 0.25] - ], - "xai/grok-4.6": [[200000, 4, 12, 1, 0]] - }, - "costs": { - "poolside/laguna-s-2.1-free": [0, 0, 0, 0], - "tencent/hy3-paid": [0.14, 0.58, 0.035, 0], - "tencent/hy4-preview": [0.834, 2.501, 0.042, 0], - "moonshotai/Kimi-K3": [3, 15, 0.3, 0], - "moonshotai/Kimi-K2.7-Code": [0.95, 4, 0.19, 0], - "moonshotai/Kimi-K2.7-Code-Highspeed": [1.9, 8, 0.38, 0], - "moonshotai/Kimi-K2.6": [0.95, 4, 0.16, 0], - "moonshotai/Kimi-K2.5": [0.6, 3, 0.1, 0], - "z-ai/glm-5.3-flash": [0.15, 0.5, 0.03, 0], - "zai-org/GLM-5.3": [1.4, 4.4, 0.26, 0], - "zai-org/GLM-5.2": [1.4, 4.4, 0.26, 0], - "zai-org/GLM-5.2-Fast": [3, 10.25, 0.5, 0], - "zai-org/GLM-5.1": [1.4, 4.4, 0.26, 0], - "zai-org/GLM-5": [1, 3.2, 0.2, 0], - "MiniMaxAI/MiniMax-M3": [0.3, 1.2, 0.06, 0], - "MiniMaxAI/MiniMax-M2.7": [0.3, 1.2, 0.06, 0], - "MiniMaxAI/MiniMax-M2.5": [0.3, 1.2, 0.03, 0], - "deepseek/deepseek-v4-pro": [0.66, 1.98, 0.022, 0], - "deepseek/deepseek-v4-flash": [0.22, 0.66, 0.007, 0], - "deepseek/deepseek-v4-flash-vision-exp": [0.22, 0.66, 0.007, 0], - "deepseek/deepseek-v4-flash-fast": [0.28, 0.56, 0.07, 0], - "Qwen/Qwen3.8-Max": [2, 6, 0.25, 2.5], - "Qwen/Qwen3.8-27B": [0.4, 3, 0.04, 0], - "Qwen/Qwen3.8-Flash": [0.16, 0.47, 0.016, 0], - "Qwen/Qwen3.7-Max": [2.5, 7.5, 0.5, 3.13], - "Qwen/Qwen3.7-Plus": [0.4, 1.6, 0.08, 0.5], - "Qwen/Qwen3.7-Flash": [0.03, 0.13, 0.006, 0.038], - "Qwen/Qwen3.6-Max-Preview": [1.3, 7.8, 0.26, 1.63], - "Qwen/Qwen3.6-Plus": [0.5, 3, 0.1, 0], - "stepfun/Step-3.7-Flash": [0.2, 1.15, 0.04, 0], - "stepfun/Step-3.5-Flash": [0.1, 0.3, 0.02, 0], - "xiaomi/mimo-v2.5-pro": [0.435, 0.87, 0.0036, 0], - "xiaomi/mimo-v2.5": [0.14, 0.28, 0.0028, 0], - "nvidia/nemotron-3-ultra-550b-a55b": [0.6, 2.4, 0.12, 0], - "sakana/fugu-ultra": [5, 30, 0.5, 0], - "thinkingmachines/inkling": [1, 4.05, 0.17, 0], - "thinkingmachines/inkling-small": [0.5, 1.2, 0.1, 0], - "meta/muse-spark-1.1": [1.25, 4.25, 0.15, 0], - "meta/muse-spark-1.2": [1.25, 4.25, 0.15, 0], - "meta/muse-spark-1.2-contributor": [0.1, 0.2, 0.002, 0], - "claude-sonnet-5": [2, 10, 0.2, 2.5], - "claude-sonnet-4-6": [3, 15, 0.3, 3.75], - "claude-fable-5-1": [10, 50, 0.25, 12.5], - "claude-fable-5": [10, 50, 1, 12.5], - "claude-opus-5": [5, 25, 0.5, 6.25], - "claude-opus-4-8": [5, 25, 0.5, 6.25], - "claude-opus-4-7": [5, 25, 0.5, 6.25], - "claude-haiku-4-5-20251001": [1, 5, 0.1, 1.25], - "gpt-5.6-sol": [5, 30, 0.5, 6.25], - "gpt-5.6-terra": [2, 12, 0.2, 2.5], - "gpt-5.6-luna": [0.2, 1.2, 0.02, 0.25], - "gpt-5.5": [5, 30, 0.5, 0], - "gpt-5.4": [2.5, 15, 0.25, 0], - "gpt-5.3-codex": [2, 8, 0.5, 0], - "gpt-5.4-mini": [0.75, 4.5, 0.075, 0], - "google/gemini-3.7-flash": [1.5, 7.5, 0.15, 0.08334], - "google/gemini-3.6-flash": [1.5, 7.5, 0.15, 0], - "google/gemini-3.5-flash": [1.5, 9, 0.15, 0], - "google/gemini-3.5-flash-lite": [0.3, 2.5, 0.03, 0], - "google/gemini-3.1-flash-lite": [0.25, 1.5, 0.03, 0], - "xai/grok-4.5": [2, 6, 0.5, 0], - "xai/grok-4.6": [2, 6, 0.5, 0] - } -} diff --git a/tests/fixtures/compat-caller-extension.ts b/tests/fixtures/compat-caller-extension.ts deleted file mode 100644 index a028299..0000000 --- a/tests/fixtures/compat-caller-extension.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Test fixture: a sibling extension that streams through the pi-ai compat - * entrypoint with the active session model, the way background-agent - * extensions do. Registers `/compat-call` so the test can drive it over RPC. - * - * Types are declared inline so the fixture has no typecheck dependency on the - * optional pi peer packages; the host's extension loader resolves the import. - */ - -// @ts-expect-error pi resolves this peer package at load time. -import * as compatModule from "@earendil-works/pi-ai/compat" - -interface CompatTextPart { - type: string - text?: string -} - -interface CompatStreamResult { - result(): Promise<{ content: readonly CompatTextPart[] }> -} - -interface CompatModule { - streamSimple(model: unknown, context: unknown): CompatStreamResult -} - -interface CompatCallerContext { - model?: unknown - ui: { notify(message: string, level: "info" | "error"): void } -} - -interface CompatCallerExtensionApi { - registerCommand( - name: string, - command: { - description: string - handler: (args: string, ctx: CompatCallerContext) => Promise - }, - ): void -} - -const compat = compatModule as CompatModule - -export default function (pi: CompatCallerExtensionApi) { - pi.registerCommand("compat-call", { - description: "Stream through @earendil-works/pi-ai/compat with the session model", - handler: async (_args, ctx) => { - const model = ctx.model - if (!model) { - ctx.ui.notify("compat-call: no active model", "error") - return - } - try { - const message = await compat - .streamSimple(model, { - messages: [{ role: "user", content: "say mock token", timestamp: Date.now() }], - }) - .result() - const text = message.content - .filter((part) => part.type === "text") - .map((part) => part.text ?? "") - .join("") - ctx.ui.notify(`compat-call ok: ${text}`, "info") - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - ctx.ui.notify(`compat-call failed: ${detail}`, "error") - } - }, - }) -} diff --git a/tests/helpers.ts b/tests/helpers.ts deleted file mode 100644 index f4b3f65..0000000 --- a/tests/helpers.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { createServer, type IncomingHttpHeaders, type Server } from "node:http" - -import { - createStreamCommandCode, - type AssistantMessageEvent, - type AssistantMessageEventStreamLike, - type ContextLike, - type CoreDependencies, - type ModelLike, - type Usage, -} from "../src/core.ts" - -export function createTestEventStream(): AssistantMessageEventStreamLike { - const events: AssistantMessageEvent[] = [] - const waiters: Array<() => void> = [] - let ended = false - - const wake = () => { - const waiter = waiters.shift() - if (waiter) waiter() - } - - return { - push(event: AssistantMessageEvent) { - events.push(event) - wake() - }, - end() { - ended = true - while (waiters.length > 0) wake() - }, - [Symbol.asyncIterator]() { - let index = 0 - return { - async next(): Promise> { - while (index >= events.length && !ended) { - await new Promise((resolve) => waiters.push(resolve)) - } - if (index < events.length) { - const value = events[index] - index += 1 - return { done: false, value } - } - return { done: true, value: undefined } - }, - } - }, - } -} - -export async function collectEvents( - stream: AssistantMessageEventStreamLike, - timeoutMs = 2_000, -): Promise { - const events: AssistantMessageEvent[] = [] - - const collect = async () => { - for await (const event of stream) { - events.push(event) - if (event.type === "done" || event.type === "error") break - } - return events - } - - return await Promise.race([ - collect(), - new Promise((_, reject) => { - setTimeout( - () => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), - timeoutMs, - ) - }), - ]) -} - -export function makeModel(overrides: Partial = {}): ModelLike { - return { - id: "deepseek/deepseek-v4-flash", - api: "commandcode-custom", - provider: "commandcode", - maxTokens: 384_000, - cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - ...overrides, - } -} - -export function makeContext(overrides: Partial = {}): ContextLike { - return { - systemPrompt: "You are a test assistant.", - messages: [{ role: "user", content: "hello" }], - tools: [], - ...overrides, - } -} - -export interface TestDepsResult { - streamCommandCode: ReturnType - calculatedUsages: Usage[] -} - -export function createTestDeps(overrides: Partial = {}): TestDepsResult { - const calculatedUsages: Usage[] = [] - const streamCommandCode = createStreamCommandCode({ - createStream: createTestEventStream, - calculateCost: (_model, usage) => { - calculatedUsages.push({ - ...usage, - cost: { ...usage.cost }, - }) - }, - env: {}, - authPaths: [], - now: () => new Date("2026-05-05T12:00:00Z").getTime(), - uuid: () => "00000000-0000-4000-8000-000000000000", - cwd: () => "/repo", - delay: async () => {}, - ...overrides, - }) - return { streamCommandCode, calculatedUsages } -} - -type SuccessPlan = { - type: "success" - status?: number - events?: string[] - chunks?: string[] - delays?: number[] - hangAfterLast?: boolean - /** Delay in ms before the server starts sending the response. */ - responseDelay?: number -} - -type ErrorPlan = { - type: "error" - status: number - body: string - headers?: Record -} - -export type ResponsePlan = SuccessPlan | ErrorPlan - -function headersToRecord(headers: IncomingHttpHeaders): Record { - const out: Record = {} - for (const [key, value] of Object.entries(headers)) { - if (typeof value === "string") out[key] = value - else if (Array.isArray(value)) out[key] = value.join(", ") - } - return out -} - -export interface MockCommandCodeServer { - baseUrl(): string - mockResponse(plan: ResponsePlan): void - mockResponseQueue(plans: ResponsePlan[]): void - reset(): void - close(): Promise - lastRequestBody(): unknown - lastRequestHeaders(): Record - requestCount(): number - responseClosedBeforeEnd(): boolean -} - -export async function startMockCommandCodeServer(): Promise { - let planQueue: ResponsePlan[] = [{ type: "success", events: [] }] - let lastBody: unknown - let lastHeaders: Record = {} - let requests = 0 - let closedBeforeEnd = false - let port = 0 - - const server: Server = createServer((req, res) => { - if (req.method !== "POST" || req.url !== "/alpha/generate") { - res.writeHead(404) - res.end("Not found") - return - } - - requests += 1 - lastHeaders = headersToRecord(req.headers) - let body = "" - req.on("data", (chunk: Buffer) => { - body += chunk.toString("utf-8") - }) - req.on("end", () => { - try { - const parsed: unknown = JSON.parse(body) - lastBody = parsed - } catch { - lastBody = undefined - } - - // Pop the first plan from the queue; keep the last one as fallback. - const plan = planQueue.length > 1 ? planQueue.shift()! : planQueue[0] - - if (plan.type === "error") { - const headers: Record = { "Content-Type": "text/plain", ...plan.headers } - res.writeHead(plan.status, headers) - res.end(plan.body) - return - } - - res.writeHead(plan.status ?? 200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", - }) - - let ended = false - res.on("close", () => { - if (!ended) closedBeforeEnd = true - }) - - const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`) - const delays = plan.delays ?? chunks.map(() => 0) - let index = 0 - - const sendNext = () => { - if (index >= chunks.length) { - if (!plan.hangAfterLast) { - ended = true - res.end() - } - return - } - - res.write(chunks[index]) - index += 1 - if (index < chunks.length) { - setTimeout(sendNext, delays[index] ?? 0) - } else if (!plan.hangAfterLast) { - ended = true - res.end() - } - } - - if (plan.responseDelay) { - setTimeout(sendNext, plan.responseDelay) - } else { - sendNext() - } - }) - }) - - await new Promise((resolve) => { - server.listen(0, () => { - const address = server.address() - if (typeof address === "object" && address) port = address.port - resolve() - }) - }) - - return { - baseUrl: () => `http://127.0.0.1:${port}`, - mockResponse(plan: ResponsePlan) { - planQueue = [plan] - }, - mockResponseQueue(plans: ResponsePlan[]) { - planQueue = [...plans] - }, - reset() { - planQueue = [{ type: "success", events: [] }] - lastBody = undefined - lastHeaders = {} - requests = 0 - closedBeforeEnd = false - }, - close() { - return new Promise((resolve) => server.close(() => resolve())) - }, - lastRequestBody: () => lastBody, - lastRequestHeaders: () => lastHeaders, - requestCount: () => requests, - responseClosedBeforeEnd: () => closedBeforeEnd, - } -} - -export function objectAt(value: unknown, path: readonly string[]): unknown { - let current = value - for (const key of path) { - if (Array.isArray(current)) { - const index = Number(key) - if (!Number.isInteger(index)) return undefined - current = current[index] - continue - } - if (typeof current !== "object" || current === null) return undefined - current = Object.getOwnPropertyDescriptor(current, key)?.value - } - return current -} diff --git a/tests/models/models.test.ts b/tests/models/models.test.ts new file mode 100644 index 0000000..1ad81b2 --- /dev/null +++ b/tests/models/models.test.ts @@ -0,0 +1,188 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { + accountApiBase, + apiForModelId, + baseUrlForApi, + fetchLiveCatalog, + getModelsTimeoutMs, + modelsFromCatalog, + modelsFromLive, + parseLiveCatalog, + providerHeaders, + thinkingLevelMapFor, + toProviderModel, +} from "../../src/models.ts" + +const PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1" + +/** Shape of the real GET /provider/v1/models response. */ +const liveCatalogResponse = { + object: "list", + data: [ + { id: "deepseek/deepseek-v4.1-flash", object: "model", name: "DeepSeek V4.1 Flash", context_length: 1_000_000 }, + { id: "claude-sonnet-4-6", object: "model", name: "Claude Sonnet 4.6", context_length: 1_000_000 }, + { id: "vendor/brand-new-model", object: "model", name: "Brand New", context_length: 32_768 }, + ], +} + +test("parseLiveCatalog reads id, name and context window", () => { + const models = parseLiveCatalog(liveCatalogResponse) + + assert.deepEqual( + models.map((model) => model.id), + ["deepseek/deepseek-v4.1-flash", "claude-sonnet-4-6", "vendor/brand-new-model"], + ) + assert.equal(models[2]?.contextWindow, 32_768) +}) + +test("parseLiveCatalog rejects malformed catalogs", () => { + assert.throws(() => parseLiveCatalog({ object: "list", data: [] }), /empty model catalog/) + assert.throws(() => parseLiveCatalog({ object: "collection", data: [{}] }), /'list'/) + assert.throws( + () => parseLiveCatalog({ object: "list", data: [{ id: "x", name: "X" }] }), + /context_length/, + ) +}) + +test("modelsFromLive uses CLI metadata when the model is known", () => { + const [deepseek, claude, unknown] = modelsFromLive(parseLiveCatalog(liveCatalogResponse)) + + assert.equal(deepseek?.reasoning, true) + assert.deepEqual(deepseek?.efforts, ["low", "high", "max"]) + assert.deepEqual(deepseek?.input, ["text", "image"]) + assert.equal(deepseek?.cost.output, 0.6) + assert.equal(claude?.api, "anthropic-messages") + + // Unknown models stay usable but text-only and unpriced until the catalog syncs. + assert.equal(unknown?.reasoning, false) + assert.deepEqual(unknown?.input, ["text"]) + assert.deepEqual(unknown?.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }) + assert.equal(unknown?.maxTokens, 32_768) +}) + +test("modelsFromCatalog exposes every generated entry", async () => { + const { CATALOG } = await import("../../src/catalog.ts") + const models = modelsFromCatalog() + + assert.equal(models.length, CATALOG.length) + assert.ok(models.every((model) => model.maxTokens > 0 && model.contextWindow > 0)) +}) + +test("baseline models without a published context window fall back to a usable default", () => { + // The CLI reference lists GLM-5.1 as "—" for context, and pi cannot run a model with a 0 window. + const glm = modelsFromCatalog().find((model) => model.id === "zai-org/GLM-5.1") + + assert.equal(glm?.contextWindow, 200_000) + assert.ok((glm?.maxTokens ?? 0) > 0) +}) + +test("modelsFromLive prefers the live context window over the CLI snapshot", () => { + const [model] = modelsFromLive([ + { id: "deepseek/deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", contextWindow: 512_000 }, + ]) + + assert.equal(model?.contextWindow, 512_000) + assert.equal(model?.maxTokens, 65_536) + assert.deepEqual(model?.input, ["text", "image"]) +}) + +test("live context windows and DeepSeek V4.1 vision/effort metadata survive the merge", () => { + const [model] = modelsFromLive([ + { id: "deepseek/deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", contextWindow: 1_000_000 }, + ]) + assert.ok(model) + const config = toProviderModel(model, PROVIDER_API_BASE) + + // Vision and the opt-in max effort are what the API actually serves for V4.1. + assert.deepEqual(config.input, ["text", "image"]) + assert.deepEqual(config.thinkingLevelMap, { + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: "max", + }) + assert.equal( + (config.compat as { supportsReasoningEffort?: boolean }).supportsReasoningEffort, + true, + ) +}) + +test("api and base URL follow the model family", () => { + assert.equal(apiForModelId("claude-opus-5"), "anthropic-messages") + assert.equal(apiForModelId("deepseek/deepseek-v4.1-flash"), "openai-completions") + assert.equal(baseUrlForApi(PROVIDER_API_BASE, "openai-completions"), PROVIDER_API_BASE) + // pi appends /v1/messages to the Anthropic base URL. + assert.equal(baseUrlForApi(PROVIDER_API_BASE, "anthropic-messages"), "https://api.commandcode.ai/provider") +}) + +test("accountApiBase strips the provider namespace", () => { + assert.equal(accountApiBase(PROVIDER_API_BASE), "https://api.commandcode.ai") + assert.equal(accountApiBase("https://example.test/provider/v1/"), "https://example.test") +}) + +test("thinkingLevelMap hides levels the model does not offer", () => { + assert.deepEqual(thinkingLevelMapFor(["low", "high", "max"]), { + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: "max", + }) +}) + +test("toProviderModel maps a Claude model onto the Anthropic adapter", () => { + const [claude] = modelsFromLive(parseLiveCatalog(liveCatalogResponse)).slice(1) + assert.ok(claude) + const config = toProviderModel(claude, PROVIDER_API_BASE) + + assert.equal(config.api, "anthropic-messages") + assert.equal(config.baseUrl, "https://api.commandcode.ai/provider") + assert.equal(config.reasoning, true) + assert.deepEqual(config.thinkingLevelMap?.high, "high") + assert.equal( + (config.compat as { forceAdaptiveThinking?: boolean }).forceAdaptiveThinking, + true, + ) + assert.equal(config.cost.cacheWrite, 3.75) +}) + +test("toProviderModel maps an OpenAI-compatible model onto the completions adapter", () => { + const [deepseek] = modelsFromLive(parseLiveCatalog(liveCatalogResponse)) + assert.ok(deepseek) + const config = toProviderModel(deepseek, PROVIDER_API_BASE) + + assert.equal(config.api, "openai-completions") + assert.equal(config.baseUrl, PROVIDER_API_BASE) + assert.deepEqual(config.compat, { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + maxTokensField: "max_tokens", + }) + assert.deepEqual(config.input, ["text", "image"]) +}) + +test("fetchLiveCatalog parses the response and surfaces HTTP failures", async () => { + const ok = await fetchLiveCatalog({ + fetchImpl: async () => new Response(JSON.stringify(liveCatalogResponse), { status: 200 }), + }) + assert.equal(ok.length, 3) + + await assert.rejects( + fetchLiveCatalog({ fetchImpl: async () => new Response("nope", { status: 503 }) }), + /503/, + ) +}) + +test("environment overrides for base URL, headers and timeout", () => { + assert.deepEqual(providerHeaders({ CMD_ZDR: "1" }), { "x-cmd-zdr": "1" }) + assert.equal(providerHeaders({ COMMANDCODE_ZDR: "1" })?.["x-cmd-zdr"], "1") + assert.equal(providerHeaders({}), undefined) + assert.equal(getModelsTimeoutMs({ COMMANDCODE_MODELS_TIMEOUT_MS: "2500" }), 2500) + assert.equal(getModelsTimeoutMs({ COMMANDCODE_MODELS_TIMEOUT_MS: "-1" }), 10_000) +}) diff --git a/tests/models/sync-catalog.test.ts b/tests/models/sync-catalog.test.ts new file mode 100644 index 0000000..aaeed57 --- /dev/null +++ b/tests/models/sync-catalog.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { + buildCatalog, + parseBundleMetadata, + parseRates, + parseReference, + parseReferenceRow, + sliceModelObject, +} from "../../scripts/sync-catalog.mjs" + +/** Mirrors one row of the CLI reference table, including the em-dash placeholders. */ +const referenceMarkdown = [ + "| Id (use EXACTLY this) | Name | Context | Efforts | $/1M in/out · cache read | Min plan | Best for |", + "|---|---|---|---|---|---|---|", + "| `deepseek/deepseek-v4.1-flash` | DeepSeek V4.1 Flash | 1M | low, high, max | $0.15/$0.6 · cache $0.003 | Go and above | reasoning with vision |", + "| `moonshotai/Kimi-K2.7-Code` | Kimi K2.7 Code | 256K | — | $0.95/$4 · cache $0.19 | Go and above | long-horizon coding |", + "| `claude-sonnet-4-6` | Claude Sonnet 4.6 | 1M | low, medium, high, xhigh, max | $3/$15 · cache $0.3 (write $3.75) | Pro and above | fast and capable |", +].join("\n") + +/** Trimmed shape of the minified CLI model literal. */ +const cliBundle = [ + "var $L={SONNET:{id:\"claude-sonnet-4-6\",inputModalities:[\"text\",\"image\"],provider:\"x\",spec:\"chatComplete\",label:\"Claude Sonnet 4.6\",reasoning:!0,reasoningEfforts:[\"low\",\"medium\",\"high\",\"xhigh\",\"max\"],contextWindow:1e6},", + "FLASH:{id:\"deepseek/deepseek-v4.1-flash\",inputModalities:[\"text\",\"image\"],provider:\"y\",spec:\"chatComplete\",label:\"DeepSeek V4.1 Flash\",reasoning:!0,reasoningEfforts:[\"low\",\"high\",\"max\"],contextWindow:1e6},", + "KIMI:{id:\"moonshotai/Kimi-K2.7-Code\",inputModalities:[\"text\",\"image\"],provider:\"z\",spec:\"chatComplete\",label:\"Kimi K2.7 Code\",reasoning:!0,contextWindow:262144}};", +].join("") + +/** Output shape of the generator, asserted field by field below. */ +interface CatalogEntry { + id: string + name: string + contextWindow: number + efforts: string[] + reasoning: boolean + input: string[] + maxOutputTokens: number + cost: { input: number; output: number; cacheRead: number; cacheWrite: number } +} + +test("parseReference reads ids, names, efforts and rates", () => { + const models = parseReference(referenceMarkdown) + + assert.deepEqual(models.map((model) => model.id), [ + "deepseek/deepseek-v4.1-flash", + "moonshotai/Kimi-K2.7-Code", + "claude-sonnet-4-6", + ]) + assert.deepEqual(models[0]?.efforts, ["low", "high", "max"]) + assert.deepEqual(models[1]?.efforts, []) + assert.deepEqual(models[2]?.cost, { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }) +}) + +test("parseReference ignores header rows and rejects unknown efforts", () => { + assert.equal(parseReferenceRow("| Id (use EXACTLY this) | Name | Context | Efforts |"), undefined) + assert.throws( + () => parseReference("| `m` | M | 1M | turbo | $1/$2 · cache $0 | Go |"), + /Unknown effort/, + ) +}) + +test("parseRates handles free and cache-write pricing", () => { + assert.deepEqual(parseRates("$0/$0 · cache $0"), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }) + assert.deepEqual(parseRates("$2/$6 · cache $0.25 (write $2.5)"), { + input: 2, + output: 6, + cacheRead: 0.25, + cacheWrite: 2.5, + }) +}) + +test("sliceModelObject extracts one nested literal from the minified bundle", () => { + const entry = sliceModelObject(cliBundle, "moonshotai/Kimi-K2.7-Code") + + assert.ok(entry?.startsWith('{id:"moonshotai/Kimi-K2.7-Code",inputModalities:')) + assert.ok(entry?.includes('contextWindow:262144}')) + assert.ok(entry?.endsWith('contextWindow:262144}')) + assert.equal(sliceModelObject(cliBundle, "missing/model"), undefined) +}) + +test("parseBundleMetadata reports modalities, reasoning and limits", () => { + const metadata = parseBundleMetadata(cliBundle, [ + "claude-sonnet-4-6", + "moonshotai/Kimi-K2.7-Code", + ]) as Map + + assert.deepEqual(metadata.get("claude-sonnet-4-6")?.input, ["text", "image"]) + assert.equal(metadata.get("claude-sonnet-4-6")?.reasoning, true) + assert.equal(metadata.get("claude-sonnet-4-6")?.contextWindow, 1_000_000) + // Reason-only models carry no effort list but still report reasoning. + assert.equal(metadata.get("moonshotai/Kimi-K2.7-Code")?.reasoning, true) + assert.equal(metadata.get("moonshotai/Kimi-K2.7-Code")?.maxOutputTokens, undefined) +}) + +test("buildCatalog merges the reference table with bundle metadata", () => { + const reference = parseReference(referenceMarkdown) + const metadata = parseBundleMetadata( + cliBundle, + reference.map((model: { id: string }) => model.id), + ) + const catalog = buildCatalog("1.54.0", reference, metadata) as CatalogEntry[] + + assert.deepEqual(catalog.map((model) => model.id), reference.map((model) => model.id)) + assert.deepEqual(catalog[0], { + id: "deepseek/deepseek-v4.1-flash", + name: "DeepSeek V4.1 Flash", + contextWindow: 1_000_000, + efforts: ["low", "high", "max"], + reasoning: true, + input: ["text", "image"], + maxOutputTokens: 0, + cost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + }) + assert.equal(catalog[1]?.reasoning, true) + assert.equal(catalog[2]?.cost.cacheWrite, 3.75) +}) diff --git a/tests/overflow/overflow.test.ts b/tests/overflow/overflow.test.ts new file mode 100644 index 0000000..8b763aa --- /dev/null +++ b/tests/overflow/overflow.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { + normalizeCommandCodeErrorMessage, + normalizeCommandCodeMessage, + redactCommandCodeErrorText, +} from "../../src/overflow.ts" + +test("provider overflow wording is rewritten to pi's generic prefix", () => { + const cases = [ + "This model's maximum context length is 1000000 tokens, however you requested 1200000 tokens", + "prompt is too long: 210000 tokens > 200000 maximum", + "Input tokens exceed the context window", + "Request exceeds the maximum allowed input tokens", + ] + + for (const message of cases) { + assert.match(normalizeCommandCodeErrorMessage(message) ?? "", /^context_length_exceeded: /, message) + } +}) + +test("retryable and quota errors are never treated as overflow", () => { + const cases = [ + "rate limit exceeded, please retry", + "429 Too Many Requests", + "status: 429", + "Service temporarily unavailable", + "quota exceeded for this billing period", + ] + + for (const message of cases) { + assert.equal(normalizeCommandCodeErrorMessage(message), undefined, message) + } +}) + +test("already-normalized and unrelated errors are left alone", () => { + assert.equal(normalizeCommandCodeErrorMessage("context_length_exceeded: prompt too long"), undefined) + assert.equal(normalizeCommandCodeErrorMessage("invalid api key"), undefined) + assert.equal(normalizeCommandCodeErrorMessage(undefined), undefined) +}) + +test("message rewriting only applies to Command Code assistant errors", () => { + const message = { + role: "assistant", + provider: "commandcode", + stopReason: "error", + errorMessage: "prompt is too long", + } + + assert.deepEqual(normalizeCommandCodeMessage(message), { + message: { ...message, errorMessage: "context_length_exceeded: prompt is too long" }, + }) + assert.equal(normalizeCommandCodeMessage({ ...message, stopReason: "stop" }), undefined) + assert.equal(normalizeCommandCodeMessage({ ...message, role: "user" }), undefined) + assert.equal(normalizeCommandCodeMessage({ ...message, provider: "anthropic" }), undefined) + // pi asks the model's provider when the message provider differs. + assert.ok(normalizeCommandCodeMessage({ ...message, provider: "other" }, "commandcode")) +}) + +test("error text never leaks API keys", () => { + const redacted = redactCommandCodeErrorText( + "failed with Authorization: Bearer user_abcdefgh12345678 and apiKey=user_secret_value", + ) + + assert.ok(!redacted.includes("user_abcdefgh12345678")) + assert.ok(!redacted.includes("user_secret_value")) + assert.match(redacted, /Bearer \[redacted\]/) +}) diff --git a/tests/quota/quota.test.ts b/tests/quota/quota.test.ts new file mode 100644 index 0000000..3edfa49 --- /dev/null +++ b/tests/quota/quota.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { formatQuota } from "../../src/quota-format.ts" +import { + fetchCommandCodeQuota, + normalizeResetAt, + parseAccount, + parseCredits, + parseSubscription, + parseSummary, + parseWindowLimits, +} from "../../src/quota.ts" + +/** Shapes returned by the Command Code alpha account endpoints. */ +const accountResponse = { + success: true, + user: { id: "7a18ec22", name: "cat_shark", userName: "shark-cat", keyName: "cli-key" }, + org: null, +} +const creditsResponse = { + credits: { monthlyCredits: 12.5, purchasedCredits: 3, freeCredits: 0 }, + windowLimits: { + fiveHour: { used: 2.5, cap: 10, resetAt: 1_800_000_000 }, + weekly: { used: 20, cap: 100, resetAt: 1_800_500_000 }, + }, +} +const subscriptionResponse = { + data: { + planId: "go", + status: "active", + currentPeriodStart: "2026-09-01T00:00:00.000Z", + currentPeriodEnd: "2026-10-01T00:00:00.000Z", + }, +} +const summaryResponse = { totalCost: 1.23, totalCount: 45, totalTokens: 123_456 } + +/** Answers each alpha endpoint with its canned payload. */ +function createFetchStub(responses: { + whoami?: Response + credits?: Response + subscription?: Response + summary?: Response +}): typeof fetch { + return async (input) => { + const url = String(input) + if (url.includes("/alpha/whoami")) return responses.whoami ?? json(accountResponse) + if (url.includes("/alpha/billing/credits")) return responses.credits ?? json(creditsResponse) + if (url.includes("/alpha/billing/subscriptions")) { + return responses.subscription ?? json(subscriptionResponse) + } + if (url.includes("/alpha/usage/summary")) return responses.summary ?? json(summaryResponse) + throw new Error(`Unexpected request: ${url}`) + } +} + +function json(value: unknown): Response { + return new Response(JSON.stringify(value), { status: 200 }) +} + +test("parseAccount accepts user names and organization logins", () => { + assert.deepEqual(parseAccount(accountResponse), { login: "shark-cat", orgId: null, keyName: "cli-key" }) + assert.deepEqual(parseAccount({ org: { login: "team", id: "org_1" }, user: {} }), { + login: "team", + orgId: "org_1", + }) + assert.equal(parseAccount({ user: {} }), null) +}) + +test("parseCredits totals the credit sources", () => { + const credits = parseCredits(creditsResponse) + + assert.equal(credits?.remainingCredits, 15.5) + assert.equal(credits?.monthlyCredits, 12.5) + assert.deepEqual( + credits?.windowLimits.map((limit) => [limit.window, limit.used, limit.cap]), + [ + ["fiveHour", 2.5, 10], + ["weekly", 20, 100], + ], + ) + assert.equal(parseCredits({ credits: {} }), null) +}) + +test("parseWindowLimits drops empty windows and normalizes reset times", () => { + const limits = parseWindowLimits({ + fiveHour: { used: 0, cap: 0, resetAt: "2026-09-14T10:00:00.000Z" }, + weekly: { used: 4, cap: 8, resetAt: "2026-09-20T10:00:00.000Z" }, + }) + + assert.equal(limits.length, 1) + assert.equal(limits[0]?.window, "weekly") + assert.equal(limits[0]?.resetAt, Date.parse("2026-09-20T10:00:00.000Z") / 1000) + assert.equal(normalizeResetAt(1_800_000_000_000), 1_800_000_000) + assert.equal(normalizeResetAt("not a date"), null) +}) + +test("parseSubscription and parseSummary read the plan and usage payloads", () => { + assert.deepEqual(parseSubscription(subscriptionResponse), { + planId: "go", + status: "active", + currentPeriodStart: "2026-09-01T00:00:00.000Z", + currentPeriodEnd: "2026-10-01T00:00:00.000Z", + }) + assert.equal(parseSubscription({ data: {} }), null) + assert.deepEqual(parseSummary(summaryResponse), { totalCost: 1.23, totalCount: 45, totalTokens: 123_456 }) + assert.equal(parseSummary({ totalCost: 1 }), null) +}) + +test("fetchCommandCodeQuota assembles every section", async () => { + const result = await fetchCommandCodeQuota({ + apiKey: "user_abc", + fetchImpl: createFetchStub({}), + }) + + assert.equal(result.ok, true) + if (!result.ok) return + assert.equal(result.quota.account.login, "shark-cat") + assert.equal(result.quota.credits?.remainingCredits, 15.5) + assert.equal(result.quota.subscription?.planId, "go") + assert.equal(result.quota.summary?.totalCount, 45) + assert.deepEqual(result.quota.unavailable, []) +}) + +test("fetchCommandCodeQuota reports rejected keys and missing sections", async () => { + const rejected = await fetchCommandCodeQuota({ + apiKey: "user_bad", + fetchImpl: createFetchStub({ whoami: new Response("{}", { status: 401 }) }), + }) + assert.equal(rejected.ok, false) + + const partial = await fetchCommandCodeQuota({ + apiKey: "user_abc", + fetchImpl: createFetchStub({ credits: new Response("{}", { status: 500 }) }), + }) + assert.equal(partial.ok, true) + if (!partial.ok) return + assert.equal(partial.quota.credits, null) + assert.deepEqual(partial.quota.unavailable, ["credits"]) +}) + +test("fetchCommandCodeQuota without a key fails fast", async () => { + const result = await fetchCommandCodeQuota({ apiKey: "" }) + + assert.equal(result.ok, false) +}) + +test("formatQuota renders account, credits and usage sections", async () => { + const result = await fetchCommandCodeQuota({ apiKey: "user_abc", fetchImpl: createFetchStub({}) }) + assert.equal(result.ok, true) + if (!result.ok) return + + const text = formatQuota(result.quota, () => Date.parse("2026-09-14T09:30:00.000Z")) + + assert.match(text, /Account: shark-cat/) + assert.match(text, /API key: cli-key/) + assert.match(text, /Plan: go \(active\)/) + assert.match(text, /Credits remaining: 15\.50/) + assert.match(text, /5-hour: 2\.50 \/ 10\.00 credits \(25% used\)/) + assert.match(text, /Weekly: 20\.00 \/ 100\.00 credits \(20% used\)/) + assert.match(text, /Usage this period: \$1\.23 over 45 requests \(123456 tokens\)/) +}) diff --git a/tests/test-abort.ts b/tests/test-abort.ts deleted file mode 100644 index 8db0ad5..0000000 --- a/tests/test-abort.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Abort tests against the real streamCommandCode core. - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -describe("streamCommandCode — abort behavior", () => { - it("emits aborted error when signal is already aborted", async () => { - const controller = new AbortController() - controller.abort() - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - }), - ) - - assert.deepEqual( - events.map((event) => event.type), - ["start", "error"], - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - assert.equal(error.error.stopReason, "aborted") - assert.equal(server.requestCount(), 0) - }) - - it("emits aborted error and cancels the response reader mid-stream", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "text-delta", text: "first" })], - hangAfterLast: true, - }) - const controller = new AbortController() - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const stream = streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - }) - - setTimeout(() => controller.abort(), 50) - const events = await collectEvents(stream, 2_000) - - assert.ok( - events.some((event) => event.type === "text_delta"), - "stream should process data before abort", - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - assert.equal(error.error.errorMessage, "Request aborted") - await new Promise((resolve) => setTimeout(resolve, 50)) - assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response") - }) -}) diff --git a/tests/test-api-key.ts b/tests/test-api-key.ts deleted file mode 100644 index f3029cb..0000000 --- a/tests/test-api-key.ts +++ /dev/null @@ -1,66 +0,0 @@ -import assert from "node:assert/strict" -import { mkdtemp, rm, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { describe, it } from "node:test" - -import { getConfiguredApiKey } from "../src/api-key.ts" - -async function withAuthFile( - value: unknown, - run: (authPath: string) => Promise, -): Promise { - const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-auth-")) - const authPath = join(directory, "auth.json") - try { - await writeFile(authPath, JSON.stringify(value), "utf-8") - await run(authPath) - } finally { - await rm(directory, { recursive: true, force: true }) - } -} - -describe("getConfiguredApiKey()", () => { - it("prefers the official environment variable and keeps the legacy alias", () => { - assert.equal( - getConfiguredApiKey({ - env: { COMMAND_CODE_API_KEY: "official-key", COMMANDCODE_API_KEY: "legacy-key" }, - authPaths: [], - }), - "official-key", - ) - assert.equal( - getConfiguredApiKey({ env: { COMMANDCODE_API_KEY: "legacy-key" }, authPaths: [] }), - "legacy-key", - ) - }) - - it("reads pi OAuth and API credentials", async () => { - const cases: readonly { credential: unknown; expected: string }[] = [ - { - credential: { commandcode: { type: "oauth", access: "oauth-key" } }, - expected: "oauth-key", - }, - { credential: { commandcode: { type: "api", key: "api-key" } }, expected: "api-key" }, - { credential: { "command-code": { type: "api", key: "cli-key" } }, expected: "cli-key" }, - { credential: { apiKey: "legacy-key" }, expected: "legacy-key" }, - ] - - for (const testCase of cases) { - await withAuthFile(testCase.credential, async (authPath) => { - assert.equal(getConfiguredApiKey({ env: {}, authPaths: [authPath] }), testCase.expected) - }) - } - }) - - it("ignores malformed files", async () => { - const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-auth-")) - const authPath = join(directory, "auth.json") - try { - await writeFile(authPath, "not json", "utf-8") - assert.equal(getConfiguredApiKey({ env: {}, authPaths: [authPath] }), undefined) - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) -}) diff --git a/tests/test-cost.ts b/tests/test-cost.ts deleted file mode 100644 index ae7f730..0000000 --- a/tests/test-cost.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Regression test for the local cost calculation. - * - * The provider ships its own cost function because Oh My Pi's legacy pi-ai - * shim does not export `calculateCost` (see issue #24). This test locks the - * local implementation to pi-ai's documented per-million-token arithmetic - * without installing another pi-ai runtime next to the extension. - */ - -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { calculateCommandCodeCost } from "../src/cost.ts" -import type { Usage } from "../src/types.ts" - -interface CostRates { - input: number - output: number - cacheRead: number - cacheWrite: number -} - -interface CostTable extends CostRates { - tiers?: Array -} - -const COST_FIXTURES: Record = { - "zero-cost-model": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "deepseek/deepseek-v4-pro": { - input: 0.435, - output: 0.87, - cacheRead: 0.003625, - cacheWrite: 0, - }, - "Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, - "Qwen/Qwen3.7-Flash": { - input: 0.03, - output: 0.13, - cacheRead: 0.006, - cacheWrite: 0.038, - tiers: [ - { inputTokensAbove: 32_000, input: 0.1, output: 0.4, cacheRead: 0.02, cacheWrite: 0.125 }, - { inputTokensAbove: 256_000, input: 0.2, output: 0.8, cacheRead: 0.04, cacheWrite: 0.25 }, - ], - }, - "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, -} - -const USAGE_CASES = [ - { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 }, - { input: 812, output: 187, cacheRead: 52_000, cacheWrite: 3_100 }, - { input: 1_000_000, output: 65_536, cacheRead: 998_877, cacheWrite: 123_456 }, - { input: 7, output: 999_999_999, cacheRead: 0.5, cacheWrite: 42 }, -] - -function commandCodeModel(id: string, cost: CostTable) { - return { - id, - api: "commandcode-custom", - provider: "commandcode", - cost, - maxTokens: 65_536, - } -} - -function assertClose(actual: number, expected: number) { - assert.ok( - Math.abs(actual - expected) <= - Number.EPSILON * Math.max(1, Math.abs(actual), Math.abs(expected)), - `expected ${actual} to be close to ${expected}`, - ) -} - -function freshUsage(tokens: (typeof USAGE_CASES)[number]): Usage { - return { - ...tokens, - totalTokens: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - } -} - -function expectedCost(cost: CostTable, tokens: (typeof USAGE_CASES)[number]): Usage["cost"] { - const inputTokens = tokens.input + tokens.cacheRead + tokens.cacheWrite - let rates: CostRates = cost - let matchedThreshold = -1 - for (const tier of cost.tiers ?? []) { - if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { - rates = tier - matchedThreshold = tier.inputTokensAbove - } - } - - const input = (rates.input / 1_000_000) * tokens.input - const output = (rates.output / 1_000_000) * tokens.output - const cacheRead = (rates.cacheRead / 1_000_000) * tokens.cacheRead - const cacheWrite = (rates.cacheWrite * tokens.cacheWrite) / 1_000_000 - return { - input, - output, - cacheRead, - cacheWrite, - total: input + output + cacheRead + cacheWrite, - } -} - -describe("calculateCommandCodeCost()", () => { - it("applies per-million-token rates to all cost fields", () => { - for (const [id, cost] of Object.entries(COST_FIXTURES)) { - const model = commandCodeModel(id, cost) - - for (const tokens of USAGE_CASES) { - const usage = freshUsage(tokens) - calculateCommandCodeCost(model, usage) - - assert.deepEqual( - usage.cost, - expectedCost(cost, tokens), - `${id} cost for tokens=${JSON.stringify(tokens)}`, - ) - } - } - }) - - it("applies the highest request-wide input tier above its threshold", () => { - const model = commandCodeModel("Qwen/Qwen3.7-Flash", COST_FIXTURES["Qwen/Qwen3.7-Flash"]) - - const atThreshold = freshUsage({ - input: 32_000, - output: 1_000, - cacheRead: 0, - cacheWrite: 0, - }) - calculateCommandCodeCost(model, atThreshold) - assertClose(atThreshold.cost.input, (0.03 * 32_000) / 1_000_000) - - const aboveFirstTier = freshUsage({ - input: 30_000, - output: 1_000, - cacheRead: 2_001, - cacheWrite: 0, - }) - calculateCommandCodeCost(model, aboveFirstTier) - assertClose(aboveFirstTier.cost.input, (0.1 * 30_000) / 1_000_000) - assertClose(aboveFirstTier.cost.cacheRead, (0.02 * 2_001) / 1_000_000) - - const aboveHighestTier = freshUsage({ - input: 100_000, - output: 1_000, - cacheRead: 156_001, - cacheWrite: 0, - }) - calculateCommandCodeCost(model, aboveHighestTier) - assertClose(aboveHighestTier.cost.input, (0.2 * 100_000) / 1_000_000) - assertClose(aboveHighestTier.cost.output, (0.8 * 1_000) / 1_000_000) - }) - - it("prices one-hour cache writes at twice the active input rate", () => { - const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) - const usage = freshUsage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 1_000 }) - usage.cacheWrite1h = 400 - - calculateCommandCodeCost(model, usage) - - const expectedShortWrite = (3.75 * 600) / 1_000_000 - const expectedLongWrite = (3 * 2 * 400) / 1_000_000 - assertClose(usage.cost.cacheWrite, expectedShortWrite + expectedLongWrite) - }) - - it("writes the total as the sum of all cost components", () => { - const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) - const usage = freshUsage({ input: 1_000, output: 500, cacheRead: 10_000, cacheWrite: 2_000 }) - - calculateCommandCodeCost(model, usage) - - assert.equal( - usage.cost.total, - usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite, - ) - assert.ok(usage.cost.total > 0) - }) -}) diff --git a/tests/test-live-e2e.mjs b/tests/test-live-e2e.mjs deleted file mode 100644 index 7a91d88..0000000 --- a/tests/test-live-e2e.mjs +++ /dev/null @@ -1,508 +0,0 @@ -#!/usr/bin/env node -/** - * Live end-to-end validation against Command Code with existing credentials. - * - * This test never reads or prints credential files. Pi resolves authentication - * through its normal provider flow. It is intentionally excluded from `npm test` - * because it consumes live provider capacity. - */ - -import assert from "node:assert/strict" -import { spawn } from "node:child_process" -import { - accessSync, - constants, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, -} from "node:fs" -import { homedir, tmpdir } from "node:os" -import { delimiter, dirname, join, resolve } from "node:path" -import { fileURLToPath } from "node:url" - -const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") -const extensionPath = join(projectDir, "index.ts") -const testModel = process.env.COMMANDCODE_E2E_MODEL ?? "deepseek/deepseek-v4-flash" -const testProfile = process.env.COMMANDCODE_E2E_PROFILE -const expectedTransport = - testProfile === "go" - ? "generate" - : testProfile === "goat" || testProfile === "provider" - ? "provider" - : undefined -const expectedPlan = - testProfile === "go" - ? "go" - : testProfile === "goat" - ? "goat" - : testProfile === "provider" - ? "provider" - : undefined -// GPT-5.6 Luna is available on every plan and has a ZDR-capable upstream; -// Gemini 3.7 Flash currently fails with a zero-data-retention routing 404. -const goatVisionModel = process.env.COMMANDCODE_E2E_GOAT_VISION_MODEL ?? "gpt-5.6-luna" -const marker = "commandcode-live-e2e-ok" - -function findPiBinary() { - if (process.env.PI_BIN) return process.env.PI_BIN - const localBin = resolve(projectDir, "node_modules", ".bin") - for (const entry of (process.env.PATH ?? "").split(delimiter)) { - const candidate = resolve(entry, "pi") - if (candidate.startsWith(localBin)) continue - try { - accessSync(candidate, constants.X_OK) - return candidate - } catch { - // Try the next PATH entry. - } - } - return undefined -} - -function hasAuthMetadata() { - return ( - Boolean(process.env.COMMAND_CODE_API_KEY) || - Boolean(process.env.COMMANDCODE_API_KEY) || - existsSync(join(homedir(), ".commandcode", "auth.json")) || - existsSync(join(homedir(), ".pi", "agent", "auth.json")) - ) -} - -const piBin = findPiBinary() -if (!piBin || !hasAuthMetadata()) { - console.log("[live-e2e] SKIP — pi or Command Code auth metadata unavailable") - process.exit(0) -} - -const profileAgentDir = testProfile - ? mkdtempSync(join(tmpdir(), `pi-commandcode-live-${testProfile}-agent-`)) - : undefined - -function safeEnv(overrides = {}) { - const env = { ...process.env, PI_SKIP_VERSION_CHECK: "1", ...overrides } - if (testProfile && profileAgentDir) { - env.PI_CODING_AGENT_DIR = profileAgentDir - env.COMMANDCODE_MODELS_CACHE = join(profileAgentDir, "commandcode-models.json") - } else { - delete env.COMMAND_CODE_API_KEY - delete env.COMMANDCODE_API_KEY - } - return env -} - -function run(command, args, options = {}) { - const timeoutMs = options.timeoutMs ?? 180_000 - return new Promise((resolve) => { - const child = spawn(command, args, { - cwd: options.cwd ?? projectDir, - env: options.env ?? safeEnv(), - stdio: ["ignore", "pipe", "pipe"], - }) - let stdout = "" - let stderr = "" - const timer = setTimeout(() => { - child.kill() - resolve({ code: -1, stdout, stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms` }) - }, timeoutMs) - child.stdout.on("data", (chunk) => { - stdout += chunk.toString("utf-8") - }) - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8") - }) - child.on("close", (code) => { - clearTimeout(timer) - resolve({ code, stdout, stderr }) - }) - }) -} - -async function runRpc(extension, action, timeoutMs = 120_000, model = testModel) { - const child = spawn( - piBin, - [ - "--no-extensions", - "--mode", - "rpc", - "-e", - extension, - "--provider", - "commandcode", - "--model", - model, - "--thinking", - "high", - ], - { cwd: projectDir, env: safeEnv(), stdio: ["pipe", "pipe", "pipe"] }, - ) - - let buffer = "" - let stderr = "" - const events = [] - const waiters = [] - - const publish = (event) => { - events.push(event) - for (let index = waiters.length - 1; index >= 0; index -= 1) { - const waiter = waiters[index] - if (!waiter.predicate(event)) continue - waiters.splice(index, 1) - clearTimeout(waiter.timer) - waiter.resolve(event) - } - } - - child.stdout.on("data", (chunk) => { - buffer += chunk.toString("utf-8") - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - for (const line of lines) { - if (!line.trim()) continue - try { - publish(JSON.parse(line)) - } catch { - // Ignore non-JSON output. - } - } - }) - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8") - }) - - const waitFor = (predicate) => - new Promise((resolveWait, reject) => { - const existing = events.find(predicate) - if (existing) { - resolveWait(existing) - return - } - const timer = setTimeout(() => { - const index = waiters.findIndex((waiter) => waiter.timer === timer) - if (index >= 0) waiters.splice(index, 1) - reject(new Error(`RPC timeout. stderr: ${stderr.slice(-500)}`)) - }, timeoutMs) - waiters.push({ predicate, resolve: resolveWait, timer }) - }) - const send = (value) => child.stdin.write(`${JSON.stringify(value)}\n`) - - try { - return await action({ send, waitFor, events, getStderr: () => stderr }) - } finally { - child.kill() - } -} - -const tempRoot = mkdtempSync(join(tmpdir(), "pi-commandcode-live-e2e-")) -try { - console.log("[live-e2e] live reasoning request") - const reasoning = await run( - piBin, - [ - "--no-extensions", - "-e", - extensionPath, - "--no-session", - "-p", - "--provider", - "commandcode", - "--model", - testModel, - "--thinking", - "high", - `Reply exactly: ${marker}`, - ], - { timeoutMs: 180_000 }, - ) - assert.equal(reasoning.code, 0, reasoning.stderr) - assert.match(reasoning.stdout, new RegExp(marker)) - - console.log("[live-e2e] live multi-turn reasoning history") - const multiTurn = await runRpc(extensionPath, async ({ send, waitFor, events, getStderr }) => { - const countThinkingDeltas = (startIndex) => - events - .slice(startIndex) - .filter( - (event) => - event.type === "message_update" && - event.assistantMessageEvent?.type === "thinking_delta" && - typeof event.assistantMessageEvent.delta === "string" && - event.assistantMessageEvent.delta.length > 0, - ).length - - const firstStart = events.length - send({ - id: "reasoning-turn-1", - type: "prompt", - message: - "Reason step by step before answering. Calculate 37 * 41, then reply with only the number.", - }) - await waitFor( - (event) => event.type === "response" && event.id === "reasoning-turn-1" && event.success, - ) - const firstSettled = await waitFor( - (event) => event.type === "agent_settled" && events.indexOf(event) >= firstStart, - ) - const firstSettledIndex = events.indexOf(firstSettled) - const firstThinkingDeltas = events - .slice(firstStart, firstSettledIndex + 1) - .filter( - (event) => - event.type === "message_update" && - event.assistantMessageEvent?.type === "thinking_delta" && - typeof event.assistantMessageEvent.delta === "string" && - event.assistantMessageEvent.delta.length > 0, - ).length - - const secondStart = events.length - send({ - id: "reasoning-turn-2", - type: "prompt", - message: - "Now reason step by step again. Add 19 to your previous numeric result, then reply with only the number.", - }) - await waitFor( - (event) => event.type === "response" && event.id === "reasoning-turn-2" && event.success, - ) - const secondSettled = await waitFor( - (event) => event.type === "agent_settled" && events.indexOf(event) >= secondStart, - ) - const secondThinkingDeltas = countThinkingDeltas(secondStart) - assert.ok(events.indexOf(secondSettled) >= secondStart) - - return { firstThinkingDeltas, secondThinkingDeltas, stderr: getStderr() } - }) - assert.ok(multiTurn.firstThinkingDeltas > 0, "first turn should stream reasoning") - assert.ok(multiTurn.secondThinkingDeltas > 0, "follow-up turn should stream fresh reasoning") - assert.doesNotMatch(multiTurn.stderr, /Bearer\s+\S+/i) - - console.log("[live-e2e] live runtime refresh/status commands") - const runtime = await runRpc(extensionPath, async ({ send, waitFor, getStderr }) => { - if (expectedTransport) { - send({ id: "transport-probe", type: "prompt", message: `Reply exactly: ${marker}` }) - await waitFor( - (event) => event.type === "response" && event.id === "transport-probe" && event.success, - ) - await waitFor((event) => event.type === "agent_settled") - } - - send({ id: "commands", type: "get_commands" }) - const commands = await waitFor( - (event) => event.type === "response" && event.id === "commands" && event.success, - ) - const names = commands.data?.commands?.map((command) => command.name) ?? [] - - send({ id: "refresh", type: "prompt", message: "/commandcode-refresh" }) - await waitFor((event) => event.type === "response" && event.id === "refresh" && event.success) - const refresh = await waitFor( - (event) => - event.type === "extension_ui_request" && - event.method === "notify" && - typeof event.message === "string" && - event.message.includes("model catalog"), - ) - - send({ id: "status", type: "prompt", message: "/commandcode-status" }) - await waitFor((event) => event.type === "response" && event.id === "status" && event.success) - const status = await waitFor( - (event) => - event.type === "extension_ui_request" && - event.method === "notify" && - typeof event.message === "string" && - event.message.includes("source:"), - ) - - send({ id: "quota", type: "prompt", message: "/commandcode-quota" }) - await waitFor((event) => event.type === "response" && event.id === "quota" && event.success) - const quota = await waitFor( - (event) => - event.type === "extension_ui_request" && - event.method === "notify" && - typeof event.message === "string" && - event.message.includes("Plan:"), - ) - return { - names, - refresh: refresh.message, - status: status.message, - quota: quota.message, - stderr: getStderr(), - } - }) - assert.ok(runtime.names.includes("commandcode-refresh")) - assert.ok(runtime.names.includes("commandcode-status")) - assert.ok(runtime.names.includes("commandcode-quota")) - assert.match(runtime.refresh, /model catalog (?:refreshed|unchanged)/) - if (expectedTransport) assert.match(runtime.status, new RegExp(`transport: ${expectedTransport}`)) - assert.match(runtime.status, /source: (?:live|cache)/) - assert.match(runtime.status, /model count: [1-9][0-9]*/) - if (expectedPlan) assert.match(runtime.quota, new RegExp(`Plan:.*\\b${expectedPlan}\\b`, "i")) - assert.doesNotMatch( - `${runtime.refresh}\n${runtime.status}\n${runtime.quota}\n${runtime.stderr}`, - /Bearer\s+\S+/i, - ) - - console.log("[live-e2e] live abort through real RPC host") - const abortResult = await runRpc(extensionPath, async ({ send, waitFor, events, getStderr }) => { - const startIndex = events.length - send({ - id: "abort-turn", - type: "prompt", - message: "Write a very long detailed explanation of every integer from 1 to 10000.", - }) - await waitFor( - (event) => event.type === "response" && event.id === "abort-turn" && event.success, - ) - await waitFor((event) => event.type === "message_update" && events.indexOf(event) >= startIndex) - send({ id: "abort", type: "abort" }) - await waitFor((event) => event.type === "response" && event.id === "abort" && event.success) - await waitFor((event) => event.type === "agent_settled" && events.indexOf(event) >= startIndex) - return { - aborted: events - .slice(startIndex) - .some( - (event) => - event.type === "message_end" && - event.message?.role === "assistant" && - event.message?.stopReason === "aborted", - ), - stderr: getStderr(), - } - }) - assert.equal(abortResult.aborted, true) - assert.doesNotMatch(abortResult.stderr, /Bearer\s+\S+/i) - - console.log("[live-e2e] live tool-call round trip") - const toolRoot = join(tempRoot, "tool-roundtrip") - const targetPath = join(toolRoot, "commandcode-e2e.txt") - const toolPrompt = [ - `Use the write tool to create ${targetPath}.`, - `The file content must be exactly ${marker}.`, - `After the tool succeeds, reply exactly: ${marker}`, - ].join(" ") - const toolResult = await run( - piBin, - [ - "--no-extensions", - "-e", - extensionPath, - "--no-session", - "-p", - "--provider", - "commandcode", - "--model", - testModel, - toolPrompt, - ], - { cwd: tempRoot, timeoutMs: 180_000 }, - ) - assert.equal(toolResult.code, 0, toolResult.stderr) - assert.match(toolResult.stdout, new RegExp(marker)) - assert.equal(readFileSync(targetPath, "utf-8").trimEnd(), marker) - - if (testProfile === "goat") { - console.log("[live-e2e] live vision request through Provider API") - const vision = await runRpc( - extensionPath, - async ({ send, waitFor, events, getStderr }) => { - const startIndex = events.length - send({ - id: "vision", - type: "prompt", - message: "Describe the attached image briefly.", - images: [ - { - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", - mimeType: "image/png", - }, - ], - }) - await waitFor( - (event) => event.type === "response" && event.id === "vision" && event.success, - ) - await waitFor( - (event) => event.type === "agent_settled" && events.indexOf(event) >= startIndex, - ) - const messageEnd = events - .slice(startIndex) - .find((event) => event.type === "message_end" && event.message?.role === "assistant") - return { messageEnd, stderr: getStderr() } - }, - 180_000, - goatVisionModel, - ) - assert.notEqual(vision.messageEnd?.message?.stopReason, "error") - assert.doesNotMatch(vision.stderr, /Bearer\s+\S+/i) - } - - if (testProfile === "go") { - console.log("[live-e2e] image rejection through real RPC host") - const image = await runRpc(extensionPath, async ({ send, waitFor, events }) => { - send({ - id: "image", - type: "prompt", - message: "Describe this image", - images: [{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }], - }) - await waitFor((event) => event.type === "response" && event.id === "image") - await waitFor( - (event) => - event.type === "message_end" && - event.message?.role === "assistant" && - event.message?.stopReason === "error", - ) - return events - }) - assert.ok( - image.some( - (event) => - event.type === "message_end" && - /does not support image content/i.test(event.message?.errorMessage ?? ""), - ), - ) - } - - console.log("[live-e2e] packed artifact with existing authentication") - const packDir = join(tempRoot, "pack") - mkdirSync(packDir, { recursive: true }) - const pack = await run("npm", ["pack", "--pack-destination", packDir, "--silent"], { - timeoutMs: 120_000, - }) - assert.equal(pack.code, 0, pack.stderr) - const tarballName = pack.stdout.trim().split("\n").at(-1) - assert.ok(tarballName) - const tarball = join(packDir, tarballName) - const appDir = join(tempRoot, "packed-app") - const install = await run( - "npm", - ["install", "--prefix", appDir, "--ignore-scripts", "--no-save", tarball], - { timeoutMs: 180_000 }, - ) - assert.equal(install.code, 0, install.stderr) - const packedExtension = join(appDir, "node_modules", "pi-commandcode-provider", "index.ts") - const packedLive = await run( - piBin, - [ - "--no-extensions", - "-e", - packedExtension, - "--no-session", - "-p", - "--provider", - "commandcode", - "--model", - testModel, - `Reply exactly: ${marker}`, - ], - { timeoutMs: 180_000 }, - ) - assert.equal(packedLive.code, 0, packedLive.stderr) - assert.match(packedLive.stdout, new RegExp(marker)) - - console.log("[live-e2e] PASS") -} finally { - rmSync(tempRoot, { recursive: true, force: true }) - if (profileAgentDir) rmSync(profileAgentDir, { recursive: true, force: true }) -} diff --git a/tests/test-model-metadata-check.ts b/tests/test-model-metadata-check.ts deleted file mode 100644 index 97fe30e..0000000 --- a/tests/test-model-metadata-check.ts +++ /dev/null @@ -1,170 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { - commandCodeModelMetadataFromContents, - diffModelMetadata, - hasModelMetadataDiff, - parseBundleModelCapabilities, - parseKnownTextOnlyModelIds, - parseModelsReference, - parsePackageVersion, - renderCommandCodeCatalog, - updateReadmeCatalogVersion, - type CommandCodeModelMetadata, -} from "../.github/scripts/check-commandcode-model-metadata.ts" - -const MODELS_REFERENCE = ` -| Id (use EXACTLY this) | Name | Context | Efforts | $/1M in/out · cache read | Min plan | Best for | -|---|---|---|---|---|---|---| -| \`vision-model\` | Vision | 1M | low, high | $1/$2 | Go | images | -| \`text-model\` | Text | 200K | — | $1/$2 | Go | text | -` - -const CLI_BUNDLE = - 'const V={id:"vision-model",inputModalities:["text","image"],reasoning:!0,reasoningEfforts:["low","high"],maxOutputTokens:32768},T={id:"text-model",inputModalities:["text"]},catalog=new Set(["text-model"]),__name(isKnownTextOnlyModel,"isKnownTextOnlyModel")' - -describe("Command Code model metadata checker", () => { - it("parses model ids and reasoning efforts from the generated reference", () => { - assert.deepEqual(parseModelsReference(MODELS_REFERENCE), { - modelIds: ["text-model", "vision-model"], - reasoningEfforts: { "vision-model": ["low", "high"] }, - }) - }) - - it("extracts the text-only set from the bundled CLI catalog", () => { - assert.deepEqual(parseKnownTextOnlyModelIds(CLI_BUNDLE), ["text-model"]) - }) - - it("accepts one exact npm registry version and rejects stale-looking output shapes", () => { - assert.equal(parsePackageVersion("1.32.2"), "1.32.2") - assert.equal(parsePackageVersion("2.0.0-beta.1"), "2.0.0-beta.1") - assert.throws(() => parsePackageVersion(["1.32.1", "1.32.2"]), /one semantic version/) - assert.throws(() => parsePackageVersion("latest"), /one semantic version/) - }) - - it("derives image, reasoning, effort, and output-limit metadata", () => { - assert.deepEqual(parseBundleModelCapabilities(CLI_BUNDLE, ["text-model", "vision-model"]), { - reasoningModelIds: ["vision-model"], - maxOutputTokens: { "vision-model": 32_768 }, - }) - assert.deepEqual(commandCodeModelMetadataFromContents(MODELS_REFERENCE, CLI_BUNDLE), { - imageModelIds: ["vision-model"], - reasoningModelIds: ["vision-model"], - reasoningEfforts: { "vision-model": ["low", "high"] }, - maxOutputTokens: { "vision-model": 32_768 }, - }) - }) - - it("reports additions, removals, and changed reasoning efforts", () => { - const current: CommandCodeModelMetadata = { - imageModelIds: ["removed-image", "stable-image"], - reasoningModelIds: ["removed-reasoning", "stable-reasoning"], - reasoningEfforts: { - "changed-effort": ["low"], - "removed-effort": ["high"], - "stable-effort": ["low", "high"], - }, - maxOutputTokens: { "changed-output": 1, "removed-output": 2, "stable-output": 3 }, - } - const upstream: CommandCodeModelMetadata = { - imageModelIds: ["added-image", "stable-image"], - reasoningModelIds: ["added-reasoning", "stable-reasoning"], - reasoningEfforts: { - "added-effort": ["max"], - "changed-effort": ["low", "high"], - "stable-effort": ["low", "high"], - }, - maxOutputTokens: { "added-output": 4, "changed-output": 5, "stable-output": 3 }, - } - - const diff = diffModelMetadata(current, upstream) - - assert.deepEqual(diff, { - versionChanged: false, - addedImageModelIds: ["added-image"], - removedImageModelIds: ["removed-image"], - addedReasoningModelIds: ["added-reasoning"], - removedReasoningModelIds: ["removed-reasoning"], - addedEffortModelIds: ["added-effort"], - removedEffortModelIds: ["removed-effort"], - changedEffortModelIds: ["changed-effort"], - addedMaxOutputModelIds: ["added-output"], - removedMaxOutputModelIds: ["removed-output"], - changedMaxOutputModelIds: ["changed-output"], - }) - assert.equal(hasModelMetadataDiff(diff), true) - }) - - it("reports CLI version drift even when model metadata is unchanged", () => { - const metadata: CommandCodeModelMetadata = { - imageModelIds: ["vision-model"], - reasoningModelIds: ["vision-model"], - reasoningEfforts: { "vision-model": ["low"] }, - maxOutputTokens: { "vision-model": 32_768 }, - } - - const diff = diffModelMetadata(metadata, metadata, "1.32.2", "1.33.0") - - assert.equal(diff.versionChanged, true) - assert.equal(hasModelMetadataDiff(diff), true) - }) - - it("renders a deterministic generated catalog and updates the README version", () => { - assert.equal( - renderCommandCodeCatalog("1.33.0", { - imageModelIds: ["b-model", "a-model"], - reasoningModelIds: ["c-model", "a-model"], - reasoningEfforts: { - "b-model": ["high", "max"], - "a-model": ["low"], - }, - maxOutputTokens: { "b-model": 32_768 }, - }), - `export const COMMAND_CODE_CLI_VERSION = "1.33.0" - -export type CommandCodeInputType = "text" | "image" -export type CommandCodeReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" - -/** - * Generated from command-code@1.33.0 by \`npm run sync:commandcode-catalog\`. - * Do not edit manually. - */ -export const MODEL_INPUT_MODALITIES: Readonly> = { - "a-model": ["text", "image"], - "b-model": ["text", "image"], -} - -export const MODEL_REASONING: Readonly> = { - "a-model": true, - "c-model": true, -} - -export const MODEL_EFFORTS: Readonly> = { - "a-model": ["low"], - "b-model": ["high", "max"], -} - -export const MODEL_MAX_OUTPUT_TOKENS: Readonly> = { - "b-model": 32_768, -} -`, - ) - assert.equal( - updateReadmeCatalogVersion( - "The capability snapshot currently follows `command-code@1.32.2`.", - "1.33.0", - ), - "The capability snapshot currently follows `command-code@1.33.0`.", - ) - }) - - it("rejects unexpected upstream structures instead of silently passing", () => { - assert.throws(() => parseModelsReference("# no catalog"), /No model rows/) - assert.throws( - () => parseModelsReference(MODELS_REFERENCE.replace("low, high", "low, turbo")), - /Unexpected reasoning efforts/, - ) - assert.throws(() => parseKnownTextOnlyModelIds("const unrelated = true"), /Could not find/) - }) -}) diff --git a/tests/test-models.ts b/tests/test-models.ts deleted file mode 100644 index 7c722a0..0000000 --- a/tests/test-models.ts +++ /dev/null @@ -1,477 +0,0 @@ -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 { MODEL_EFFORT_OVERRIDES } from "../src/commandcode-catalog-overrides.ts" -import { - COMMAND_CODE_CLI_VERSION, - MODEL_EFFORTS as CATALOG_MODEL_EFFORTS, -} from "../src/commandcode-catalog.ts" -import { - apiForModelId, - baseUrlForModel, - commandCodeModelsFromApiResponse, - commandCodeModelsFromCache, - DEFAULT_MODELS_TIMEOUT_MS, - getModelsTimeoutMs, - inputModalitiesForModel, - loadCommandCodeModels, - MODEL_EFFORTS, - MODEL_INPUT_MODALITIES, - MODEL_MAX_OUTPUT_TOKENS, - MODEL_REASONING, - modelSupportsImageInput, - thinkingLevelMapForEfforts, - thinkingMetadataForModel, - 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)", - api: "openai-completions", - 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)) -} - -function hangingFetch(): typeof fetch { - return (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener( - "abort", - () => reject(init.signal?.reason ?? new DOMException("Aborted", "AbortError")), - { once: true }, - ) - }) -} - -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", () => { - assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS) - }) - - it("routes Claude models to Anthropic Messages and all others to Chat Completions", () => { - assert.equal(apiForModelId("claude-sonnet-4-6"), "anthropic-messages") - assert.equal(apiForModelId("gpt-5.6-sol"), "openai-completions") - assert.equal( - baseUrlForModel("https://api.commandcode.ai/provider/v1/", "openai-completions"), - "https://api.commandcode.ai/provider/v1", - ) - assert.equal( - baseUrlForModel("https://api.commandcode.ai/provider/v1/", "anthropic-messages"), - "https://api.commandcode.ai/provider", - ) - }) - - it(`uses the command-code@${COMMAND_CODE_CLI_VERSION} image capability catalog`, () => { - const imageModels = Object.keys(MODEL_INPUT_MODALITIES) - assert.ok(imageModels.length > 0) - for (const modelId of imageModels) { - assert.deepEqual(MODEL_INPUT_MODALITIES[modelId], ["text", "image"], modelId) - assert.deepEqual(inputModalitiesForModel(modelId), ["text", "image"], modelId) - assert.equal(modelSupportsImageInput(modelId), true, modelId) - } - - const textOnlyModel = Object.keys(MODEL_REASONING).find( - (modelId) => !(modelId in MODEL_INPUT_MODALITIES), - ) - assert.ok(textOnlyModel, "catalog should contain at least one text-only model") - assert.deepEqual(inputModalitiesForModel(textOnlyModel), ["text"]) - assert.equal(modelSupportsImageInput(textOnlyModel), false) - assert.deepEqual(inputModalitiesForModel("unknown-new-model"), ["text"]) - assert.equal(modelSupportsImageInput("unknown-new-model"), false) - }) - - it("exposes DeepSeek V4.1 vision input and its low/high/max thinking levels", () => { - const modelId = "deepseek/deepseek-v4.1-flash" - assert.deepEqual(inputModalitiesForModel(modelId), ["text", "image"]) - assert.equal(modelSupportsImageInput(modelId), true) - assert.equal(MODEL_REASONING[modelId], true) - assert.deepEqual(MODEL_EFFORTS[modelId], ["low", "high", "max"]) - assert.deepEqual(thinkingMetadataForModel(modelId)?.thinkingLevelMap, { - minimal: null, - low: "low", - medium: null, - high: "high", - xhigh: null, - max: "max", - }) - }) - - it("tracks reasoning independently from selectable effort levels", () => { - const reasoningModels = Object.keys(MODEL_REASONING) - const effortModels = Object.keys(MODEL_EFFORTS) - assert.ok(reasoningModels.length > 0) - assert.ok(effortModels.length > 0) - for (const modelId of effortModels) { - assert.equal(MODEL_REASONING[modelId], true, `${modelId} has efforts but no reasoning flag`) - } - - const reasoningWithoutEfforts = reasoningModels.find((modelId) => !(modelId in MODEL_EFFORTS)) - assert.ok(reasoningWithoutEfforts, "catalog should contain a reasoning model without efforts") - - const models = commandCodeModelsFromApiResponse({ - object: "list", - data: [ - { ...API_RESPONSE.data[0], id: effortModels[0] }, - { ...API_RESPONSE.data[0], id: reasoningWithoutEfforts }, - { ...API_RESPONSE.data[0], id: "new-model-without-metadata" }, - ], - }) - - assert.equal(models[0]?.reasoning, true) - assert.equal(models[1]?.reasoning, true) - assert.deepEqual(thinkingMetadataForModel(reasoningWithoutEfforts), { - thinkingLevelMap: { - minimal: null, - low: null, - medium: null, - high: null, - xhigh: null, - max: null, - }, - }) - assert.equal(models[2]?.reasoning, false) - }) - - it("uses model-specific output limits from the CLI catalog", () => { - const limitedModels = Object.entries(MODEL_MAX_OUTPUT_TOKENS) - assert.ok(limitedModels.length > 0) - for (const [modelId, limit] of limitedModels) { - assert.ok(Number.isInteger(limit) && limit > 0, `${modelId} has an invalid output limit`) - } - - const [limitedId, limit] = limitedModels[0]! - const models = commandCodeModelsFromApiResponse({ - object: "list", - data: [ - { ...API_RESPONSE.data[0], id: limitedId, context_length: limit * 4 }, - { ...API_RESPONSE.data[0], id: limitedId, context_length: Math.floor(limit / 2) }, - { ...API_RESPONSE.data[0], id: "unknown-new-model", context_length: 256_000 }, - { ...API_RESPONSE.data[0], id: "unknown-new-model", context_length: 8_192 }, - ], - }) - - assert.deepEqual( - models.map(({ maxTokens }) => maxTokens), - [limit, Math.floor(limit / 2), 65_536, 8_192], - ) - }) - - it(`uses the command-code@${COMMAND_CODE_CLI_VERSION} reasoning effort catalog`, () => { - const validEfforts = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]) - assert.ok(Object.keys(MODEL_EFFORTS).length > 0) - for (const efforts of Object.values(MODEL_EFFORTS)) { - assert.ok(efforts.length > 0) - assert.equal(new Set(efforts).size, efforts.length) - assert.ok(efforts.every((effort) => validEfforts.has(effort))) - } - }) - - it("merges manual effort overrides over the generated catalog", () => { - const validEfforts = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]) - for (const [modelId, efforts] of Object.entries(MODEL_EFFORT_OVERRIDES)) { - assert.equal(MODEL_REASONING[modelId], true, `${modelId} override needs a reasoning flag`) - assert.equal( - CATALOG_MODEL_EFFORTS[modelId], - undefined, - `${modelId} now has upstream efforts; drop the manual override`, - ) - assert.ok(efforts.length > 0) - assert.ok(efforts.every((effort) => validEfforts.has(effort))) - assert.deepEqual(MODEL_EFFORTS[modelId], efforts) - assert.deepEqual(thinkingMetadataForModel(modelId)?.thinking?.efforts, efforts) - } - for (const [modelId, efforts] of Object.entries(CATALOG_MODEL_EFFORTS)) { - assert.deepEqual(MODEL_EFFORTS[modelId], efforts, `${modelId} upstream efforts changed`) - } - }) - - it("builds separate canonical pi and OMP metadata", () => { - for (const [modelId, efforts] of Object.entries(MODEL_EFFORTS)) { - const metadata = thinkingMetadataForModel(modelId) - assert.ok(metadata, `${modelId} should have reasoning metadata`) - assert.ok(metadata.thinking) - assert.equal(metadata.thinking.mode, "effort") - assert.deepEqual(metadata.thinking.efforts, efforts) - assert.deepEqual( - metadata.thinking.effortMap, - Object.fromEntries(efforts.map((effort) => [effort, effort])), - ) - assert.equal("defaultLevel" in metadata.thinking, false) - for (const level of ["minimal", "low", "medium", "high", "xhigh", "max"] as const) { - const expected = efforts.includes(level) - assert.equal( - metadata.thinkingLevelMap[level], - expected ? level : null, - `${modelId} should map ${level} according to its catalog entry`, - ) - } - } - - assert.deepEqual(thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), { - minimal: null, - low: null, - medium: null, - high: "high", - xhigh: null, - max: "max", - }) - assert.deepEqual(thinkingMetadataForModel("new-model-without-metadata"), undefined) - }) - - 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("normalizes cached reasoning metadata from the model id", () => { - const cached = commandCodeModelsFromCache({ - version: 1, - models: [ - { - ...EXPECTED_MODELS[0], - id: "deepseek/deepseek-v4-flash", - reasoning: false, - }, - ], - }) - assert.equal(cached[0]?.reasoning, true) - }) - - 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("model discovery configuration", () => { - it("uses a safe default timeout and ignores invalid environment values", () => { - assert.equal(getModelsTimeoutMs({}), DEFAULT_MODELS_TIMEOUT_MS) - assert.equal( - getModelsTimeoutMs({ COMMANDCODE_MODELS_TIMEOUT_MS: "0" }), - DEFAULT_MODELS_TIMEOUT_MS, - ) - assert.equal( - getModelsTimeoutMs({ COMMANDCODE_MODELS_TIMEOUT_MS: "invalid" }), - DEFAULT_MODELS_TIMEOUT_MS, - ) - assert.equal(getModelsTimeoutMs({ COMMANDCODE_MODELS_TIMEOUT_MS: "25" }), 25) - }) -}) - -describe("loadCommandCodeModels()", () => { - it("falls back to cache when live discovery times out", async () => { - await withTemporaryCache(async ({ cachePath }) => { - await loadCommandCodeModels({ cachePath, fetchImpl: successfulFetch() }) - - const startedAt = Date.now() - const result = await loadCommandCodeModels({ - cachePath, - fetchImpl: hangingFetch(), - timeoutMs: 25, - }) - - assert.ok(Date.now() - startedAt < 500) - assert.deepEqual(result.models, EXPECTED_MODELS) - assert.equal(result.source, "cache") - assert.match(result.warning ?? "", /timed out after 25ms/) - assert.match(result.warning ?? "", /Using the cached catalog/) - }) - }) - - it("preserves an external abort instead of falling back to cache", async () => { - await withTemporaryCache(async ({ cachePath }) => { - await loadCommandCodeModels({ cachePath, fetchImpl: successfulFetch() }) - const controller = new AbortController() - const promise = loadCommandCodeModels({ - cachePath, - fetchImpl: hangingFetch(), - timeoutMs: 1_000, - signal: controller.signal, - }) - - controller.abort(new Error("caller cancelled discovery")) - - await assert.rejects(promise, /caller cancelled discovery/) - }) - }) - - 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 \/commandcode-refresh 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") - } - }) - }) -}) diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts deleted file mode 100644 index 6a64381..0000000 --- a/tests/test-oauth.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * Tests for the Command Code OAuth / browser auth flow. - * - * Tests the local callback server (src/auth-server.ts) and the OAuth - * integration functions (src/oauth.ts). - */ - -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { startAuthServer, type AuthCallback } from "../src/auth-server.ts" -import { getApiKey, login, refreshToken, sanitizeApiKey, validateApiKey } from "../src/oauth.ts" - -/** - * Helper: wait for an HTTP server to close, or resolve immediately if already closed. - */ -function waitForClose(server: { - listening: boolean - on(event: "close", cb: () => void): void -}): Promise { - return new Promise((resolve) => { - if (!server.listening) return resolve(undefined) - server.on("close", resolve) - }) -} - -async function withValidApiKeyFetch(run: () => Promise): Promise { - const originalFetch = globalThis.fetch - globalThis.fetch = (input, init) => { - if (String(input).endsWith("/alpha/whoami")) { - return Promise.resolve(new Response(JSON.stringify({ user: {} }), { status: 200 })) - } - return originalFetch(input, init) - } - try { - return await run() - } finally { - globalThis.fetch = originalFetch - } -} - -describe("startAuthServer()", () => { - it("starts on a localhost port and accepts a valid callback POST", async () => { - const { server, port, waitForCallback } = await startAuthServer({ - startPort: 0, - expectedState: "test-state-token", - }) - - const callbackData: AuthCallback = { - apiKey: "user_testKey123", - state: "test-state-token", - userId: "user_123", - userName: "Test User", - keyName: "test-key", - } - - // Simulate the Command Code Studio posting the API key back - const response = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, - body: JSON.stringify(callbackData), - }) - - assert.equal(response.status, 200) - const body = (await response.json()) as { success: boolean } - assert.equal(body.success, true) - - const result = await waitForCallback - assert.equal(result.apiKey, "user_testKey123") - assert.equal(result.state, "test-state-token") - assert.equal(result.userId, "user_123") - assert.equal(result.userName, "Test User") - assert.equal(result.keyName, "test-key") - - await waitForClose(server) - }) - - it("rejects a mismatched state without closing the callback server", async () => { - const { server, port, waitForCallback } = await startAuthServer({ - startPort: 0, - expectedState: "correct-state", - }) - - const invalidResponse = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, - body: JSON.stringify({ - apiKey: "user_badState", - state: "wrong-state", - userId: "user_789", - userName: "Attacker", - keyName: "evil-key", - }), - }) - assert.equal(invalidResponse.status, 403) - assert.equal(server.listening, true) - - const validResponse = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, - body: JSON.stringify({ - apiKey: "user_valid", - state: "correct-state", - userId: "user_123", - userName: "Valid User", - keyName: "valid-key", - }), - }) - assert.equal(validResponse.status, 200) - assert.equal((await waitForCallback).apiKey, "user_valid") - await waitForClose(server) - }) - - it("rejects when the callback indicates access_denied", async () => { - const { server, port, waitForCallback } = await startAuthServer({ startPort: 0 }) - - // Attach rejection handler before posting to avoid unhandled rejection - const errorPromise: Promise = waitForCallback.then( - () => { - throw new Error("Expected callback to reject") - }, - (e: Error) => e.message, - ) - - const response = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, - body: JSON.stringify({ error: "access_denied", error_description: "User cancelled" }), - }) - - assert.equal(response.status, 200) - - const errorMsg = await errorPromise - assert.match(errorMsg, /User cancelled/) - - await waitForClose(server) - }) - - it("returns 400 for missing required fields", async () => { - const { server, port } = await startAuthServer({ startPort: 0 }) - - const response = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, - body: JSON.stringify({ apiKey: "key", state: "s" }), - }) - - assert.equal(response.status, 400) - - await new Promise((resolve) => setTimeout(resolve, 100)) - server.close() - }) - - it("handles CORS and private-network preflight OPTIONS request", async () => { - const { server, port } = await startAuthServer({ startPort: 0 }) - - const response = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "OPTIONS", - headers: { - Origin: "https://commandcode.ai", - "Access-Control-Request-Headers": "content-type,x-requested-with", - "Access-Control-Request-Private-Network": "true", - }, - }) - - assert.equal(response.status, 204) - assert.equal(response.headers.get("access-control-allow-origin"), "https://commandcode.ai") - assert.equal( - response.headers.get("access-control-allow-headers"), - "content-type,x-requested-with", - ) - assert.equal(response.headers.get("access-control-allow-private-network"), "true") - - await new Promise((resolve) => setTimeout(resolve, 100)) - server.close() - }) - - it("returns 404 for non-callback paths", async () => { - const { server, port } = await startAuthServer({ startPort: 0 }) - - const response = await fetch(`http://127.0.0.1:${port}/other`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }) - - assert.equal(response.status, 404) - - await new Promise((resolve) => setTimeout(resolve, 100)) - server.close() - }) - - it("returns 405 for GET on /callback", async () => { - const { server, port } = await startAuthServer({ startPort: 0 }) - - const response = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "GET", - headers: { Origin: "https://commandcode.ai" }, - }) - - assert.equal(response.status, 405) - - await new Promise((resolve) => setTimeout(resolve, 100)) - server.close() - }) -}) - -describe("OAuth functions", () => { - it("getApiKey returns the access token", () => { - const creds = { - refresh: "refresh-key", - access: "access-key", - expires: Date.now() + 3600000, - } - assert.equal(getApiKey(creds), "access-key") - }) - - it("refreshToken returns updated far-future expiry", async () => { - const creds = { - refresh: "my-api-key", - access: "my-api-key", - expires: Date.now() - 1000, // already expired - } - const result = await refreshToken(creds) - assert.equal(result.access, "my-api-key") - assert.equal(result.refresh, "my-api-key") - assert.ok(result.expires > Date.now(), "expiry should be in the future") - }) - - it("sanitizeApiKey removes paste markers, control chars, and whitespace", () => { - assert.equal(sanitizeApiKey("\u001b[200~ user_manualKey\n\u001b[201~"), "user_manualKey") - }) - - it("validates manual API keys through whoami", async () => { - await validateApiKey("valid-key", { - fetchImpl: () => Promise.resolve(new Response(JSON.stringify({ user: {} }), { status: 200 })), - }) - await assert.rejects( - validateApiKey("invalid-key", { - fetchImpl: () => Promise.resolve(new Response("unauthorized", { status: 401 })), - }), - /Invalid Command Code API key/, - ) - }) -}) - -describe("login()", () => { - it("completes the full browser login flow via the local server", async () => { - let authUrl = "" - const callbacks = { - onAuth(params: { url: string }) { - authUrl = params.url - }, - onPrompt(_params: { message: string }): Promise { - return Promise.resolve("") - }, - } - - // Start login in the background - const loginPromise = login(callbacks) - - // Wait for onAuth to be called (it fires asynchronously after the auth server starts) - while (!authUrl) await new Promise((resolve) => setTimeout(resolve, 10)) - - // Verify the auth URL was passed to callbacks (callback URL is encoded) - assert.match( - authUrl, - /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?callback=http%3A%2F%2Flocalhost%3A\d+%2Fcallback&state=/, - ) - - // Extract port and state from the URL - const url = new URL(authUrl) - const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0") - const state = url.searchParams.get("state") ?? "" - - assert.ok(port > 0, "auth server should be on a non-zero port") - assert.ok(state.length > 0, "state token should not be empty") - - // Simulate the Command Code Studio posting the API key back - const response = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://commandcode.ai", - }, - body: JSON.stringify({ - apiKey: "user_browserApiKey", - state, - userId: "user_456", - userName: "Browser User", - keyName: "browser-key", - }), - }) - - assert.equal(response.status, 200) - - const result = await loginPromise - assert.equal(result.access, "user_browserApiKey") - assert.equal(result.refresh, "user_browserApiKey") - assert.ok(result.expires > Date.now(), "expiry should be far in the future") - }) - - it("prompts for a manual API key if browser transfer times out", async () => { - const originalTimeout = process.env.COMMANDCODE_AUTH_TIMEOUT_MS - process.env.COMMANDCODE_AUTH_TIMEOUT_MS = "1" - - let authUrl = "" - const promptMessages: string[] = [] - - try { - const result = await withValidApiKeyFetch(() => - login({ - onAuth(params: { url: string }) { - authUrl = params.url - }, - async onPrompt(params: { message: string }): Promise { - promptMessages.push(params.message) - return promptMessages.length === 1 ? "" : "\u001b[200~ user_manualApiKey\n\u001b[201~" - }, - }), - ) - - assert.match(authUrl, /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?/) - assert.match(promptMessages[1] ?? "", /Paste your Command Code API key/) - assert.equal(result.access, "user_manualApiKey") - assert.equal(result.refresh, "user_manualApiKey") - assert.ok(result.expires > Date.now(), "expiry should be far in the future") - } finally { - if (originalTimeout === undefined) delete process.env.COMMANDCODE_AUTH_TIMEOUT_MS - else process.env.COMMANDCODE_AUTH_TIMEOUT_MS = originalTimeout - } - }) - - it("accepts a directly pasted API key", async () => { - let authOpened = false - const result = await withValidApiKeyFetch(() => - login({ - onAuth() { - authOpened = true - }, - onPrompt(): Promise { - return Promise.resolve("user_directApiKey") - }, - }), - ) - - assert.equal(authOpened, false) - assert.equal(result.access, "user_directApiKey") - }) - - it("offers an explicit API key prompt", async () => { - let promptCount = 0 - const result = await withValidApiKeyFetch(() => - login({ - onAuth() { - throw new Error("browser should not open") - }, - onPrompt(): Promise { - promptCount += 1 - return Promise.resolve(promptCount === 1 ? "key" : "user_promptedApiKey") - }, - }), - ) - - assert.equal(result.access, "user_promptedApiKey") - assert.equal(promptCount, 2) - }) - - it("keeps waiting after a state mismatch and accepts the legitimate callback", async () => { - let authUrl = "" - const callbacks = { - onAuth(params: { url: string }) { - authUrl = params.url - }, - onPrompt(_params: { message: string }): Promise { - return Promise.resolve("") - }, - } - - const loginPromise = login(callbacks) - - // Wait for onAuth to be called asynchronously - while (!authUrl) await new Promise((resolve) => setTimeout(resolve, 10)) - - const url = new URL(authUrl) - const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0") - - // Post back with a wrong state token. - const invalidResponse = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, - body: JSON.stringify({ - apiKey: "user_badState", - state: "wrong-state-token", - userId: "user_789", - userName: "Attacker", - keyName: "evil-key", - }), - }) - - assert.equal(invalidResponse.status, 403) - - const validResponse = await fetch(`http://127.0.0.1:${port}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, - body: JSON.stringify({ - apiKey: "user_goodState", - state: url.searchParams.get("state"), - userId: "user_123", - userName: "Real User", - keyName: "real-key", - }), - }) - assert.equal(validResponse.status, 200) - assert.equal((await loginPromise).access, "user_goodState") - }) -}) diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs deleted file mode 100644 index 3ab296e..0000000 --- a/tests/test-omp-compat.mjs +++ /dev/null @@ -1,467 +0,0 @@ -#!/usr/bin/env node -/** - * OMP compatibility smoke test. - * - * Uses an isolated HOME/PI_CODING_AGENT_DIR so the test does not depend on or - * mutate the user's real ~/.omp state. The Command Code API base is pointed at - * a deterministic local mock server so print mode can exercise the provider - * without touching the real API. - */ - -import assert from "node:assert/strict" -import { spawn, spawnSync } from "node:child_process" -import { accessSync, constants, mkdtempSync, rmSync } from "node:fs" -import { createServer } from "node:http" -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 ADVISORY_EXT_PATH = resolve(PROJECT_DIR, "tests/fixtures/advisory-injector-extension.ts") -const TEST_MODEL = "deepseek/deepseek-v4-flash" -const ADVISORY_XML = - '\nStop and correct the benchmark.\n' - -function findOmpBinary() { - if (process.env.OMP_BIN) return process.env.OMP_BIN - const candidates = (process.env.PATH ?? "").split(delimiter).map((entry) => resolve(entry, "omp")) - for (const candidate of candidates) { - try { - accessSync(candidate, constants.X_OK) - return candidate - } catch { - // Try next PATH entry. - } - } - return undefined -} - -const OMP_BIN = findOmpBinary() -if (!OMP_BIN) { - if (process.env.OMP_COMPAT_REQUIRED === "1") { - console.error("[omp-compat] FAIL - omp is required but not on PATH and OMP_BIN is unset") - process.exit(1) - } - console.log("[omp-compat] SKIP - omp is not on PATH") - process.exit(0) -} - -const tempHome = mkdtempSync(join(tmpdir(), "omp-cc-home-")) -let requestCount = 0 -let modelListRequestCount = 0 -let lastRequestBody -let requestBodies = [] -let lastRequestHeaders = {} -// When true the mock Provider API answers 403 upgrade_required so the -// transport router falls back to the legacy /alpha/generate transport. -let providerUpgradeRequired = false - -const server = createServer((req, res) => { - if (req.method === "GET" && req.url === "/provider/v1/models") { - modelListRequestCount += 1 - res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }) - res.end( - JSON.stringify({ - object: "list", - data: [ - { - id: TEST_MODEL, - object: "model", - created: 1779824324, - owned_by: "command-code", - name: "DeepSeek V4 Flash", - context_length: 1_000_000, - }, - { - id: "Qwen/Qwen3.7-Max", - object: "model", - created: 1779824324, - owned_by: "command-code", - name: "Qwen 3.7 Max", - context_length: 1_000_000, - }, - ], - }), - ) - return - } - - if (req.method === "POST" && req.url === "/provider/v1/chat/completions") { - requestCount += 1 - lastRequestHeaders = Object.fromEntries( - Object.entries(req.headers).map(([key, value]) => [ - key, - Array.isArray(value) ? value.join(", ") : (value ?? ""), - ]), - ) - - let body = "" - req.on("data", (chunk) => { - body += chunk.toString("utf-8") - }) - req.on("end", () => { - try { - lastRequestBody = JSON.parse(body) - requestBodies.push(lastRequestBody) - } catch { - lastRequestBody = undefined - } - - if (providerUpgradeRequired) { - res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" }) - res.end(JSON.stringify({ error: { code: "upgrade_required" } })) - return - } - - res.writeHead(200, { - "Content-Type": "text/event-stream; charset=utf-8", - "Transfer-Encoding": "chunked", - }) - res.write( - `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: "mock-omp-ok" }, finish_reason: null }] })}\n\n`, - ) - res.write( - `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`, - ) - res.write( - `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`, - ) - res.end("data: [DONE]\n\n") - }) - return - } - - if (req.method !== "POST" || req.url !== "/alpha/generate") { - res.writeHead(404) - res.end("Not found") - return - } - - requestCount += 1 - lastRequestHeaders = Object.fromEntries( - Object.entries(req.headers).map(([key, value]) => [ - key, - Array.isArray(value) ? value.join(", ") : (value ?? ""), - ]), - ) - - let generateBody = "" - req.on("data", (chunk) => { - generateBody += chunk.toString("utf-8") - }) - req.on("end", () => { - try { - lastRequestBody = JSON.parse(generateBody) - requestBodies.push(lastRequestBody) - } catch { - lastRequestBody = undefined - } - - res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", - }) - res.write(`${JSON.stringify({ type: "text-delta", text: "mock-omp-ok" })}\n`) - res.write( - `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`, - ) - res.end() - }) -}) - -await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) -const address = server.address() -const port = typeof address === "object" && address ? address.port : 0 -const apiBase = `http://127.0.0.1:${port}` - -const agentDir = join(tempHome, ".omp", "agent") - -function ompEnv(overrides = {}) { - const env = { - ...process.env, - HOME: tempHome, - USERPROFILE: tempHome, - PI_CODING_AGENT_DIR: agentDir, - COMMAND_CODE_API_KEY: "mock-key", - COMMANDCODE_API_BASE: `${apiBase}/provider/v1`, - COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, - } - for (const [key, value] of Object.entries(overrides)) { - if (value === undefined) delete env[key] - else env[key] = value - } - return env -} - -// Same DDL OMP 18 runs for its credential store; OMP's own migration is -// `CREATE TABLE IF NOT EXISTS`, so creating it first is safe. -const OMP_AUTH_CREDENTIALS_DDL = `CREATE TABLE IF NOT EXISTS auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - identity_key TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), - updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) -)` - -/** - * Store a credential the way OMP's `/login` does: in the `auth_credentials` - * table of `agent.db`. OMP is a Bun binary, so `bun:sqlite` is always - * available next to it and matches the SQLite build OMP itself uses. - * Pass `undefined` to remove any stored Command Code credential. - */ -function seedOmpCredential(credential) { - const rows = - credential === undefined - ? [] - : [ - [ - credential.type, - JSON.stringify( - credential.type === "oauth" - ? { - access: credential.access, - refresh: credential.refresh, - expires: credential.expires, - } - : { key: credential.key, source: "login" }, - ), - ], - ] - const script = ` - import { Database } from "bun:sqlite" - // \`bun -e\` has no script slot: argv is [bun, ...args]. - const [dbPath, ddl, rowsJson] = process.argv.slice(1) - const db = new Database(dbPath) - db.run(ddl) - db.run("DELETE FROM auth_credentials WHERE provider = ?", ["commandcode"]) - for (const [type, data] of JSON.parse(rowsJson)) { - db.run( - "INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?)", - ["commandcode", type, data], - ) - } - db.close() - ` - const result = spawnSync( - "bun", - ["-e", script, join(agentDir, "agent.db"), OMP_AUTH_CREDENTIALS_DDL, JSON.stringify(rows)], - { env: ompEnv(), encoding: "utf-8" }, - ) - assert.equal(result.status, 0, result.stderr) -} - -function runOmp(args, timeoutOrOptions = 30_000) { - const options = - typeof timeoutOrOptions === "number" ? { timeoutMs: timeoutOrOptions } : timeoutOrOptions - const timeoutMs = options.timeoutMs ?? 30_000 - return new Promise((resolve) => { - const child = spawn(OMP_BIN, args, { - cwd: PROJECT_DIR, - env: ompEnv(options.env), - stdio: ["ignore", "pipe", "pipe"], - }) - let stdout = "" - let stderr = "" - const timer = setTimeout(() => { - child.kill() - resolve({ - code: -1, - stdout, - stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms`, - }) - }, timeoutMs) - child.stdout.on("data", (chunk) => { - stdout += chunk.toString("utf-8") - }) - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8") - }) - child.on("close", (code) => { - clearTimeout(timer) - resolve({ code, stdout, stderr }) - }) - }) -} - -try { - console.log("[omp-compat] extension loads against the host's pi packages") - // OMP remaps `@earendil-works/pi-ai/compat` onto its own pi-ai and rejects - // any named import that module does not export, both in `omp plugin - // install` validation and when loading the extension. 0.6.1 shipped such an - // import and could not be installed on omp 18 (#74). `omp models -e EXT` - // runs the same loader, so it reproduces that failure without a registry. - const load = await runOmp(["models", "-e", EXT_PATH]) - assert.equal(load.code, 0, load.stderr) - assert.doesNotMatch( - load.stdout + load.stderr, - /Failed to load extension|not found in module/, - "the extension must only import what OMP's bundled pi packages export", - ) - - console.log("[omp-compat] list models through real extension") - modelListRequestCount = 0 - // Prefer the flag form `omp -e EXT --list-models`; Homebrew's `omp` - // distribution only exposes the `omp models` subcommand, so fall back to - // that form when the flag invocation is not recognized. - let result = await runOmp(["-e", EXT_PATH, "--list-models"]) - if (result.code !== 0) { - result = await runOmp(["models", "-e", EXT_PATH]) - } - assert.equal(result.code, 0, result.stderr) - const listOutput = result.stdout || result.stderr - assert.match(listOutput, /commandcode/) - assert.match(listOutput, /deepseek\/deepseek-v4-flash/) - // The failed flag attempt may already load the extension and fetch the - // catalog once before the subcommand fallback runs, so only assert that - // the mock catalog was actually consulted. - assert.ok(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/) - - console.log("[omp-compat] print mode through real extension and mock API") - requestCount = 0 - requestBodies = [] - const print = await runOmp( - ["-e", EXT_PATH, "-p", "say mock token", "--model", `commandcode/${TEST_MODEL}`], - 30_000, - ) - assert.equal(print.code, 0, print.stderr) - assert.match(print.stdout, /mock-omp-ok/) - assert.equal(requestCount, 1) - assert.equal( - lastRequestHeaders.authorization, - "Bearer mock-key", - "should send the resolved env-var value, not the literal var name", - ) - assert.equal(lastRequestBody?.model, TEST_MODEL) - assert.ok(Array.isArray(lastRequestBody?.messages)) - - // OMP stores `/login` credentials in agent.db and consults them only when - // the extension does not install a config API key. main registered the - // unresolved `$COMMAND_CODE_API_KEY` placeholder, which OMP kept as a - // literal config override and sent as the Bearer token, so stored - // credentials never reached the request (401). - const chatArgs = ["-e", EXT_PATH, "-p", "say mock token", "--model", `commandcode/${TEST_MODEL}`] - const noEnvKey = { COMMAND_CODE_API_KEY: undefined, COMMANDCODE_API_KEY: undefined } - - console.log("[omp-compat] stored /login OAuth credential is used when no env key exists") - seedOmpCredential({ - type: "oauth", - access: "stored-oauth-token", - refresh: "stored-oauth-token", - expires: Date.now() + 24 * 60 * 60 * 1000, - }) - requestCount = 0 - lastRequestHeaders = {} - const oauthChat = await runOmp(chatArgs, { env: noEnvKey }) - assert.equal(oauthChat.code, 0, oauthChat.stderr) - assert.match(oauthChat.stdout, /mock-omp-ok/) - assert.equal(requestCount, 1) - assert.equal(lastRequestHeaders.authorization, "Bearer stored-oauth-token") - - console.log("[omp-compat] stored /login API key credential is used when no env key exists") - seedOmpCredential({ type: "api_key", key: "stored-api-key" }) - requestCount = 0 - lastRequestHeaders = {} - const apiKeyChat = await runOmp(chatArgs, { env: noEnvKey }) - assert.equal(apiKeyChat.code, 0, apiKeyChat.stderr) - assert.match(apiKeyChat.stdout, /mock-omp-ok/) - assert.equal(requestCount, 1) - assert.equal(lastRequestHeaders.authorization, "Bearer stored-api-key") - - console.log("[omp-compat] --api-key wins over a stored credential") - requestCount = 0 - lastRequestHeaders = {} - const cliKeyChat = await runOmp([...chatArgs, "--api-key", "cli-key"], { env: noEnvKey }) - assert.equal(cliKeyChat.code, 0, cliKeyChat.stderr) - assert.match(cliKeyChat.stdout, /mock-omp-ok/) - assert.equal(requestCount, 1) - assert.equal(lastRequestHeaders.authorization, "Bearer cli-key") - - console.log("[omp-compat] COMMAND_CODE_API_KEY still works alongside a stored credential") - requestCount = 0 - lastRequestHeaders = {} - const envKeyChat = await runOmp(chatArgs) - assert.equal(envKeyChat.code, 0, envKeyChat.stderr) - assert.match(envKeyChat.stdout, /mock-omp-ok/) - assert.equal(requestCount, 1) - assert.match(lastRequestHeaders.authorization ?? "", /^Bearer (mock-key|stored-api-key)$/) - - console.log("[omp-compat] no credential at all never sends the placeholder") - seedOmpCredential(undefined) - requestCount = 0 - lastRequestHeaders = {} - const noKeyChat = await runOmp(chatArgs, { env: noEnvKey }) - assert.equal(requestCount, 0, JSON.stringify(lastRequestHeaders)) - assert.doesNotMatch(noKeyChat.stdout + noKeyChat.stderr, /\$COMMAND_CODE_API_KEY/) - - console.log("[omp-compat] developer advisory reaches the legacy generate request body") - requestCount = 0 - requestBodies = [] - providerUpgradeRequired = true - const advisoryRun = await runOmp( - [ - "-e", - EXT_PATH, - "-e", - ADVISORY_EXT_PATH, - "-p", - "say mock token", - "--model", - `commandcode/${TEST_MODEL}`, - "--no-tools", - "--no-title", - ], - 30_000, - ) - assert.equal(advisoryRun.code, 0, advisoryRun.stderr) - assert.match(advisoryRun.stdout, /mock-omp-ok/) - - const promptBodies = requestBodies.filter((body) => - JSON.stringify(body?.params?.messages ?? []).includes("say mock token"), - ) - assert.ok(promptBodies.length >= 1, "expected at least one generate request with the prompt") - - for (const body of promptBodies) { - const messages = body?.params?.messages ?? [] - const advisoryMessages = messages.filter((message) => - JSON.stringify(message).includes("Stop and correct the benchmark."), - ) - assert.equal(advisoryMessages.length, 1, "the advisory should survive conversion exactly once") - const advisoryMessage = advisoryMessages[0] - assert.equal(advisoryMessage.role, "user") - const advisoryText = - typeof advisoryMessage.content === "string" - ? advisoryMessage.content - : (advisoryMessage.content ?? []) - .map((part) => (part?.type === "text" ? part.text : "")) - .join("\n") - assert.equal(advisoryText, ADVISORY_XML, "advisory content must arrive verbatim") - - const advisoryIndex = messages.indexOf(advisoryMessage) - const promptIndex = messages.findIndex((message) => - JSON.stringify(message).includes("say mock token"), - ) - assert.ok( - advisoryIndex < promptIndex, - "advisory must keep its chronological position relative to the prompt", - ) - assert.doesNotMatch( - String(body?.params?.system ?? ""), - /Stop and correct the benchmark| server.close(resolve)) - rmSync(tempHome, { recursive: true, force: true }) -} diff --git a/tests/test-overflow.ts b/tests/test-overflow.ts deleted file mode 100644 index dc28888..0000000 --- a/tests/test-overflow.ts +++ /dev/null @@ -1,196 +0,0 @@ -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import { - commandCodeErrorMessage, - normalizeCommandCodeErrorMessage, - normalizeCommandCodeMessage, -} from "../src/overflow.ts" -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -describe("Command Code overflow normalization", () => { - it("normalizes Command Code context errors to pi's generic overflow marker", () => { - const normalized = normalizeCommandCodeErrorMessage("Prompt token limit exceeded") - - assert.equal(normalized, "context_length_exceeded: Prompt token limit exceeded") - }) - - it("is idempotent and leaves unrelated, rate-limit, and capacity errors unchanged", () => { - assert.equal( - normalizeCommandCodeErrorMessage("context_length_exceeded: Prompt token limit exceeded"), - undefined, - ) - assert.equal(normalizeCommandCodeErrorMessage("OpenAI request failed"), undefined) - assert.equal( - normalizeCommandCodeErrorMessage("Prompt token limit exceeded due to rate limit"), - undefined, - ) - assert.equal( - normalizeCommandCodeErrorMessage("Command Code API error 429: context window exceeded"), - undefined, - ) - assert.equal( - normalizeCommandCodeErrorMessage("context window exceeded: status: 429"), - undefined, - ) - assert.equal( - normalizeCommandCodeErrorMessage("The input is too long"), - "context_length_exceeded: The input is too long", - ) - assert.equal( - normalizeCommandCodeErrorMessage("Input exceeds context limit"), - "context_length_exceeded: Input exceeds context limit", - ) - assert.equal( - normalizeCommandCodeErrorMessage("Context window exceeded: provider capacity reached"), - undefined, - ) - }) - - it("scopes finalized message normalization to Command Code", () => { - const message = { - role: "assistant" as const, - provider: "commandcode", - stopReason: "error" as const, - errorMessage: "model context window exceeded", - } - - const normalized = normalizeCommandCodeMessage(message) - assert.equal( - normalized?.message.errorMessage, - "context_length_exceeded: model context window exceeded", - ) - assert.equal(normalizeCommandCodeMessage({ ...message, provider: "openai" }), undefined) - assert.equal(normalizeCommandCodeMessage({ ...message, stopReason: "stop" }), undefined) - }) - - it("extracts nested stream error messages without exposing credentials", () => { - assert.equal( - commandCodeErrorMessage({ - error: { details: { errorMessage: "context window exceeded" } }, - }), - "context window exceeded", - ) - }) - - it("redacts secrets from finalized provider errors", async () => { - server.mockResponse({ - type: "error", - status: 400, - body: "api_key=user_secret_value", - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.doesNotMatch(error.error.errorMessage ?? "", /user_secret_value/) - assert.match(error.error.errorMessage ?? "", /api_key=\[redacted\]/) - }) - - it("normalizes HTTP error bodies containing nested context errors", async () => { - server.mockResponse({ - type: "error", - status: 400, - body: JSON.stringify({ error: { message: "Prompt token limit exceeded" } }), - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - const error = events.at(-1) - - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - const normalized = normalizeCommandCodeMessage(error.error) - assert.match(normalized?.message.errorMessage ?? "", /^context_length_exceeded:/) - }) - - it("does not normalize an HTTP rate-limit response that mentions context", async () => { - server.mockResponse({ - type: "error", - status: 429, - body: JSON.stringify({ error: { message: "context window exceeded" } }), - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - const error = events.at(-1) - - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(normalizeCommandCodeMessage(error.error), undefined) - }) - - it("normalizes nested stream error events", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: { details: { message: "model context window exceeded" } }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - const error = events.at(-1) - - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - const normalized = normalizeCommandCodeMessage(error.error) - assert.equal( - normalized?.message.errorMessage, - "context_length_exceeded: model context window exceeded", - ) - - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: { message: "context window exceeded", status: 429 }, - }), - ], - }) - const retryEvents = await collectEvents( - createTestDeps({ apiBase: server.baseUrl() }).streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - }), - ) - const retryError = retryEvents.at(-1) - assert.equal(retryError?.type, "error") - if (retryError?.type !== "error") throw new Error("expected error") - assert.equal(normalizeCommandCodeMessage(retryError.error), undefined) - }) -}) diff --git a/tests/test-package-manifest.ts b/tests/test-package-manifest.ts deleted file mode 100644 index 88b43cf..0000000 --- a/tests/test-package-manifest.ts +++ /dev/null @@ -1,30 +0,0 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" -import { describe, it } from "node:test" - -interface PackageManifest { - dependencies?: Record - devDependencies?: Record - peerDependencies?: Record - peerDependenciesMeta?: Record -} - -const CORE_PEERS = ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"] as const - -async function readPackageManifest(): Promise { - const contents = await readFile(new URL("../package.json", import.meta.url), "utf-8") - return JSON.parse(contents) as PackageManifest -} - -describe("package manifest", () => { - it("uses pi's bundled core packages instead of installing private runtime copies", async () => { - const manifest = await readPackageManifest() - - for (const packageName of CORE_PEERS) { - assert.equal(manifest.dependencies?.[packageName], undefined) - assert.equal(manifest.devDependencies?.[packageName], undefined) - assert.equal(manifest.peerDependencies?.[packageName], "*") - assert.equal(manifest.peerDependenciesMeta?.[packageName]?.optional, true) - } - }) -}) diff --git a/tests/test-pi-authenticated.mjs b/tests/test-pi-authenticated.mjs deleted file mode 100644 index 39fa536..0000000 --- a/tests/test-pi-authenticated.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import assert from "node:assert/strict" -import { spawnSync } from "node:child_process" -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { delimiter, dirname, join, resolve } from "node:path" -import { fileURLToPath } from "node:url" -import { describe, it } from "node:test" - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") -const launcher = join(repoRoot, "scripts", "pi-authenticated.mjs") - -function runLauncher() { - const fakeBin = mkdtempSync(join(tmpdir(), "pi-commandcode-fake-bin-")) - const logPath = join(fakeBin, "call.json") - const fakePi = join(fakeBin, "pi") - - writeFileSync( - fakePi, - `#!/bin/sh -node - "$@" <<'NODE' -const { writeFileSync } = require("node:fs") -writeFileSync(process.env.FAKE_PI_LOG, JSON.stringify({ - args: process.argv.slice(2), - agentDir: process.env.PI_CODING_AGENT_DIR ?? null, - apiKey: process.env.COMMAND_CODE_API_KEY ?? process.env.COMMANDCODE_API_KEY ?? null, - skipVersionCheck: process.env.PI_SKIP_VERSION_CHECK, -})) -NODE -`, - { mode: 0o700 }, - ) - - try { - const result = spawnSync(process.execPath, [launcher, "--thinking", "high"], { - cwd: repoRoot, - env: { - ...process.env, - PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, - FAKE_PI_LOG: logPath, - PI_CODING_AGENT_DIR: "/existing/pi-agent", - COMMAND_CODE_API_KEY: "official-existing-key", - COMMANDCODE_API_KEY: "legacy-existing-key", - }, - encoding: "utf8", - }) - return { result, call: JSON.parse(readFileSync(logPath, "utf8")) } - } finally { - rmSync(fakeBin, { recursive: true, force: true }) - } -} - -describe("authenticated pi launcher", () => { - it("loads only the checkout extension and leaves auth resolution to existing files", () => { - const { result, call } = runLauncher() - - assert.equal(result.status, 0) - assert.deepEqual(call.args, [ - "--no-extensions", - "--extension", - join(repoRoot, "index.ts"), - "--provider", - "commandcode", - "--model", - "gpt-5.6-luna", - "--models", - "commandcode/*", - "--thinking", - "high", - ]) - assert.equal(call.agentDir, "/existing/pi-agent") - assert.equal(call.apiKey, null) - assert.equal(call.skipVersionCheck, "1") - }) -}) diff --git a/tests/test-pi-isolated.mjs b/tests/test-pi-isolated.mjs deleted file mode 100644 index 3ca85c6..0000000 --- a/tests/test-pi-isolated.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import assert from "node:assert/strict" -import { spawnSync } from "node:child_process" -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { delimiter, dirname, join, resolve } from "node:path" -import { fileURLToPath } from "node:url" -import { describe, it } from "node:test" - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") -const launcher = join(repoRoot, "scripts", "pi-isolated.mjs") - -function runLauncher({ exitStatus = 0 } = {}) { - const fakeBin = mkdtempSync(join(tmpdir(), "pi-commandcode-fake-bin-")) - const logPath = join(fakeBin, "calls.jsonl") - const fakePi = join(fakeBin, "pi") - - writeFileSync( - fakePi, - `#!/bin/sh -node - "$@" <<'NODE' -const { appendFileSync } = require("node:fs") -appendFileSync(process.env.FAKE_PI_LOG, JSON.stringify({ - args: process.argv.slice(2), - agentDir: process.env.PI_CODING_AGENT_DIR, - sessionDir: process.env.PI_CODING_AGENT_SESSION_DIR, - skipVersionCheck: process.env.PI_SKIP_VERSION_CHECK, - home: process.env.HOME, - userProfile: process.env.USERPROFILE, - inheritedApiKey: - process.env.COMMAND_CODE_API_KEY ?? process.env.COMMANDCODE_API_KEY ?? null, -}) + "\\n") -NODE -if [ "$1" = "install" ]; then exit 0; fi -exit ${exitStatus} -`, - { mode: 0o700 }, - ) - - try { - const result = spawnSync(process.execPath, [launcher, "--model", "claude-sonnet-5"], { - cwd: repoRoot, - env: { - ...process.env, - PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, - FAKE_PI_LOG: logPath, - COMMAND_CODE_API_KEY: "must-not-leak-official", - COMMANDCODE_API_KEY: "must-not-leak-legacy", - }, - encoding: "utf8", - }) - const calls = readFileSync(logPath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)) - return { result, calls } - } finally { - rmSync(fakeBin, { recursive: true, force: true }) - } -} - -describe("isolated pi launcher", () => { - it("installs the current checkout, forwards arguments, and removes its environment", () => { - const { result, calls } = runLauncher() - - assert.equal(result.status, 0) - assert.equal(calls.length, 2) - assert.deepEqual(calls[0].args, ["install", repoRoot, "--no-approve"]) - assert.deepEqual(calls[1].args, [ - "--no-approve", - "--provider", - "commandcode", - "--model", - "gpt-5.6-luna", - "--model", - "claude-sonnet-5", - ]) - - const [install, launch] = calls - assert.equal(install.agentDir, launch.agentDir) - assert.equal(install.sessionDir, launch.sessionDir) - assert.equal(launch.skipVersionCheck, "1") - assert.equal(launch.inheritedApiKey, null) - assert.ok(launch.agentDir.includes("pi-commandcode-isolated-")) - assert.equal(launch.home, dirname(launch.agentDir)) - assert.equal(launch.userProfile, dirname(launch.agentDir)) - assert.equal(dirname(launch.agentDir), dirname(launch.sessionDir)) - assert.equal(existsSync(dirname(launch.agentDir)), false) - assert.match(result.stderr, /Removed the isolated pi environment/) - }) - - it("returns the pi exit status and still removes its environment", () => { - const { result, calls } = runLauncher({ exitStatus: 7 }) - - assert.equal(result.status, 7) - assert.equal(calls.length, 2) - assert.equal(existsSync(dirname(calls[1].agentDir)), false) - }) -}) diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs deleted file mode 100644 index aee3495..0000000 --- a/tests/test-pi-local.mjs +++ /dev/null @@ -1,1031 +0,0 @@ -#!/usr/bin/env node -/** - * Local end-to-end test: loads the real extension through the pi CLI while the - * Command Code API is replaced by a deterministic local mock server. - */ - -import assert from "node:assert/strict" -import { spawn, spawnSync } from "node:child_process" -import { accessSync, constants, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs" -import { createServer } from "node:http" -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 COMPAT_CALLER_EXT_PATH = resolve( - PROJECT_DIR, - "tests", - "fixtures", - "compat-caller-extension.ts", -) -const TEST_MODEL = "gpt-5.4" -const CLAUDE_TEST_MODEL = "claude-sonnet-4-6" - -function findPiBinary() { - if (process.env.PI_BIN) return process.env.PI_BIN - const localBin = resolve(PROJECT_DIR, "node_modules", ".bin") - const candidates = (process.env.PATH ?? "") - .split(delimiter) - .map((entry) => resolve(entry, "pi")) - .filter((candidate) => !candidate.startsWith(localBin)) - for (const candidate of candidates) { - try { - accessSync(candidate, constants.X_OK) - return candidate - } catch { - // Try next PATH entry. - } - } - return undefined -} - -const PI_BIN = findPiBinary() -if (!PI_BIN) { - if (process.env.PI_LOCAL_REQUIRED === "1") { - console.error("[pi-local] FAIL - pi is required but not on PATH and PI_BIN is unset") - process.exit(1) - } - console.log("[pi-local] SKIP — pi is not on PATH") - process.exit(0) -} - -const piCheck = spawnSync(PI_BIN, ["--help"], { stdio: "ignore" }) -if (piCheck.error) { - if (process.env.PI_LOCAL_REQUIRED === "1") { - console.error(`[pi-local] FAIL - pi failed to start: ${piCheck.error.message}`) - process.exit(1) - } - console.log(`[pi-local] SKIP — pi failed to start: ${piCheck.error.message}`) - process.exit(0) -} - -let requestCount = 0 -let modelListRequestCount = 0 -let lastRequestBody -let lastRequestHeaders = {} -let overflowMode = false -let overflowRequestCount = 0 -let modelsDelayMs = 0 -let includeRefreshedModel = false - -function modelCatalog() { - const data = [ - { - id: TEST_MODEL, - object: "model", - created: 1779824324, - owned_by: "command-code", - name: "GPT 5.4", - context_length: 1_000_000, - }, - { - id: CLAUDE_TEST_MODEL, - object: "model", - created: 1779824324, - owned_by: "command-code", - name: "Claude Sonnet 4.6", - context_length: 200_000, - }, - { - id: "cc-second-model", - object: "model", - created: 1779824324, - owned_by: "command-code", - name: "Qwen 3.7 Max", - context_length: 1_000_000, - }, - ] - if (includeRefreshedModel) { - data.push({ - id: "cc-refreshed-model", - object: "model", - created: 1779824324, - owned_by: "command-code", - name: "Refreshed Model", - context_length: 200_000, - }) - } - return { object: "list", data } -} - -const server = createServer((req, res) => { - if (req.method === "GET" && req.url === "/provider/v1/models") { - modelListRequestCount += 1 - const respond = () => { - if (res.destroyed) return - res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }) - res.end(JSON.stringify(modelCatalog())) - } - if (modelsDelayMs > 0) setTimeout(respond, modelsDelayMs) - else respond() - return - } - - const isOpenAIRequest = req.method === "POST" && req.url === "/provider/v1/chat/completions" - const isAnthropicRequest = req.method === "POST" && req.url === "/provider/v1/messages" - if (!isOpenAIRequest && !isAnthropicRequest) { - res.writeHead(404) - res.end("Not found") - return - } - - requestCount += 1 - if (overflowMode) overflowRequestCount += 1 - lastRequestHeaders = Object.fromEntries( - Object.entries(req.headers).map(([key, value]) => [ - key, - Array.isArray(value) ? value.join(", ") : (value ?? ""), - ]), - ) - - let body = "" - req.on("data", (chunk) => { - body += chunk.toString("utf-8") - }) - req.on("end", () => { - try { - lastRequestBody = JSON.parse(body) - } catch { - lastRequestBody = undefined - } - - if (overflowMode && overflowRequestCount === 2) { - res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }) - res.end( - JSON.stringify({ - error: { - message: "Input exceeds context limit", - type: "invalid_request_error", - code: "context_length_exceeded", - }, - }), - ) - return - } - - res.writeHead(200, { - "Content-Type": "text/event-stream; charset=utf-8", - "Transfer-Encoding": "chunked", - }) - const text = overflowMode - ? overflowRequestCount === 1 - ? "overflow-initial" - : overflowRequestCount === 3 - ? "compaction-summary" - : "overflow-recovered" - : "mock-pi-ok" - if (isAnthropicRequest) { - res.write( - `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "mock", type: "message", role: "assistant", content: [], model: CLAUDE_TEST_MODEL, stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } })}\n\n`, - ) - res.write( - `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`, - ) - res.write( - `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } })}\n\n`, - ) - res.write( - `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`, - ) - res.write( - `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } })}\n\n`, - ) - res.end(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`) - return - } - - res.write( - `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] })}\n\n`, - ) - res.write( - `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`, - ) - res.write( - `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`, - ) - res.end("data: [DONE]\n\n") - }) -}) - -await new Promise((resolve) => server.listen(0, resolve)) -const address = server.address() -const port = typeof address === "object" && address ? address.port : 0 -const apiBase = `http://127.0.0.1:${port}` - -const tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-")) -const agentDir = join(tempHome, "custom-pi-agent") -mkdirSync(agentDir, { recursive: true }) -writeFileSync( - join(agentDir, "settings.json"), - JSON.stringify({ compaction: { enabled: true, reserveTokens: 10, keepRecentTokens: 10 } }), -) -const env = { - ...process.env, - HOME: tempHome, - USERPROFILE: tempHome, - PI_CODING_AGENT_DIR: agentDir, - PI_CODING_AGENT_SESSION_DIR: join(tempHome, "sessions"), - COMMANDCODE_API_BASE: `${apiBase}/provider/v1`, - COMMAND_CODE_API_KEY: "mock-key", - CMD_ZDR: "1", - COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, -} - -function runPi(args, timeoutOrOptions = 30_000) { - const options = - typeof timeoutOrOptions === "number" ? { timeoutMs: timeoutOrOptions } : timeoutOrOptions - const timeoutMs = options.timeoutMs ?? 30_000 - const childEnv = { ...env } - for (const [key, value] of Object.entries(options.env ?? {})) { - if (value === undefined) delete childEnv[key] - else childEnv[key] = value - } - return new Promise((resolve) => { - const child = spawn(PI_BIN, args, { - cwd: PROJECT_DIR, - env: childEnv, - stdio: ["ignore", "pipe", "pipe"], - }) - let stdout = "" - let stderr = "" - const timer = setTimeout(() => { - child.kill() - resolve({ - code: -1, - stdout, - stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms`, - }) - }, timeoutMs) - child.stdout.on("data", (chunk) => { - stdout += chunk.toString("utf-8") - }) - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8") - }) - child.on("close", (code) => { - clearTimeout(timer) - resolve({ code, stdout, stderr }) - }) - }) -} - -async function runRpcQuery( - timeoutMs = 30_000, - promptMessage = "say mock token", - extraArgs = [], - promptFields = {}, -) { - const child = spawn( - PI_BIN, - [ - "--no-extensions", - "--mode", - "rpc", - "-e", - EXT_PATH, - "--provider", - "commandcode", - "--model", - TEST_MODEL, - ...extraArgs, - ], - { - cwd: PROJECT_DIR, - env, - stdio: ["pipe", "pipe", "pipe"], - }, - ) - - let stdout = "" - let stderr = "" - let buffer = "" - let sawPromptAccepted = false - let sawAssistantMessage = false - let sawTextDelta = false - const events = [] - - const done = new Promise((resolve) => { - const timer = setTimeout(() => { - child.kill() - resolve(false) - }, timeoutMs) - - const finish = (ok) => { - clearTimeout(timer) - try { - child.stdin.write(`${JSON.stringify({ type: "quit" })}\n`) - } catch { - // ignore shutdown race - } - child.kill() - resolve(ok) - } - - child.stdin.write( - `${JSON.stringify({ - id: "prompt-1", - type: "prompt", - message: promptMessage, - ...promptFields, - })}\n`, - ) - - child.stdout.on("data", (chunk) => { - const text = chunk.toString("utf-8") - stdout += text - buffer += text - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - try { - const event = JSON.parse(trimmed) - events.push(event) - if (event.type === "response" && event.id === "prompt-1" && event.success === true) { - sawPromptAccepted = true - } - if ( - event.type === "message_update" && - event.assistantMessageEvent?.type === "text_delta" - ) { - sawTextDelta = true - } - if (event.type === "message_end" && event.message?.role === "assistant") { - sawAssistantMessage = true - finish(true) - } - } catch { - // ignore non-JSON output - } - } - }) - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8") - }) - child.on("close", () => { - if (!sawAssistantMessage) finish(false) - }) - }) - - const ok = await done - return { - ok, - stdout, - stderr, - events, - sawPromptAccepted, - sawAssistantMessage, - sawTextDelta, - } -} - -async function runRpcExtensionCommands(timeoutMs = 30_000) { - const child = spawn( - PI_BIN, - [ - "--no-extensions", - "--mode", - "rpc", - "-e", - EXT_PATH, - "--provider", - "commandcode", - "--model", - TEST_MODEL, - ], - { - cwd: PROJECT_DIR, - env, - stdio: ["pipe", "pipe", "pipe"], - }, - ) - - let buffer = "" - let stderr = "" - const events = [] - const waiters = [] - - const publish = (event) => { - events.push(event) - for (let index = waiters.length - 1; index >= 0; index -= 1) { - const waiter = waiters[index] - if (!waiter.predicate(event)) continue - waiters.splice(index, 1) - clearTimeout(waiter.timer) - waiter.resolve(event) - } - } - - child.stdout.on("data", (chunk) => { - buffer += chunk.toString("utf-8") - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - for (const line of lines) { - if (!line.trim()) continue - try { - publish(JSON.parse(line)) - } catch { - // Ignore non-JSON output. - } - } - }) - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8") - }) - - const waitFor = (predicate, fromIndex = 0) => - new Promise((resolve, reject) => { - const existing = events.slice(fromIndex).find(predicate) - if (existing) { - resolve(existing) - return - } - const timer = setTimeout(() => { - const index = waiters.findIndex((waiter) => waiter.timer === timer) - if (index >= 0) waiters.splice(index, 1) - reject(new Error(`RPC event timeout. stderr: ${stderr.slice(-500)}`)) - }, timeoutMs) - waiters.push({ predicate, resolve, timer }) - }) - - const send = (value) => child.stdin.write(`${JSON.stringify(value)}\n`) - - try { - send({ id: "commands", type: "get_commands" }) - const commandsResponse = await waitFor( - (event) => event.type === "response" && event.id === "commands", - ) - const commandNames = commandsResponse.data?.commands?.map((command) => command.name) ?? [] - - // The cached catalog registers immediately and refreshes in the - // background. `/commandcode-refresh` coalesces with an in-flight refresh, - // so the status must report the startup refresh as finished before the - // catalog is changed; otherwise the command reports the old catalog. - let statusBefore - for (let attempt = 0; attempt < 20; attempt += 1) { - const id = `status-before-${attempt}` - const fromIndex = events.length - send({ id, type: "prompt", message: "/commandcode-status" }) - await waitFor((event) => event.type === "response" && event.id === id && event.success) - statusBefore = await waitFor( - (event) => - event.type === "extension_ui_request" && - event.method === "notify" && - typeof event.message === "string" && - event.message.includes("model count: 3"), - fromIndex, - ) - if (/source: live[\s\S]*refresh: idle/.test(statusBefore.message)) break - await new Promise((resolve) => setTimeout(resolve, 100)) - } - - includeRefreshedModel = true - send({ id: "refresh", type: "prompt", message: "/commandcode-refresh" }) - await waitFor((event) => event.type === "response" && event.id === "refresh" && event.success) - const refreshNotification = await waitFor( - (event) => - event.type === "extension_ui_request" && - event.method === "notify" && - typeof event.message === "string" && - event.message.includes("4 models from live"), - ) - - send({ id: "status-after", type: "prompt", message: "/commandcode-status" }) - await waitFor( - (event) => event.type === "response" && event.id === "status-after" && event.success, - ) - const statusAfter = await waitFor( - (event) => - event.type === "extension_ui_request" && - event.method === "notify" && - typeof event.message === "string" && - event.message.includes("model count: 4"), - ) - - return { - commandNames, - statusBefore: statusBefore.message, - refreshNotification: refreshNotification.message, - statusAfter: statusAfter.message, - stderr, - } - } finally { - child.kill() - } -} - -async function runRpcCompatCall(timeoutMs = 30_000) { - const child = spawn( - PI_BIN, - [ - "--no-extensions", - "--mode", - "rpc", - "-e", - EXT_PATH, - "-e", - COMPAT_CALLER_EXT_PATH, - "--provider", - "commandcode", - "--model", - TEST_MODEL, - ], - { - cwd: PROJECT_DIR, - env, - stdio: ["pipe", "pipe", "pipe"], - }, - ) - - let buffer = "" - let stderr = "" - - const notification = new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`compat-call timeout. stderr: ${stderr.slice(-500)}`)), - timeoutMs, - ) - child.stdout.on("data", (chunk) => { - buffer += chunk.toString("utf-8") - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - for (const line of lines) { - if (!line.trim()) continue - let event - try { - event = JSON.parse(line) - } catch { - continue - } - if ( - event.type === "extension_ui_request" && - event.method === "notify" && - typeof event.message === "string" && - event.message.startsWith("compat-call") - ) { - clearTimeout(timer) - resolve(event.message) - } - } - }) - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8") - }) - }) - - try { - child.stdin.write( - `${JSON.stringify({ id: "compat", type: "prompt", message: "/compat-call" })}\n`, - ) - return { message: await notification, stderr } - } finally { - child.kill() - } -} - -async function runRpcOverflowRecovery(timeoutMs = 60_000) { - const child = spawn( - PI_BIN, - [ - "--no-extensions", - "--mode", - "rpc", - "-e", - EXT_PATH, - "--provider", - "commandcode", - "--model", - TEST_MODEL, - ], - { - cwd: PROJECT_DIR, - env, - stdio: ["pipe", "pipe", "pipe"], - }, - ) - - let buffer = "" - let stderr = "" - const events = [] - let firstSettled = false - let recovered = false - - const result = new Promise((resolve) => { - const timer = setTimeout(() => { - child.kill() - resolve({ ok: false }) - }, timeoutMs) - - const finish = (ok) => { - clearTimeout(timer) - child.kill() - resolve({ ok }) - } - - child.stdout.on("data", (chunk) => { - buffer += chunk.toString("utf-8") - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - for (const line of lines) { - if (!line.trim()) continue - let event - try { - event = JSON.parse(line) - } catch { - continue - } - events.push(event) - if (event.type === "agent_settled" && !firstSettled) { - firstSettled = true - child.stdin.write( - `${JSON.stringify({ id: "overflow-prompt", type: "prompt", message: "trigger overflow recovery" })}\n`, - ) - } - if ( - event.type === "compaction_end" && - event.reason === "overflow" && - event.willRetry === true - ) { - recovered = true - } - if (recovered && event.type === "agent_settled") finish(true) - } - }) - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8") - }) - child.stdin.write( - `${JSON.stringify({ id: "initial-prompt", type: "prompt", message: "initial turn" })}\n`, - ) - }) - - const outcome = await result - return { - ...outcome, - requests: overflowRequestCount, - sawNormalizedOverflow: events.some( - (event) => - event.type === "message_end" && - event.message?.role === "assistant" && - typeof event.message.errorMessage === "string" && - event.message.errorMessage.startsWith("context_length_exceeded:"), - ), - sawCompactionRetry: events.some( - (event) => event.type === "compaction_end" && event.reason === "overflow" && event.willRetry, - ), - stderrHasSecrets: /mock-key|user_secret|api_key/i.test(stderr), - } -} - -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|No models available/, - ) - assert.match(firstOfflineList.stderr, /no valid cached catalog/) - assert.match(firstOfflineList.stderr, /until \/commandcode-refresh 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, /gpt-5\.4/) - 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") - 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, /gpt-5\.4/) - assert.match(listOutput, /cc-second-model/) - 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, /gpt-5\.4/) - assert.match(offlineListOutput, /cc-second-model/) - // The cached catalog is registered before the background refresh fails, and - // `--list-models` exits as soon as the list is printed. Whether the refresh - // warning reaches stderr first depends on the host runtime (Bun flushes it, - // Node does not), so the warning is asserted on the print run below, which - // waits for the response. - - 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] discovery timeout through real extension") - rmSync(modelsCachePath, { force: true }) - modelsDelayMs = 5_000 - env.COMMANDCODE_MODELS_TIMEOUT_MS = "50" - const timeoutStartedAt = Date.now() - const timedOutList = await runPi( - ["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"], - 5_000, - ) - const timeoutElapsedMs = Date.now() - timeoutStartedAt - assert.equal(timedOutList.code, 0, timedOutList.stderr) - assert.ok(timeoutElapsedMs < 2_000, `model discovery took ${timeoutElapsedMs}ms`) - assert.match(timedOutList.stderr, /timed out after 50ms/i) - modelsDelayMs = 0 - delete env.COMMANDCODE_MODELS_TIMEOUT_MS - - console.log("[pi-local] cached catalog starts without waiting for slow discovery") - const warmup = await runPi( - ["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"], - 20_000, - ) - assert.equal(warmup.code, 0, warmup.stderr) - assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK)) - modelsDelayMs = 5_000 - requestCount = 0 - const cachedStartedAt = Date.now() - const cachedPrint = await runPi( - [ - "--no-extensions", - "-e", - EXT_PATH, - "-p", - "say mock token", - "--provider", - "commandcode", - "--model", - TEST_MODEL, - ], - 30_000, - ) - const cachedElapsedMs = Date.now() - cachedStartedAt - assert.equal(cachedPrint.code, 0, cachedPrint.stderr) - assert.match(cachedPrint.stdout, /mock-pi-ok/) - assert.equal(requestCount, 1) - assert.ok(cachedElapsedMs < 5_000, `cached start took ${cachedElapsedMs}ms`) - modelsDelayMs = 0 - - console.log("[pi-local] print mode with reasoning and tool schemas") - requestCount = 0 - const print = await runPi( - [ - "--no-extensions", - "-e", - EXT_PATH, - "-p", - "say mock token", - "--provider", - "commandcode", - "--model", - TEST_MODEL, - "--thinking", - "high", - ], - 30_000, - ) - assert.equal(print.code, 0, print.stderr) - assert.match(print.stdout, /mock-pi-ok/) - assert.equal(requestCount, 1) - assert.ok( - typeof lastRequestHeaders.authorization === "string" && - lastRequestHeaders.authorization.startsWith("Bearer "), - "should send a bearer Authorization header", - ) - assert.equal(lastRequestHeaders["x-cmd-zdr"], "1") - assert.equal(lastRequestBody?.model, TEST_MODEL) - assert.equal(lastRequestBody?.reasoning_effort, "high") - const sentTools = lastRequestBody?.tools - assert.ok(Array.isArray(sentTools) && sentTools.length > 0) - const editTool = sentTools.find((tool) => tool.function?.name === "edit") - assert.equal(editTool?.function?.parameters?.type, "object") - assert.equal(editTool?.function?.parameters?.properties?.edits?.type, "array") - assert.equal(editTool?.function?.parameters?.properties?.edits?.items?.type, "object") - assert.equal( - editTool?.function?.parameters?.properties?.edits?.items?.properties?.oldText?.type, - "string", - ) - - // pi resolves `/login` credentials, `--api-key`, and env keys through the - // provider's registered auth methods. Stored credentials and `--api-key` - // only reach the request when the provider keeps an API-key auth method - // next to OAuth, so every credential source is checked without an env key. - const authArgs = [ - "--no-extensions", - "-e", - EXT_PATH, - "-p", - "say mock token", - "--provider", - "commandcode", - "--model", - TEST_MODEL, - ] - const noEnvKey = { COMMAND_CODE_API_KEY: undefined, COMMANDCODE_API_KEY: undefined } - const authPath = join(agentDir, "auth.json") - - console.log("[pi-local] stored /login OAuth credential is used when no env key exists") - writeFileSync( - authPath, - JSON.stringify({ - commandcode: { - type: "oauth", - access: "stored-oauth-token", - refresh: "stored-oauth-token", - expires: Date.now() + 24 * 60 * 60 * 1000, - }, - }), - ) - requestCount = 0 - lastRequestHeaders = {} - const oauthPrint = await runPi(authArgs, { env: noEnvKey }) - assert.equal(oauthPrint.code, 0, oauthPrint.stderr) - assert.match(oauthPrint.stdout, /mock-pi-ok/) - assert.equal(requestCount, 1) - assert.equal(lastRequestHeaders.authorization, "Bearer stored-oauth-token") - - console.log("[pi-local] stored /login API key credential is used when no env key exists") - writeFileSync( - authPath, - JSON.stringify({ commandcode: { type: "api_key", key: "stored-api-key" } }), - ) - requestCount = 0 - lastRequestHeaders = {} - const apiKeyPrint = await runPi(authArgs, { env: noEnvKey }) - assert.equal(apiKeyPrint.code, 0, apiKeyPrint.stderr) - assert.match(apiKeyPrint.stdout, /mock-pi-ok/) - assert.equal(requestCount, 1) - assert.equal(lastRequestHeaders.authorization, "Bearer stored-api-key") - - console.log("[pi-local] --api-key is used when no env key or stored credential exists") - rmSync(authPath, { force: true }) - requestCount = 0 - lastRequestHeaders = {} - const cliKeyPrint = await runPi([...authArgs, "--api-key", "cli-key"], { env: noEnvKey }) - assert.equal(cliKeyPrint.code, 0, cliKeyPrint.stderr) - assert.match(cliKeyPrint.stdout, /mock-pi-ok/) - assert.equal(requestCount, 1) - assert.equal(lastRequestHeaders.authorization, "Bearer cli-key") - - console.log("[pi-local] no credential at all never sends the placeholder") - requestCount = 0 - lastRequestHeaders = {} - const noKeyPrint = await runPi(authArgs, { env: noEnvKey }) - assert.notEqual(noKeyPrint.code, 0) - assert.equal(requestCount, 0, JSON.stringify(lastRequestHeaders)) - assert.doesNotMatch(noKeyPrint.stdout + noKeyPrint.stderr, /\$COMMAND_CODE_API_KEY/) - - console.log("[pi-local] Claude request through Anthropic Messages endpoint") - requestCount = 0 - const claudePrint = await runPi( - [ - "--no-extensions", - "-e", - EXT_PATH, - "-p", - "say mock token", - "--provider", - "commandcode", - "--model", - CLAUDE_TEST_MODEL, - "--thinking", - "high", - ], - 30_000, - ) - assert.equal(claudePrint.code, 0, claudePrint.stderr) - assert.match(claudePrint.stdout, /mock-pi-ok/) - assert.equal(requestCount, 1) - assert.equal(lastRequestBody?.model, CLAUDE_TEST_MODEL) - assert.equal(lastRequestBody?.thinking?.type, "adaptive") - assert.deepEqual(lastRequestBody?.output_config, { effort: "high" }) - assert.equal(lastRequestHeaders["x-api-key"], "mock-key") - assert.equal(lastRequestHeaders["x-cmd-zdr"], "1") - - console.log("[pi-local] runtime commands through real RPC extension lifecycle") - includeRefreshedModel = false - const runtimeCommands = await runRpcExtensionCommands() - assert.ok(runtimeCommands.commandNames.includes("commandcode-refresh")) - assert.ok(runtimeCommands.commandNames.includes("commandcode-status")) - assert.match(runtimeCommands.statusBefore, /source: live/) - assert.match(runtimeCommands.refreshNotification, /4 models from live/) - assert.match(runtimeCommands.statusAfter, /model count: 4/) - assert.doesNotMatch( - `${runtimeCommands.statusBefore}\n${runtimeCommands.statusAfter}\n${runtimeCommands.stderr}`, - /mock-key/, - ) - - console.log("[pi-local] RPC prompt through real extension and mock API") - requestCount = 0 - const rpc = await runRpcQuery() - assert.equal( - rpc.ok, - true, - JSON.stringify( - { stderr: rpc.stderr, stdout: rpc.stdout, events: rpc.events.slice(-10) }, - null, - 2, - ), - ) - assert.equal(rpc.sawPromptAccepted, true) - assert.equal(rpc.sawAssistantMessage, true) - assert.equal(rpc.sawTextDelta, true) - assert.equal(requestCount, 1) - - console.log("[pi-local] forward image input through the documented provider schema") - requestCount = 0 - const imageRpc = await runRpcQuery(10_000, "describe image", [], { - images: [ - { - type: "image", - data: "iVBORw0KGgo=", - mimeType: "image/png", - }, - ], - }) - assert.equal(imageRpc.ok, true, imageRpc.stderr) - assert.equal(requestCount, 1) - const imageContent = lastRequestBody?.messages?.find( - (message) => message.role === "user", - )?.content - assert.ok(Array.isArray(imageContent), JSON.stringify(lastRequestBody?.messages)) - assert.ok( - imageContent.some((part) => part.type === "image_url"), - JSON.stringify(imageContent), - ) - - console.log("[pi-local] sibling extension streams through the pi-ai compat registry") - requestCount = 0 - const compatCall = await runRpcCompatCall() - assert.equal(compatCall.message, "compat-call ok: mock-pi-ok", compatCall.stderr) - assert.equal(requestCount, 1) - assert.equal(lastRequestBody?.model, TEST_MODEL) - assert.ok( - typeof lastRequestHeaders.authorization === "string" && - lastRequestHeaders.authorization.startsWith("Bearer "), - "compat call should send a bearer Authorization header", - ) - - console.log("[pi-local] verify overflow normalization and compaction recovery") - overflowMode = true - overflowRequestCount = 0 - const overflowRpc = await runRpcOverflowRecovery() - assert.equal(overflowRpc.ok, true) - assert.ok(overflowRpc.requests >= 4) - assert.equal(overflowRpc.sawCompactionRetry, true, JSON.stringify(overflowRpc)) - assert.equal(overflowRpc.stderrHasSecrets, false) - overflowMode = false - - console.log("[pi-local] PASS") -} finally { - await new Promise((resolve) => server.close(resolve)) - rmSync(tempHome, { recursive: true, force: true }) -} diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts deleted file mode 100644 index 73b8b75..0000000 --- a/tests/test-pricing.ts +++ /dev/null @@ -1,246 +0,0 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" -import { describe, it } from "node:test" - -import { - MODEL_COSTS, - PRICING_LAST_VERIFIED, - PRICING_SOURCE_URL, - TEMPORARY_PRICING, -} from "../src/pricing.ts" - -interface ModelCatalogSnapshot { - fetchedAt: string - source: string - modelIds: string[] -} - -interface PricingSnapshot { - verifiedAt: string - source: string - tierPolicy: string - tiers: Record - costs: Record -} - -const fixtureUrl = new URL("./fixtures/commandcode-model-ids.json", import.meta.url) -const fixture = JSON.parse(await readFile(fixtureUrl, "utf-8")) as ModelCatalogSnapshot -const pricingFixtureUrl = new URL("./fixtures/commandcode-pricing.json", import.meta.url) -const pricingFixture = JSON.parse(await readFile(pricingFixtureUrl, "utf-8")) as PricingSnapshot -const freeModels = new Set(["poolside/laguna-s-2.1-free"]) - -function assertCost( - modelId: string, - expected: { input: number; output: number; cacheRead: number; cacheWrite: number }, -) { - const cost = MODEL_COSTS[modelId] - assert.ok(cost, `${modelId} should have pricing`) - assert.deepEqual( - { - input: cost.input, - output: cost.output, - cacheRead: cost.cacheRead, - cacheWrite: cost.cacheWrite, - }, - expected, - `${modelId} base pricing should match the source`, - ) -} - -describe("MODEL_COSTS pricing overlay", () => { - it("covers the current Command Code model catalog snapshot", () => { - assert.equal(fixture.source, "https://api.commandcode.ai/provider/v1/models") - assert.match(fixture.fetchedAt, /^2026-09-01T/) - - const catalogIds = [...fixture.modelIds].sort() - const pricedIds = Object.keys(MODEL_COSTS).sort() - assert.deepEqual(pricedIds, catalogIds) - }) - - it("matches the verified official pricing snapshot", () => { - assert.equal(pricingFixture.verifiedAt, PRICING_LAST_VERIFIED) - assert.equal(pricingFixture.source, PRICING_SOURCE_URL) - assert.match(pricingFixture.tierPolicy, /request-wide input tiers/) - - const expected = Object.fromEntries( - Object.entries(pricingFixture.costs).map( - ([modelId, [input, output, cacheRead, cacheWrite]]) => [ - modelId, - { - input, - output, - cacheRead, - cacheWrite, - ...(pricingFixture.tiers[modelId] - ? { - tiers: pricingFixture.tiers[modelId].map( - ([inputTokensAbove, tierInput, tierOutput, tierCacheRead, tierCacheWrite]) => ({ - inputTokensAbove, - input: tierInput, - output: tierOutput, - cacheRead: tierCacheRead, - cacheWrite: tierCacheWrite, - }), - ), - } - : {}), - }, - ], - ), - ) - assert.deepEqual(MODEL_COSTS, expected) - }) - - it("uses non-zero prices except for models documented as free", () => { - for (const [modelId, cost] of Object.entries(MODEL_COSTS)) { - assert.ok(cost.input >= 0, `${modelId} input cost should be non-negative`) - assert.ok(cost.output >= 0, `${modelId} output cost should be non-negative`) - assert.ok(cost.cacheRead >= 0, `${modelId} cache-read cost should be non-negative`) - assert.ok(cost.cacheWrite >= 0, `${modelId} cache-write cost should be non-negative`) - - const allZero = Object.values(cost).every((value) => value === 0) - assert.equal( - allZero, - freeModels.has(modelId), - `${modelId} free-model status should be explicit`, - ) - } - }) - - it("matches corrected official rates", () => { - assertCost("deepseek/deepseek-v4-pro", { - input: 0.66, - output: 1.98, - cacheRead: 0.022, - cacheWrite: 0, - }) - assertCost("deepseek/deepseek-v4-flash", { - input: 0.22, - output: 0.66, - cacheRead: 0.007, - cacheWrite: 0, - }) - assertCost("Qwen/Qwen3.7-Max", { - input: 2.5, - output: 7.5, - cacheRead: 0.5, - cacheWrite: 3.13, - }) - assertCost("xiaomi/mimo-v2.5-pro", { - input: 0.435, - output: 0.87, - cacheRead: 0.0036, - cacheWrite: 0, - }) - assertCost("MiniMaxAI/MiniMax-M2.5", { - input: 0.3, - output: 1.2, - cacheRead: 0.03, - cacheWrite: 0, - }) - assertCost("Qwen/Qwen3.8-27B", { - input: 0.4, - output: 3, - cacheRead: 0.04, - cacheWrite: 0, - }) - assertCost("Qwen/Qwen3.8-Flash", { - input: 0.16, - output: 0.47, - cacheRead: 0.016, - cacheWrite: 0, - }) - assertCost("z-ai/glm-5.3-flash", { - input: 0.15, - output: 0.5, - cacheRead: 0.03, - cacheWrite: 0, - }) - assertCost("tencent/hy4-preview", { - input: 0.834, - output: 2.501, - cacheRead: 0.042, - cacheWrite: 0, - }) - assertCost("google/gemini-3.7-flash", { - input: 1.5, - output: 7.5, - cacheRead: 0.15, - cacheWrite: 0.08334, - }) - assertCost("claude-fable-5-1", { - input: 10, - output: 50, - cacheRead: 0.25, - cacheWrite: 12.5, - }) - assertCost("deepseek/deepseek-v4-flash-fast", { - input: 0.28, - output: 0.56, - cacheRead: 0.07, - cacheWrite: 0, - }) - assertCost("meta/muse-spark-1.2-contributor", { - input: 0.1, - output: 0.2, - cacheRead: 0.002, - cacheWrite: 0, - }) - }) - - it("uses the documented base rates for context-dependent models", () => { - assertCost("Qwen/Qwen3.7-Plus", { - input: 0.4, - output: 1.6, - cacheRead: 0.08, - cacheWrite: 0.5, - }) - assertCost("Qwen/Qwen3.7-Flash", { - input: 0.03, - output: 0.13, - cacheRead: 0.006, - cacheWrite: 0.038, - }) - assertCost("gpt-5.6-terra", { - input: 2, - output: 12, - cacheRead: 0.2, - cacheWrite: 2.5, - }) - assertCost("gpt-5.6-luna", { - input: 0.2, - output: 1.2, - cacheRead: 0.02, - cacheWrite: 0.25, - }) - assert.deepEqual(MODEL_COSTS["xai/grok-4.6"]?.tiers, [ - { - inputTokensAbove: 200_000, - input: 4, - output: 12, - cacheRead: 1, - cacheWrite: 0, - }, - ]) - }) - - it("tracks pricing provenance", () => { - assert.equal(PRICING_SOURCE_URL, "https://commandcode.ai/docs/resources/pricing-limits") - assert.equal(PRICING_LAST_VERIFIED, "2026-09-01") - }) - - it("fails once temporary pricing needs review", () => { - const today = new Date().toISOString().slice(0, 10) - for (const pricing of TEMPORARY_PRICING) { - assert.match(pricing.expiresOn, /^\d{4}-\d{2}-\d{2}$/) - assert.ok(pricing.models.length > 0) - assert.ok( - pricing.expiresOn >= today, - `${pricing.description} for ${pricing.models.join(", ")} expired on ${pricing.expiresOn}; refresh MODEL_COSTS`, - ) - for (const modelId of pricing.models) { - assert.ok(MODEL_COSTS[modelId], `${modelId} should have a temporary price entry`) - } - } - }) -}) diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts deleted file mode 100644 index 6a1a650..0000000 --- a/tests/test-pure-functions.ts +++ /dev/null @@ -1,893 +0,0 @@ -/** - * Unit tests for the real pure helpers exported by src/core.ts. - * These are hermetic: no pi runtime and no network. - */ - -import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { describe, it } from "node:test" - -import { - assertTextOnlyMessages, - getApiKey, - getEnvironmentInfo, - mapFinishReason, - messagesToCC, - parseStreamEventLine, - pickCommandCodeApiKey, - withResolvedCommandCodeApiKey, - projectSlugFromPath, - textContent, - toJsonSchema, - toolsToJson, -} from "../src/core.ts" -import { redactCommandCodeErrorText } from "../src/overflow.ts" - -import { objectAt } from "./helpers.ts" - -describe("getApiKey()", () => { - it("uses the official API key env var before the legacy alias", () => { - assert.equal( - getApiKey({ - env: { COMMAND_CODE_API_KEY: "official-key", COMMANDCODE_API_KEY: "legacy-key" }, - authPaths: [], - }), - "official-key", - ) - assert.equal( - getApiKey({ env: { COMMANDCODE_API_KEY: "legacy-key" }, authPaths: [] }), - "legacy-key", - ) - }) - - it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) - try { - const first = join(dir, "first.json") - const second = join(dir, "second.json") - const oauth = join(dir, "oauth.json") - const official = join(dir, "official.json") - writeFileSync(first, JSON.stringify({ apiKey: "file-key" })) - writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })) - writeFileSync( - oauth, - JSON.stringify({ - commandcode: { - type: "oauth", - access: "oauth-access-key", - refresh: "oauth-refresh-key", - expires: Date.now() + 3600000, - }, - }), - ) - writeFileSync( - official, - JSON.stringify({ - "command-code": { - type: "api", - key: "official-cli-key", - }, - }), - ) - assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key") - assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key") - assert.equal(getApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key") - assert.equal(getApiKey({ env: {}, authPaths: [official] }), "official-cli-key") - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it("ignores malformed auth files", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-")) - try { - const bad = join(dir, "bad.json") - writeFileSync(bad, "not json") - assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it("uses injected homeDir for default auth paths", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-home-")) - try { - const authDir = join(dir, ".pi", "agent") - mkdirSync(authDir, { recursive: true }) - writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" })) - assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key") - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) - -describe("error redaction", () => { - it("redacts bearer, credential, and query-string secrets", () => { - const redacted = redactCommandCodeErrorText( - "Bearer user_secret_value api_key=user_secret_value https://example.test/x?token=user_secret_value", - ) - assert.doesNotMatch(redacted, /user_secret_value/) - assert.match(redacted, /Bearer \[redacted\]/) - assert.doesNotMatch( - redactCommandCodeErrorText("provider returned sk-test-secret-value-1234567890"), - /sk-test-secret-value/, - ) - }) -}) - -describe("pickCommandCodeApiKey()", () => { - it("falls back to the host key for a placeholder registry value", () => { - assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", "file-key"), "file-key") - assert.equal(pickCommandCodeApiKey("COMMAND_CODE_API_KEY", "file-key"), "file-key") - assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", "file-key"), "file-key") - assert.equal(pickCommandCodeApiKey("COMMANDCODE_API_KEY", "file-key"), "file-key") - }) - - it("returns undefined when only a placeholder is provided (no fallback)", () => { - assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", undefined), undefined) - assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", undefined), undefined) - }) - - it("prefers a real registry key over the host fallback", () => { - assert.equal(pickCommandCodeApiKey("real-registry-key", "file-key"), "real-registry-key") - }) - - it("falls back to the host key when the registry has none", () => { - assert.equal(pickCommandCodeApiKey(undefined, "file-key"), "file-key") - assert.equal(pickCommandCodeApiKey(undefined, undefined), undefined) - }) - - it("falls back to the host key for empty or whitespace registry values", () => { - assert.equal(pickCommandCodeApiKey("", "file-key"), "file-key") - assert.equal(pickCommandCodeApiKey(" ", "file-key"), "file-key") - assert.equal(pickCommandCodeApiKey(" ", undefined), undefined) - }) - - it("trims a real registry key", () => { - assert.equal(pickCommandCodeApiKey(" real-registry-key ", "file-key"), "real-registry-key") - }) - - it("never returns a placeholder as the host fallback", () => { - assert.equal(pickCommandCodeApiKey(undefined, "$COMMAND_CODE_API_KEY"), undefined) - assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", "$COMMANDCODE_API_KEY"), undefined) - assert.equal(pickCommandCodeApiKey("COMMAND_CODE_API_KEY", "COMMANDCODE_API_KEY"), undefined) - }) - - it("omits a placeholder from registerProvider when no real key is configured", () => { - assert.equal(pickCommandCodeApiKey(undefined, undefined), undefined) - assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", undefined), undefined) - assert.equal(pickCommandCodeApiKey("user_real-key", undefined), "user_real-key") - }) -}) - -describe("withResolvedCommandCodeApiKey()", () => { - it("replaces a host placeholder with the configured key", () => { - assert.deepEqual( - withResolvedCommandCodeApiKey({ apiKey: "$COMMAND_CODE_API_KEY", extra: true }, "file-key"), - { apiKey: "file-key", extra: true }, - ) - }) - - it("drops a placeholder when no configured key exists", () => { - assert.deepEqual( - withResolvedCommandCodeApiKey({ apiKey: "$COMMAND_CODE_API_KEY" }, undefined), - { - apiKey: undefined, - }, - ) - }) - - it("keeps a real host key and injects a configured key when the host omitted one", () => { - const options = { apiKey: "host-key" } - assert.equal(withResolvedCommandCodeApiKey(options, "file-key"), options) - assert.deepEqual(withResolvedCommandCodeApiKey(undefined, "file-key"), { apiKey: "file-key" }) - }) -}) - -describe("projectSlugFromPath()", () => { - it("matches the official CLI-style slug from an absolute working directory", () => { - assert.equal( - projectSlugFromPath("/Users/patwoz/dev/Personal/pi/pi-commandcode-provider"), - "users-patwoz-dev-personal-pi-pi-commandcode-provider", - ) - assert.equal(projectSlugFromPath("/repo"), "repo") - }) -}) - -describe("text-only image handling", () => { - it("rejects direct image input for models without image support", () => { - assert.throws( - () => - assertTextOnlyMessages([ - { - role: "user", - content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], - }, - ]), - /does not support image content/i, - ) - }) - - it("allows historical tool-result images to be omitted for text-only models", () => { - assert.doesNotThrow(() => - assertTextOnlyMessages([ - { - role: "toolResult", - toolCallId: "c1", - content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], - }, - ]), - ) - }) -}) - -describe("textContent()", () => { - it("extracts and joins text blocks", () => { - assert.equal( - textContent({ - content: [ - { type: "text", text: "hello" }, - { type: "text", text: "world" }, - ], - }), - "hello\nworld", - ) - }) - - it("extracts text while images are handled separately", () => { - assert.equal( - textContent({ - content: [ - { type: "text", text: "hello" }, - { type: "image", data: "x", mimeType: "image/png" }, - { type: "text", text: "world" }, - ], - }), - "hello\nworld", - ) - }) - - it("normalizes malformed string and object content", () => { - assert.equal(textContent({ content: "raw result" }), "raw result") - assert.equal(textContent({ content: { ok: true } }), '{"ok":true}') - assert.equal(textContent({ content: null }), "") - }) - - it("handles empty or missing content", () => { - assert.equal(textContent({ content: [] }), "") - assert.equal(textContent({}), "") - }) -}) - -describe("getEnvironmentInfo()", () => { - it("returns platform, arch, and Node version", () => { - const info = getEnvironmentInfo() - assert.match(info, /^(darwin|linux|win32)-/) - assert.ok(info.includes("Node.js")) - }) -}) - -describe("toJsonSchema()", () => { - it("converts scalar, enum, object, optional, array, and union schema shapes", () => { - assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" }) - assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" }) - assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" }) - assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), { - type: "string", - enum: ["left", "right"], - }) - assert.deepEqual( - toJsonSchema({ - kind: "object", - properties: { - name: { kind: "string" }, - tags: { kind: "array", items: { kind: "string" }, optional: true }, - }, - }), - { - type: "object", - properties: { - name: { type: "string" }, - tags: { type: "array", items: { type: "string" } }, - }, - required: ["name"], - }, - ) - assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), { - type: "string", - }) - assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), { - type: "number", - }) - }) - - it("preserves explicit required arrays and handles unknown values", () => { - assert.deepEqual( - toJsonSchema({ - type: "object", - properties: { name: { type: "string" }, nickname: { type: "string" } }, - required: ["name"], - }), - { - type: "object", - properties: { name: { type: "string" }, nickname: { type: "string" } }, - required: ["name"], - }, - ) - assert.deepEqual(toJsonSchema(undefined), {}) - assert.deepEqual(toJsonSchema({ kind: "wat" }), {}) - assert.deepEqual(toJsonSchema({ type: "wat", description: "not a schema" }), {}) - assert.deepEqual(toJsonSchema({}), {}) - assert.equal(toJsonSchema(true), true) - }) - - it("preserves complete JSON Schema metadata and nested schemas", () => { - assert.deepEqual( - toJsonSchema({ - type: "object", - description: "Search options", - properties: { - query: { - type: "string", - description: "Text to search for", - minLength: 2, - maxLength: 50, - pattern: "^[a-z]+$", - default: "pi", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 100, - exclusiveMinimum: 0, - multipleOf: 1, - default: 10, - }, - tags: { - type: "array", - minItems: 1, - maxItems: 3, - uniqueItems: true, - items: { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - additionalProperties: false, - }, - }, - }, - required: ["query", "limit"], - additionalProperties: false, - }), - { - type: "object", - description: "Search options", - properties: { - query: { - type: "string", - description: "Text to search for", - minLength: 2, - maxLength: 50, - pattern: "^[a-z]+$", - default: "pi", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 100, - exclusiveMinimum: 0, - multipleOf: 1, - default: 10, - }, - tags: { - type: "array", - minItems: 1, - maxItems: 3, - uniqueItems: true, - items: { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - additionalProperties: false, - }, - }, - }, - required: ["query", "limit"], - additionalProperties: false, - }, - ) - }) - - it("preserves JSON Schema composition and nullable forms", () => { - assert.deepEqual( - toJsonSchema({ - anyOf: [{ type: "string" }, { type: "number" }], - oneOf: [{ const: "a" }, { const: "b" }], - allOf: [{ minLength: 1 }, { maxLength: 10 }], - nullable: true, - }), - { - anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], - oneOf: [{ const: "a" }, { const: "b" }], - allOf: [{ minLength: 1 }, { maxLength: 10 }], - }, - ) - assert.deepEqual(toJsonSchema({ type: ["string", "null"] }), { - type: ["string", "null"], - }) - assert.deepEqual(toJsonSchema({ type: "string", nullable: true }), { - type: ["string", "null"], - }) - }) - - it("preserves dangerous schema property names", () => { - const inputProperties: Record = { - constructor: { type: "number" }, - } - Object.defineProperty(inputProperties, "__proto__", { - configurable: true, - enumerable: true, - value: { type: "string" }, - writable: true, - }) - const schema = toJsonSchema({ - type: "object", - properties: inputProperties, - required: ["__proto__", "constructor"], - }) - assert.ok(schema && typeof schema === "object" && !Array.isArray(schema)) - if (!schema || typeof schema !== "object" || Array.isArray(schema)) { - throw new Error("expected object schema") - } - const outputProperties: unknown = Object.getOwnPropertyDescriptor(schema, "properties")?.value - assert.ok(outputProperties && typeof outputProperties === "object") - if (!outputProperties || typeof outputProperties !== "object") { - throw new Error("expected object properties") - } - assert.ok(Object.hasOwn(outputProperties, "__proto__")) - assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "__proto__")?.value, { - type: "string", - }) - assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "constructor")?.value, { - type: "number", - }) - }) - - it("converts legacy shapes without collapsing unions", () => { - assert.deepEqual( - toJsonSchema({ - kind: "Object", - description: "Legacy options", - properties: { - mode: { - kind: "union", - variants: [ - { kind: "string", enum: ["fast", "safe"] }, - { kind: "string", enum: ["debug"] }, - ], - }, - count: { kind: "Number", minimum: 1, optional: true }, - nested: { - kind: "Array", - element: { kind: "object", properties: { value: { kind: "boolean" } } }, - }, - }, - optional: ["count"], - additionalProperties: false, - }), - { - type: "object", - description: "Legacy options", - properties: { - mode: { - anyOf: [ - { type: "string", enum: ["fast", "safe"] }, - { type: "string", enum: ["debug"] }, - ], - }, - count: { type: "number", minimum: 1 }, - nested: { - type: "array", - items: { - type: "object", - properties: { value: { type: "boolean" } }, - required: ["value"], - }, - }, - }, - required: ["mode", "nested"], - additionalProperties: false, - }, - ) - assert.deepEqual( - toJsonSchema({ - kind: "intersect", - variants: [{ kind: "object", properties: { a: { kind: "string" } } }, { kind: "number" }], - }), - { - allOf: [ - { type: "object", properties: { a: { type: "string" } }, required: ["a"] }, - { type: "number" }, - ], - }, - ) - }) -}) - -describe("toolsToJson()", () => { - it("converts pi tools to Command Code tool JSON", () => { - assert.deepEqual( - toolsToJson([ - { - name: "get_weather", - description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - ]), - [ - { - type: "function", - name: "get_weather", - description: "Get weather", - input_schema: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"], - }, - }, - ], - ) - }) - - it("returns an empty array for missing tools", () => { - assert.deepEqual(toolsToJson(), []) - }) -}) - -describe("messagesToCC()", () => { - it("converts user, assistant, and tool result messages", () => { - const result = messagesToCC([ - { role: "user", content: "read /tmp/test" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "I will read" }, - { type: "text", text: "Sure" }, - { - type: "toolCall", - id: "c1", - name: "read", - arguments: { path: "/tmp/test" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - isError: false, - content: [ - { type: "text", text: "hello" }, - { type: "text", text: "world" }, - ], - }, - ]) - - assert.equal(objectAt(result, ["0", "role"]), "user") - assert.equal(objectAt(result, ["1", "role"]), "assistant") - assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") - assert.equal(objectAt(result, ["1", "content", "1", "type"]), "tool-call") - assert.equal(objectAt(result, ["1", "content", "2"]), undefined) - assert.equal(objectAt(result, ["2", "role"]), "tool") - assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld") - }) - - it("preserves malformed string tool results instead of sending empty output", () => { - const result = messagesToCC([ - { - role: "assistant", - content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - content: "raw result", - }, - ]) - - assert.equal(objectAt(result, ["1", "content", "0", "output", "value"]), "raw result") - }) - - it("serializes image inputs in the current Command Code wire format", () => { - assert.deepEqual( - messagesToCC( - [ - { - role: "user", - content: [ - { type: "text", text: "inspect this" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, - ], - }, - ], - { allowImages: true }, - ), - [ - { - role: "user", - content: [ - { type: "text", text: "inspect this" }, - { - type: "image", - image: "data:image/png;base64,aGVsbG8=", - mimeType: "image/png", - }, - ], - }, - ], - ) - }) - - it("omits tool-result images for text-only models while preserving their text", () => { - const result = messagesToCC([ - { role: "user", content: "read image" }, - { - role: "assistant", - content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - content: [ - { type: "text", text: "image attached" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }, - ], - }, - ]) - - assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "image attached") - assert.equal(objectAt(result, ["3"]), undefined) - }) - - it("describes an omitted image-only tool result for text-only models", () => { - const result = messagesToCC([ - { - role: "assistant", - content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }], - }, - ]) - - assert.equal( - objectAt(result, ["1", "content", "0", "output", "value"]), - "[Image omitted: model does not support images]", - ) - }) - - it("preserves tool-result images as a following user image message", () => { - const result = messagesToCC( - [ - { role: "user", content: "read image" }, - { - role: "assistant", - content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - content: [ - { type: "text", text: "image attached" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }, - ], - }, - ], - { allowImages: true }, - ) - - assert.equal(objectAt(result, ["2", "role"]), "tool") - assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "image attached") - assert.deepEqual(objectAt(result, ["3"]), { - role: "user", - content: [ - { - type: "image", - image: "data:image/jpeg;base64,aGVsbG8=", - mimeType: "image/jpeg", - }, - ], - }) - }) - - it("drops previous assistant reasoning while preserving text and tool calls", () => { - const result = messagesToCC([ - { role: "user", content: "first question" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "private reasoning from turn one" }, - { type: "text", text: "first answer" }, - ], - }, - { role: "user", content: "follow-up question" }, - ]) - - assert.deepEqual(result, [ - { role: "user", content: "first question" }, - { role: "assistant", content: [{ type: "text", text: "first answer" }] }, - { role: "user", content: "follow-up question" }, - ]) - }) - - it("omits assistant turns that contain only previous reasoning", () => { - const result = messagesToCC([ - { role: "user", content: "first question" }, - { - role: "assistant", - content: [{ type: "thinking", thinking: "private reasoning" }], - }, - { role: "user", content: "follow-up question" }, - ]) - - assert.deepEqual(result, [ - { role: "user", content: "first question" }, - { role: "user", content: "follow-up question" }, - ]) - }) - - it("synthesizes missing results for orphaned tool calls", () => { - const result = messagesToCC([ - { role: "user", content: "edit a file" }, - { - role: "assistant", - content: [ - { type: "text", text: "I will edit it" }, - { - type: "toolCall", - id: "missing-result", - name: "edit", - arguments: { path: "x" }, - }, - ], - }, - ]) - - assert.equal(objectAt(result, ["1", "role"]), "assistant") - assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") - assert.equal(objectAt(result, ["1", "content", "1", "type"]), "tool-call") - assert.equal(objectAt(result, ["2", "role"]), "tool") - assert.match( - String(objectAt(result, ["2", "content", "0", "output", "value"])), - /did not complete/, - ) - }) - - it("handles empty conversations", () => { - assert.deepEqual(messagesToCC([]), []) - }) - - it("keeps developer messages instead of dropping them", () => { - const result = messagesToCC([ - { role: "user", content: "start" }, - { role: "developer", content: "mid-conversation steering note" }, - ]) - - assert.deepEqual(result, [ - { role: "user", content: "start" }, - { role: "user", content: "mid-conversation steering note" }, - ]) - }) - - it("converts developer text parts with the same shape as user content", () => { - const result = messagesToCC([ - { - role: "developer", - content: [{ type: "text", text: "reminder one" }], - }, - ]) - - assert.deepEqual(result, [ - { - role: "user", - content: [{ type: "text", text: "reminder one" }], - }, - ]) - }) - - it("preserves advisory XML verbatim in the serialized request messages", () => { - const advisory = - '\nStop and correct the benchmark.\n' - const serialized = JSON.stringify(messagesToCC([{ role: "developer", content: advisory }])) - - assert.deepEqual(JSON.parse(serialized), [{ role: "user", content: advisory }]) - }) - - it("keeps developer advisories in chronological position without hoisting", () => { - const advisory = - '\nStop and correct the benchmark.\n' - const result = messagesToCC([ - { role: "user", content: "run the benchmark" }, - { - role: "assistant", - content: [ - { type: "text", text: "running it" }, - { type: "toolCall", id: "c1", name: "bash", arguments: { command: "bench" } }, - ], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "bash", - content: [{ type: "text", text: "benchmark output" }], - }, - { role: "developer", content: advisory }, - { role: "user", content: "continue" }, - ]) - - assert.deepEqual(result, [ - { role: "user", content: "run the benchmark" }, - { - role: "assistant", - content: [ - { type: "text", text: "running it" }, - { type: "tool-call", toolCallId: "c1", toolName: "bash", input: { command: "bench" } }, - ], - }, - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "c1", - toolName: "bash", - output: { type: "text", value: "benchmark output" }, - }, - ], - }, - { role: "user", content: advisory }, - { role: "user", content: "continue" }, - ]) - }) -}) - -describe("parseStreamEventLine()", () => { - it("parses plain JSON and SSE data lines", () => { - assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), { - type: "text-delta", - text: "x", - }) - assert.deepEqual(parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), { - type: "finish", - finishReason: "stop", - }) - }) - - it("ignores comments, event labels, done markers, and malformed JSON", () => { - assert.equal(parseStreamEventLine(":"), undefined) - assert.equal(parseStreamEventLine("event: message"), undefined) - assert.equal(parseStreamEventLine("data: [DONE]"), undefined) - assert.equal(parseStreamEventLine("not-json"), undefined) - }) -}) - -describe("mapFinishReason()", () => { - it("maps provider finish reasons to pi stop reasons", () => { - assert.equal(mapFinishReason("stop"), "stop") - assert.equal(mapFinishReason("tool-calls"), "toolUse") - assert.equal(mapFinishReason("max_tokens"), "length") - assert.equal(mapFinishReason("max_output_tokens"), "length") - }) -}) diff --git a/tests/test-quota-command.ts b/tests/test-quota-command.ts deleted file mode 100644 index 3ae753c..0000000 --- a/tests/test-quota-command.ts +++ /dev/null @@ -1,117 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { registerCommandCodeQuota, type QuotaCommandContext } from "../src/quota-command.ts" -import type { CommandCodeQuotaResult } from "../src/quota-types.ts" - -class CommandApiDouble { - handler?: (args: string, ctx: QuotaCommandContext) => Promise - - registerCommand( - name: string, - options: { - description: string - handler: (args: string, ctx: QuotaCommandContext) => Promise - }, - ): void { - assert.equal(name, "commandcode-quota") - assert.match(options.description, /usage and quota/) - this.handler = options.handler - } -} - -function context(registryKey: string | undefined) { - const notifications: Array<{ message: string; type?: "info" | "warning" | "error" }> = [] - let waited = false - const value = { - async waitForIdle() { - waited = true - }, - modelRegistry: { - async getApiKeyForProvider(provider: string) { - assert.equal(provider, "commandcode") - return registryKey - }, - }, - ui: { - notify(message: string, type?: "info" | "warning" | "error") { - notifications.push({ message, type }) - }, - }, - } satisfies QuotaCommandContext - return { value, notifications, waited: () => waited } -} - -const quotaResult: CommandCodeQuotaResult = { - ok: true, - quota: { - account: { login: "alice", orgId: null }, - credits: null, - subscription: null, - summary: { totalCost: 1, totalCount: 2 }, - }, -} - -describe("commandcode-quota command", () => { - it("registers the command and resolves OMP placeholders through the fallback key", async () => { - const pi = new CommandApiDouble() - let requestKey = "" - let requestBase = "" - registerCommandCodeQuota(pi, { - apiBase: "https://api.commandcode.ai", - getConfiguredKey: () => "fallback-key", - fetchQuota: async (options) => { - requestKey = options.apiKey - requestBase = options.baseUrl ?? "" - return quotaResult - }, - }) - - assert.ok(pi.handler) - const ctx = context("$COMMAND_CODE_API_KEY") - await pi.handler("", ctx.value) - assert.equal(ctx.waited(), true) - assert.equal(requestKey, "fallback-key") - assert.equal(requestBase, "https://api.commandcode.ai") - assert.equal(ctx.notifications.at(-1)?.type, "info") - assert.match(ctx.notifications.at(-1)?.message ?? "", /Requests: 2/) - }) - - it("warns without calling the endpoint when no API key is available", async () => { - const pi = new CommandApiDouble() - let called = false - registerCommandCodeQuota(pi, { - apiBase: "https://api.commandcode.ai", - getConfiguredKey: () => undefined, - fetchQuota: async () => { - called = true - return quotaResult - }, - }) - - assert.ok(pi.handler) - const ctx = context(undefined) - await pi.handler("", ctx.value) - assert.equal(called, false) - assert.equal(ctx.notifications.at(-1)?.type, "warning") - assert.match(ctx.notifications.at(-1)?.message ?? "", /requires an API key/) - }) - - it("redacts endpoint failures before notifying the host", async () => { - const pi = new CommandApiDouble() - registerCommandCodeQuota(pi, { - apiBase: "https://api.commandcode.ai", - getConfiguredKey: () => "real-key", - fetchQuota: async () => ({ - ok: false, - error: { kind: "http", message: "api_key=supersecretvalue123456 failed" }, - }), - }) - - assert.ok(pi.handler) - const ctx = context("real-key") - await pi.handler("", ctx.value) - assert.equal(ctx.notifications.at(-1)?.type, "error") - assert.doesNotMatch(ctx.notifications.at(-1)?.message ?? "", /supersecret/) - }) -}) diff --git a/tests/test-quota.ts b/tests/test-quota.ts deleted file mode 100644 index 23e1c5c..0000000 --- a/tests/test-quota.ts +++ /dev/null @@ -1,466 +0,0 @@ -/** - * Unit tests for the Command Code quota layer (src/quota.ts). - * - * These are hermetic: no pi runtime and no network. Fetching is exercised with - * a mocked `fetchImpl`, while parsing and formatting are pure function checks. - */ - -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { formatQuota, formatWindowLimits } from "../src/quota-format.ts" -import { - DEFAULT_API_BASE, - fetchCommandCodeQuota, - redactValue, - windowLimitsFromCredits, -} from "../src/quota.ts" -import type { - CommandCodeCredits, - CommandCodeQuota, - CommandCodeWindowLimit, -} from "../src/quota-types.ts" - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }) -} - -function okFetch(handlers: Record) { - const urls: string[] = [] - const fetchImpl = async (input: RequestInfo | URL): Promise => { - const url = String(input) - urls.push(url) - for (const [needle, body] of Object.entries(handlers)) { - if (url.includes(needle)) return jsonResponse(body) - } - throw new Error(`Unexpected URL: ${url}`) - } - return { fetchImpl, urls: () => urls } -} - -describe("Command Code quota", () => { - it("parses window limits from the credits windowLimits object", () => { - const limits = windowLimitsFromCredits({ - limited: true, - // resetAt as reported by the live API: milliseconds since epoch. - fiveHour: { used: 8, cap: 14, resetAt: 1_700_000_000_000 }, - weekly: { used: 30, cap: 35, resetAt: 1_700_000_000_000 }, - }) - assert.deepEqual(limits, [ - { window: "fiveHour", used: 8, cap: 14, resetAt: 1_700_000_000 }, - { window: "weekly", used: 30, cap: 35, resetAt: 1_700_000_000 }, - ]) - }) - - it("skips empty window limit entries", () => { - const limits = windowLimitsFromCredits({ - limited: false, - fiveHour: { used: 0, cap: 0, resetAt: null }, - weekly: { used: 0, cap: 0, resetAt: null }, - }) - assert.deepEqual(limits, []) - }) - - it("parses resetAt as numeric string or ISO timestamp string", () => { - const limits = windowLimitsFromCredits({ - fiveHour: { used: 1, cap: 2, resetAt: "1700000000000" }, - weekly: { used: 1, cap: 2, resetAt: "2023-11-14T22:13:20.000Z" }, - }) - // numeric ms string -> epoch seconds; ISO string -> epoch seconds - assert.equal(limits[0]?.resetAt, 1_700_000_000) - assert.equal(limits[1]?.resetAt, 1_700_000_000) - }) - - it("renders valid zero usage without claiming an unknown billing period", () => { - const quota: CommandCodeQuota = { - account: { login: "alice", orgId: null }, - credits: null, - subscription: null, - summary: { totalCost: 0, totalCount: 0 }, - } - const output = formatQuota(quota, () => 1_700_000_000_000) - assert.match(output, /Usage\n/) - assert.doesNotMatch(output, /billing period/) - assert.match(output, /Requests: 0/) - }) - - it("formats window limits with percentage and reset clock", () => { - const limits: CommandCodeWindowLimit[] = [ - { window: "fiveHour", used: 7, cap: 14, resetAt: 1_700_000_000 }, - { window: "weekly", used: 0, cap: 35, resetAt: null }, - ] - const lines = formatWindowLimits(limits) - assert.match(lines[0] ?? "", /^5-hour: 7\.00 \/ 14\.00 credits \(50% used\) \(resets/) - assert.match(lines[1] ?? "", /^Weekly: 0\.00 \/ 35\.00 credits \(0% used\)/) - }) - - it("uses the injected clock for the reset countdown", () => { - const limit: CommandCodeWindowLimit = { - window: "fiveHour", - used: 7, - cap: 14, - resetAt: 1_700_000_000, // seconds since epoch - } - // now() shortly before reset -> a short "in Nm" countdown - const soon = formatWindowLimits([limit], () => 1_699_999_000 * 1000)[0] - assert.match(soon ?? "", /\(resets in \d+m\)/) - // already past reset -> "soon" - const past = formatWindowLimits([limit], () => 1_700_100_000 * 1000)[0] - assert.match(past ?? "", /\(resets soon\)/) - }) - - it("fetches and normalizes the full quota snapshot", async () => { - const { fetchImpl, urls } = okFetch({ - whoami: { user: { userName: "alice" }, org: { id: "org_1", login: "alice-inc" } }, - credits: { - credits: { - monthlyCredits: 40, - purchasedCredits: 10, - freeCredits: 5, - planId: "pro", - }, - windowLimits: { - fiveHour: { used: 8, cap: 16, resetAt: 1_700_000_000_000 }, - weekly: { used: 20, cap: 40, resetAt: null }, - }, - }, - subscriptions: { - data: { - planId: "pro", - status: "active", - currentPeriodStart: "2026-01-01T00:00:00Z", - currentPeriodEnd: "2026-02-01T00:00:00Z", - }, - }, - summary: { totalCost: 12.34, totalCount: 1500 }, - }) - - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) - assert.equal(result.ok, true) - if (!result.ok) return - - assert.equal(result.quota.account.login, "alice-inc") - assert.equal(result.quota.account.orgId, "org_1") - assert.deepEqual(result.quota.credits?.remainingCredits, 55) - assert.equal(result.quota.credits?.windowLimits.length, 2) - assert.equal(result.quota.subscription?.planId, "pro") - assert.equal(result.quota.summary?.totalCost, 12.34) - - // Regression: requested URLs must carry the base exactly once (no - // double prefix), and all hit the alpha usage endpoints. - const fetched = urls() - assert.equal(fetched.length, 4) - for (const url of fetched) { - assert.ok( - /^https:\/\/api\.commandcode\.ai\/alpha\//.test(url), - `expected base-prefixed alpha URL, got: ${url}`, - ) - assert.equal((url.match(/https:\/\//g) ?? []).length, 1) - assert.equal(url.includes(`${DEFAULT_API_BASE}${DEFAULT_API_BASE}`), false) - } - }) - - it("rejects unrecognized successful endpoint schemas instead of displaying zero usage", async () => { - const fetchImpl = async (input: RequestInfo | URL): Promise => { - const url = String(input) - if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) - return jsonResponse({ changed: "schema" }) - } - - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) - assert.equal(result.ok, false) - if (result.ok) return - assert.equal(result.error.kind, "http") - assert.match(result.error.message, /no recognized usage data/i) - }) - - it("degrades gracefully when individual billing endpoints fail", async () => { - const fetchImpl = async (input: RequestInfo | URL): Promise => { - const url = String(input) - if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) - if (url.includes("summary")) return jsonResponse({ totalCost: 3.0, totalCount: 10 }) - if (url.includes("credits") || url.includes("subscriptions")) { - return jsonResponse({ error: "boom" }, 500) - } - throw new Error(`Unexpected URL: ${url}`) - } - - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) - assert.equal(result.ok, true) - if (!result.ok) return - assert.equal(result.quota.credits, null) - assert.equal(result.quota.summary?.totalCost, 3.0) - assert.deepEqual(result.quota.unavailable, ["credits", "subscription"]) - assert.match(formatQuota(result.quota), /Unavailable: credits, subscription/) - // Optional aggregate tokens are parsed when the summary reports them. - assert.equal(result.quota.summary?.totalTokens, undefined) - }) - - it("degrades on thrown network failures from optional endpoints, not just HTTP 5xx", async () => { - const fetchImpl = async (input: RequestInfo | URL): Promise => { - const url = String(input) - if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) - if (url.includes("summary")) return jsonResponse({ totalCost: 3.0, totalCount: 10 }) - if (url.includes("credits")) throw new Error("network down") - if (url.includes("subscriptions")) return jsonResponse({ data: { planId: "pro" } }) - throw new Error(`Unexpected URL: ${url}`) - } - - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) - assert.equal(result.ok, true) - if (!result.ok) return - assert.equal(result.quota.credits, null) - assert.equal(result.quota.subscription?.planId, "pro") - assert.equal(result.quota.summary?.totalCost, 3.0) - assert.deepEqual(result.quota.unavailable, ["credits"]) - }) - - it("fails the command when the summary endpoint rejects auth/permission", async () => { - const fetchImpl = async (input: RequestInfo | URL): Promise => { - const url = String(input) - if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) - if (url.includes("credits")) return jsonResponse({ credits: { monthlyCredits: 5 } }) - if (url.includes("subscriptions")) return jsonResponse({ data: { planId: "pro" } }) - if (url.includes("summary")) return jsonResponse({ error: "nope" }, 403) - throw new Error(`Unexpected URL: ${url}`) - } - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) - assert.equal(result.ok, false) - if (result.ok) return - assert.equal(result.error.kind, "http") - assert.match(result.error.message, /summary/) - }) - - it("does not treat 429 on billing endpoints as fatal", async () => { - const fetchImpl = async (input: RequestInfo | URL): Promise => { - const url = String(input) - if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) - if (url.includes("summary")) return jsonResponse({ totalCost: 3.0, totalCount: 10 }) - if (url.includes("credits") || url.includes("subscriptions")) { - return jsonResponse({ error: "rate limited" }, 429) - } - throw new Error(`Unexpected URL: ${url}`) - } - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) - assert.equal(result.ok, true) - if (!result.ok) return - assert.equal(result.quota.credits, null) - assert.equal(result.quota.summary?.totalCost, 3.0) - }) - - it("sends extra headers (ZDR) on quota requests", async () => { - let sent: Headers | undefined - const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - sent = (init?.headers as Headers) ?? undefined - const url = String(input) - if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) - if (url.includes("credits")) return jsonResponse({ credits: { monthlyCredits: 5 } }) - if (url.includes("subscriptions")) return jsonResponse({ data: { planId: "pro" } }) - if (url.includes("summary")) return jsonResponse({ totalCost: 1, totalCount: 1 }) - throw new Error(`Unexpected URL: ${url}`) - } - const result = await fetchCommandCodeQuota({ - apiKey: "cc_test_key", - fetchImpl, - extraHeaders: { "x-cmd-zdr": "1" }, - }) - assert.equal(result.ok, true) - const headers = new Headers(sent) - assert.equal(headers.get("x-cmd-zdr"), "1") - }) - - it("parses optional token count and key name when present", async () => { - const { fetchImpl } = okFetch({ - whoami: { user: { userName: "alice", keyName: "Pi Agent" }, org: null }, - credits: { credits: { monthlyCredits: 5, purchasedCredits: 0, freeCredits: 0 } }, - subscriptions: { - data: { - planId: "pro", - status: "active", - currentPeriodEnd: Date.parse("2026-02-01T00:00:00Z"), - }, - }, - summary: { totalCost: 1.06, totalCount: 654, totalTokens: 74_200_000 }, - }) - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) - assert.equal(result.ok, true) - if (!result.ok) return - assert.equal(result.quota.summary?.totalTokens, 74_200_000) - assert.equal(result.quota.account.keyName, "Pi Agent") - assert.equal(result.quota.subscription?.currentPeriodEnd, "1769904000000") - }) - - it("rejects missing API keys as a config error", async () => { - const result = await fetchCommandCodeQuota({ apiKey: "" }) - assert.equal(result.ok, false) - if (result.ok) return - assert.equal(result.error.kind, "config") - }) - - it("fails with a config-style error when the API key is rejected", async () => { - const fetchImpl = async (input: RequestInfo | URL): Promise => { - if (String(input).includes("whoami")) return jsonResponse({ error: "unauthorized" }, 401) - throw new Error(`Unexpected URL: ${input}`) - } - const result = await fetchCommandCodeQuota({ apiKey: "cc_bad_key", fetchImpl }) - assert.equal(result.ok, false) - if (result.ok) return - assert.equal(result.error.kind, "http") - assert.match(result.error.message, /401/) - }) - - it("formats a complete quota snapshot into readable output", () => { - const quota: CommandCodeQuota = { - account: { login: "alice-inc", orgId: "org_1" }, - credits: { - monthlyCredits: 40, - purchasedCredits: 10, - freeCredits: 5, - remainingCredits: 55, - windowLimits: [ - { window: "fiveHour", used: 8, cap: 16, resetAt: null }, - { window: "weekly", used: 20, cap: 40, resetAt: null }, - ], - } satisfies CommandCodeCredits, - subscription: { - planId: "pro", - status: "active", - currentPeriodStart: "2026-01-01T00:00:00Z", - currentPeriodEnd: "2026-02-01T00:00:00Z", - }, - summary: { totalCost: 12.34, totalCount: 1500 }, - } - - const output = formatQuota(quota, () => Date.parse("2026-01-15T00:00:00Z")) - assert.doesNotMatch(output, /Command Code quota —/) - assert.match(output, /Credits/) - assert.match(output, /Remaining: \$55\.00 of \$67\.34/) - assert.match(output, /Used: \$12\.34/) - assert.match(output, /Sources: monthly \$40\.00 \/ purchased \$10\.00 \/ free \$5\.00/) - assert.match(output, /Plan: pro \(active\) · renews Feb 1 \(17d\)/) - assert.match(output, /Usage \(billing period\)/) - assert.match(output, /Cost: \$12\.34/) - assert.match(output, /Requests: 1,500/) - assert.match(output, /Account/) - assert.match(output, /alice-inc/) - assert.match(output, /5-hour: 8\.00 \/ 16\.00 credits/) - assert.match(output, /Weekly: 20\.00 \/ 40\.00 credits/) - assert.match(output, /https:\/\/commandcode\.ai\/usage/) - }) - - it("formats renewal dates in UTC and handles renewal edge cases", () => { - const baseQuota: CommandCodeQuota = { - account: { login: "alice", orgId: null }, - credits: null, - subscription: null, - summary: null, - } - const formatRenewal = (currentPeriodEnd: string | null, now: string) => - formatQuota( - { - ...baseQuota, - subscription: { - planId: "pro", - status: "active", - currentPeriodStart: null, - currentPeriodEnd, - }, - }, - () => Date.parse(now), - ) - - const beforeReset = formatRenewal("2026-02-01T00:00:00Z", "2026-01-31T12:00:00Z") - assert.match(beforeReset, /Plan: pro \(active\) · renews Feb 1 \(1d\)/) - - const numericTimestamp = formatRenewal( - String(Date.parse("2026-02-01T00:00:00Z")), - "2026-01-31T12:00:00Z", - ) - assert.match(numericTimestamp, /Plan: pro \(active\) · renews Feb 1 \(1d\)/) - - const today = formatRenewal("2026-01-31T12:00:00Z", "2026-01-31T12:00:00Z") - assert.match(today, /Plan: pro \(active\) · renews Jan 31 \(today\)/) - - const expired = formatRenewal("2026-01-30T00:00:00Z", "2026-01-31T12:00:00Z") - assert.match(expired, /Plan: pro \(active\) · renewed Jan 30/) - - for (const currentPeriodEnd of [null, "not-a-date"]) { - const output = formatRenewal(currentPeriodEnd, "2026-01-31T12:00:00Z") - assert.doesNotMatch(output, /renews|renewed/) - } - }) - - it("redacts token-like values from error messages", () => { - // 16+ char run after a credential key is redacted by the shared redactor. - assert.equal(redactValue("api_key=abcdefghijklmnop123456"), "api_key=[redacted]") - assert.equal(redactValue("Bearer user_12345678901234 failed"), "Bearer [redacted] failed") - }) - - it("redacts named credential fields and short tokens from error bodies", () => { - // Credential key-value forms (with = or : separator) are redacted. - assert.equal(redactValue("api_key=abc123"), "api_key=[redacted]") - assert.equal( - redactValue("authorization=Basic abc:def failed"), - "authorization=[redacted] abc:def failed", - ) - assert.equal(redactValue("user_123456789 failed"), "[redacted] failed") - assert.equal(redactValue("cc_abcdefghijkl failed"), "[redacted] failed") - assert.equal( - redactValue("token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret"), - "token=[redacted]", - ) - }) - - it("redacts JSON-quoted credential fields in error bodies", () => { - assert.equal( - redactValue('{"apiKey":"sk-abcdefghijklmnop123456","ok":true}'), - '{"apiKey":"[redacted]","ok":true}', - ) - assert.equal( - redactValue('{"error":"bad","access_token":"opaque-internal-token-12345"}'), - '{"error":"bad","access_token":"[redacted]"}', - ) - assert.equal( - redactValue('{"authorization":"Bearer user_1234"}'), - '{"authorization":"[redacted]"}', - ) - }) - - it("redacts thrown network errors from the outer catch path", async () => { - const fetchImpl = async (_input: RequestInfo | URL): Promise => { - throw new Error("connection reset by proxy api_key=supersecretvalue123456") - } - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) - assert.equal(result.ok, false) - if (result.ok) return - assert.doesNotMatch(result.error.message, /supersecretvalue123456/) - assert.match(result.error.kind, /network/) - }) - - it("honors the overall deadline once it has already fired (no phase starts after abort)", async () => { - const start = Date.now() - const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = String(input) - if (url.includes("whoami")) { - // Never resolve; let the per-request controller abort it at timeoutMs. - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => - reject(Object.assign(new Error("aborted"), { name: "AbortError" })), - ) - }) - } - throw new Error(`Unexpected URL: ${url}`) - } - - const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl, timeoutMs: 30 }) - const elapsed = Date.now() - start - assert.equal(result.ok, false) - if (result.ok) return - assert.equal(result.error.kind, "timeout") - // The overall deadline governs the whole command; no phase may add ~30ms on top. - assert.ok(elapsed < 200, `elapsed ${elapsed}ms exceeded overall deadline`) - }) -}) diff --git a/tests/test-retry.ts b/tests/test-retry.ts deleted file mode 100644 index e0366de..0000000 --- a/tests/test-retry.ts +++ /dev/null @@ -1,470 +0,0 @@ -/** - * Tests for retry and timeout behaviour driven by pi settings.json - * retry config (timeoutMs, maxRetries, maxRetryDelayMs). - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import type { AssistantMessageEvent } from "../src/core.ts" -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -const TEST_API_KEY = "option-key" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -function eventTypes(events: readonly AssistantMessageEvent[]): string[] { - return events.map((event) => event.type) -} - -describe("streamCommandCode — retry on transient errors", () => { - it("retries on 429 and succeeds on the second attempt", async () => { - server.mockResponseQueue([ - { type: "error", status: 429, body: "rate limited" }, - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "ok" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 2, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "stop") - }) - - it("retries on 500 and succeeds on the second attempt", async () => { - server.mockResponseQueue([ - { type: "error", status: 500, body: "internal server error" }, - { - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 2, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.equal(events.at(-1)?.type, "done") - }) - - it("does NOT retry on 400 (non-retryable client error)", async () => { - server.mockResponse({ type: "error", status: 400, body: "bad request" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY }), - ) - - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const last = events.at(-1) - if (last?.type !== "error") throw new Error("expected error") - assert.match(last.error.errorMessage ?? "", /400/) - }) - - it("exhausts maxRetries and emits an error", async () => { - server.mockResponse({ type: "error", status: 503, body: "unavailable" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 3, - }), - ) - - // initial attempt + 3 retries = 4 total - assert.equal(server.requestCount(), 4) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const last503 = events.at(-1) - if (last503?.type !== "error") throw new Error("expected error") - assert.match(last503.error.errorMessage ?? "", /503/) - }) -}) - -describe("streamCommandCode — Retry-After header", () => { - it("respects Retry-After delay in seconds", async () => { - let delayCalled = false - server.mockResponseQueue([ - { - type: "error", - status: 429, - body: "rate limited", - headers: { "retry-after": "2" }, - }, - { - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }, - ]) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - delay: async (ms: number) => { - delayCalled = true - assert.equal(ms, 2000) - }, - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 2, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.equal(events.at(-1)?.type, "done") - assert.ok(delayCalled, "delay should have been called with Retry-After value") - }) - - it("fails immediately when Retry-After exceeds maxRetryDelayMs", async () => { - server.mockResponse({ - type: "error", - status: 429, - body: "rate limited", - headers: { "retry-after": "300" }, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetryDelayMs: 10_000, - }), - ) - - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const lastMax = events.at(-1) - if (lastMax?.type !== "error") throw new Error("expected error") - assert.match(lastMax.error.errorMessage ?? "", /exceeds max/) - }) - - it("does not cap Retry-After when maxRetryDelayMs is 0", async () => { - let delayCalled = false - server.mockResponseQueue([ - { - type: "error", - status: 429, - body: "rate limited", - headers: { "retry-after": "120" }, - }, - { - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }, - ]) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - delay: async (ms: number) => { - delayCalled = true - assert.equal(ms, 120_000) - }, - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 1, - maxRetryDelayMs: 0, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.equal(events.at(-1)?.type, "done") - assert.ok(delayCalled) - }) -}) - -describe("streamCommandCode — timeout", () => { - it("retries on per-attempt timeout and succeeds", async () => { - server.mockResponseQueue([ - { - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - hangAfterLast: true, - responseDelay: 200, - }, - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "fast" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - timeoutMs: 50, - maxRetries: 2, - }), - 5_000, - ) - - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) - }) - - it("retries when the response starts but the stream hangs before finish", async () => { - server.mockResponseQueue([ - { - type: "success", - events: [], - hangAfterLast: true, - }, - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "ok" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - timeoutMs: 50, - maxRetries: 2, - }), - 5_000, - ) - - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) - }) - - it("does NOT retry on timeout after partial text-delta was emitted", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "text-delta", text: "partial" })], - hangAfterLast: true, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - timeoutMs: 50, - maxRetries: 2, - }), - 5_000, - ) - - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "error"]) - }) - - it("emits error when all retry attempts time out", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - hangAfterLast: true, - responseDelay: 200, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - timeoutMs: 50, - maxRetries: 1, - }), - 5_000, - ) - - // initial + 1 retry = 2 - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /timed out after 50ms/) - }) -}) - -describe("streamCommandCode — abort cancels retry loop", () => { - it("user abort stops retries immediately", async () => { - server.mockResponse({ type: "error", status: 500, body: "error" }) - const controller = new AbortController() - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - delay: async (_ms: number, signal: AbortSignal) => { - // Abort during the retry delay - controller.abort() - // Simulate the real delay which rejects on abort - return new Promise((_, reject) => { - if (signal.aborted) reject(new DOMException("Aborted", "AbortError")) - signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))) - }) - }, - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - signal: controller.signal, - maxRetries: 10, - }), - ) - - // Should only have made 1 request (the initial one), then aborted during delay - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - }) -}) - -describe("streamCommandCode — retry defaults", () => { - it("uses default maxRetries of 0 when not specified", async () => { - server.mockResponse({ type: "error", status: 500, body: "error" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY })) - - assert.equal(server.requestCount(), 1) - }) - - it("respects maxRetries: 0 (no retries)", async () => { - server.mockResponse({ type: "error", status: 500, body: "error" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 0, - }), - ) - - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "error"]) - }) -}) - -describe("streamCommandCode — stream-level error retry", () => { - it("retries when API returns 200 OK but stream contains an error event", async () => { - server.mockResponseQueue([ - { - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: "Service temporarily unavailable. Please try again shortly.", - }), - ], - }, - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "ok" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 2, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) - }) - - it("exhausts retries on persistent stream-level errors", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: "Service temporarily unavailable. Please try again shortly.", - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 3, - }), - ) - - // initial + 3 retries = 4 - assert.equal(server.requestCount(), 4) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const last = events.at(-1) - if (last?.type !== "error") throw new Error("expected error") - assert.match(last.error.errorMessage ?? "", /temporarily unavailable/) - }) - - it("does NOT retry stream error when content was already emitted", async () => { - server.mockResponseQueue([ - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "partial" }), - JSON.stringify({ - type: "error", - error: "Service temporarily unavailable", - }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY }), - ) - - // Only 1 request — no retry because content was already emitted. - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "error"]) - }) -}) diff --git a/tests/test-runtime.ts b/tests/test-runtime.ts deleted file mode 100644 index 28f9a42..0000000 --- a/tests/test-runtime.ts +++ /dev/null @@ -1,421 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { - createCommandCodeRuntime, - type CommandCodeCommandContext, - type CommandCodeRuntimeApi, -} from "../src/runtime.ts" -import type { CommandCodeModel, LoadCommandCodeModelsResult } from "../src/models.ts" - -type ProviderConfig = { - models: readonly CommandCodeModel[] -} - -class ExtensionAPITestDouble implements CommandCodeRuntimeApi { - readonly providers: ProviderConfig[] = [] - readonly commands = new Map Promise | void>() - - registerProvider(_name: string, config: ProviderConfig): void { - this.providers.push(config) - } - - registerCommand( - name: string, - options: { - description: string - handler: (args: string, ctx: CommandContext) => Promise | void - }, - ): void { - this.commands.set(name, options.handler) - } -} - -class CommandContext implements CommandCodeCommandContext { - readonly notifications: Array<{ message: string; type?: "info" | "warning" | "error" }> = [] - waitForIdleCalls = 0 - - readonly ui = { - notify: (message: string, type?: "info" | "warning" | "error") => { - this.notifications.push({ message, type }) - }, - } - - async waitForIdle(): Promise { - this.waitForIdleCalls += 1 - } -} - -const FIRST_MODEL: CommandCodeModel = { - id: "first-model", - name: "First Model", - api: "openai-completions", - reasoning: true, - contextWindow: 128_000, - maxTokens: 16_384, -} - -const SECOND_MODEL: CommandCodeModel = { - id: "second-model", - name: "Second Model", - api: "openai-completions", - reasoning: true, - contextWindow: 256_000, - maxTokens: 32_768, -} - -function loaded( - models: readonly CommandCodeModel[], - source: LoadCommandCodeModelsResult["source"] = "live", - warning?: string, -): LoadCommandCodeModelsResult { - return warning ? { models, source, warning } : { models, source } -} - -function deferred(): { - promise: Promise - resolve(value: T): void - reject(error: unknown): void -} { - let resolvePromise: (value: T) => void = () => {} - let rejectPromise: (error: unknown) => void = () => {} - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve - rejectPromise = reject - }) - return { promise, resolve: resolvePromise, reject: rejectPromise } -} - -describe("Command Code runtime", () => { - it("registers refresh and status commands and exposes redacted state", async () => { - const pi = new ExtensionAPITestDouble() - const context = new CommandContext() - let now = 1_700_000_000_000 - const firstLoad = deferred() - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models?token=user_secret_value", - cachePath: "/tmp/commandcode-models.json", - loadModels: () => firstLoad.promise, - loadCachedModels: async () => [], - createProviderConfig: (models) => ({ models }), - getTransport: () => "provider", - now: () => now, - logWarning: () => {}, - }) - - const initialization = runtime.initialize() - assert.deepEqual([...pi.commands.keys()], ["commandcode-refresh", "commandcode-status"]) - await Promise.resolve() - assert.equal(runtime.getStatus().refreshing, true) - assert.equal(runtime.getStatus().lastAttempt, now) - - firstLoad.resolve(loaded([FIRST_MODEL])) - await initialization - now += 1_000 - - const statusCommand = pi.commands.get("commandcode-status") - assert.ok(statusCommand) - await statusCommand("", context) - const statusMessage = context.notifications.at(-1)?.message ?? "" - assert.match(statusMessage, /transport: provider/) - assert.match(statusMessage, /source: live/) - assert.match(statusMessage, /model count: 1/) - assert.match(statusMessage, /last success:/) - assert.match(statusMessage, /last attempt:/) - assert.match(statusMessage, /cache path: \/tmp\/commandcode-models\.json/) - assert.match(statusMessage, /endpoint: https:\/\/api\.commandcode\.ai\/provider\/v1\/models/) - assert.doesNotMatch(statusMessage, /token=user_secret_value/) - assert.doesNotMatch(statusMessage, /user_secret_value/) - }) - - it("coalesces overlapping refreshes and preserves the current catalog on failure", async () => { - const pi = new ExtensionAPITestDouble() - const warnings: string[] = [] - const loads = [Promise.resolve(loaded([FIRST_MODEL])), deferred()] - let loadCount = 0 - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models", - cachePath: "/tmp/commandcode-models.json", - loadModels: () => { - const next = loads[loadCount] - loadCount += 1 - if (!next) throw new Error("unexpected refresh") - return next instanceof Promise ? next : next.promise - }, - loadCachedModels: async () => [], - createProviderConfig: (models) => ({ models }), - logWarning: (warning) => warnings.push(warning), - }) - - await runtime.initialize() - assert.equal(pi.providers.length, 1) - assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) - - const pending = loads[1] - assert.ok(!(pending instanceof Promise)) - const firstRefresh = runtime.refresh() - const secondRefresh = runtime.refresh() - assert.strictEqual(firstRefresh, secondRefresh) - assert.equal(runtime.getStatus().refreshing, true) - - pending.reject(new Error("request failed with apiKey=user_secret_value")) - const result = await firstRefresh - - assert.equal(result.refreshed, false) - assert.equal(result.modelCount, 1) - assert.equal(runtime.getStatus().modelCount, 1) - assert.equal(runtime.getStatus().source, "live") - assert.equal(pi.providers.length, 1) - assert.equal(runtime.getStatus().refreshing, false) - assert.match(runtime.getStatus().warning ?? "", /Could not refresh/) - assert.doesNotMatch(runtime.getStatus().warning ?? "", /user_secret_value/) - assert.doesNotMatch(warnings.join("\n"), /user_secret_value/) - }) - - it("runs the refresh command and reports the updated catalog", async () => { - const pi = new ExtensionAPITestDouble() - const context = new CommandContext() - const results = [ - Promise.resolve(loaded([FIRST_MODEL])), - Promise.resolve(loaded([FIRST_MODEL, SECOND_MODEL])), - ] - let index = 0 - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models", - cachePath: "/tmp/commandcode-models.json", - loadModels: () => { - const result = results[index] - index += 1 - if (!result) throw new Error("unexpected refresh") - return result - }, - loadCachedModels: async () => [], - createProviderConfig: (models) => ({ models }), - logWarning: () => {}, - }) - - await runtime.initialize() - const refreshCommand = pi.commands.get("commandcode-refresh") - assert.ok(refreshCommand) - await refreshCommand("", context) - - assert.equal(context.waitForIdleCalls, 1) - assert.equal(context.notifications.at(-1)?.type, "info") - assert.match(context.notifications.at(-1)?.message ?? "", /2 models from live/) - assert.deepEqual(pi.providers.at(-1)?.models, [FIRST_MODEL, SECOND_MODEL]) - }) - - it("registers the cached catalog immediately and refreshes it in the background", async () => { - const pi = new ExtensionAPITestDouble() - const liveLoad = deferred() - let now = 1_700_000_000_000 - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models", - cachePath: "/tmp/commandcode-models.json", - loadModels: () => liveLoad.promise, - loadCachedModels: async () => [FIRST_MODEL], - createProviderConfig: (models) => ({ models }), - now: () => now, - logWarning: () => {}, - }) - - await runtime.initialize() - assert.equal(pi.providers.length, 1) - assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) - assert.equal(runtime.getStatus().source, "cache") - assert.equal(runtime.getStatus().modelCount, 1) - assert.equal(runtime.getStatus().refreshing, true) - - now += 1_000 - liveLoad.resolve(loaded([FIRST_MODEL, SECOND_MODEL])) - await runtime.refresh() - assert.equal(pi.providers.length, 2) - assert.deepEqual(pi.providers[1]?.models, [FIRST_MODEL, SECOND_MODEL]) - assert.equal(runtime.getStatus().source, "live") - assert.equal(runtime.getStatus().modelCount, 2) - assert.equal(runtime.getStatus().refreshing, false) - }) - - it("keeps the cached catalog when the background refresh fails", async () => { - const pi = new ExtensionAPITestDouble() - const warnings: string[] = [] - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models", - cachePath: "/tmp/commandcode-models.json", - loadModels: async () => { - throw new Error("offline") - }, - loadCachedModels: async () => [FIRST_MODEL], - createProviderConfig: (models) => ({ models }), - logWarning: (message) => warnings.push(message), - }) - - await runtime.initialize() - await runtime.refresh() - assert.equal(pi.providers.length, 1) - assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) - assert.equal(runtime.getStatus().source, "cache") - assert.equal(runtime.getStatus().modelCount, 1) - assert.match(runtime.getStatus().warning ?? "", /offline/) - assert.equal(warnings.length, 1) - }) - - it("aborts the background refresh on dispose without reporting a warning", async () => { - const pi = new ExtensionAPITestDouble() - const warnings: string[] = [] - let refreshSignal: AbortSignal | undefined - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models", - cachePath: "/tmp/commandcode-models.json", - loadModels: (signal) => - new Promise((_resolve, reject) => { - refreshSignal = signal - signal.addEventListener("abort", () => reject(signal.reason), { once: true }) - }), - loadCachedModels: async () => [FIRST_MODEL], - createProviderConfig: (models) => ({ models }), - logWarning: (message) => warnings.push(message), - }) - - await runtime.initialize() - const pending = runtime.refresh() - assert.equal(refreshSignal?.aborted, false) - - runtime.dispose() - const result = await pending - assert.equal(refreshSignal?.aborted, true) - assert.equal(result.refreshed, false) - assert.equal(runtime.getStatus().refreshing, false) - assert.equal(runtime.getStatus().warning, undefined) - assert.deepEqual(warnings, []) - assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) - }) - - it("awaits the live catalog when no cache exists", async () => { - const pi = new ExtensionAPITestDouble() - const liveLoad = deferred() - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models", - cachePath: "/tmp/commandcode-models.json", - loadModels: () => liveLoad.promise, - loadCachedModels: async () => [], - createProviderConfig: (models) => ({ models }), - logWarning: () => {}, - }) - - let initialized = false - const initialization = runtime.initialize().then(() => { - initialized = true - }) - await Promise.resolve() - assert.equal(pi.providers.length, 0) - assert.equal(initialized, false) - - liveLoad.resolve(loaded([FIRST_MODEL])) - await initialization - assert.equal(initialized, true) - assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) - assert.equal(runtime.getStatus().source, "live") - }) - - it("installs a cached catalog after an initially empty start", async () => { - const pi = new ExtensionAPITestDouble() - const results = [ - Promise.resolve(loaded([], "empty", "offline")), - Promise.resolve(loaded([SECOND_MODEL], "cache")), - ] - let index = 0 - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models", - cachePath: "/tmp/commandcode-models.json", - loadModels: () => { - const result = results[index] - index += 1 - if (!result) throw new Error("unexpected refresh") - return result - }, - loadCachedModels: async () => [], - createProviderConfig: (models) => ({ models }), - logWarning: () => {}, - }) - - await runtime.initialize() - assert.equal(pi.providers.length, 1) - assert.deepEqual(pi.providers[0]?.models, []) - - const result = await runtime.refresh() - assert.equal(result.refreshed, true) - assert.equal(result.source, "cache") - assert.deepEqual(pi.providers.at(-1)?.models, [SECOND_MODEL]) - assert.equal(runtime.getStatus().modelCount, 1) - }) - - it("does not replace an existing provider with an empty failed catalog", async () => { - const pi = new ExtensionAPITestDouble() - const results = [ - Promise.resolve(loaded([FIRST_MODEL])), - Promise.resolve(loaded([], "cache", "No valid catalog is available at /private/cache")), - Promise.resolve(loaded([SECOND_MODEL])), - ] - let index = 0 - - const runtime = createCommandCodeRuntime(pi, { - endpoint: "http://127.0.0.1:1234/provider/v1/models", - cachePath: "/private/cache", - loadModels: () => { - const result = results[index] - index += 1 - if (!result) throw new Error("unexpected refresh") - return result - }, - loadCachedModels: async () => [], - createProviderConfig: (models) => ({ models }), - logWarning: () => {}, - }) - - await runtime.initialize() - const refreshResult = await runtime.refresh() - assert.equal(refreshResult.refreshed, false) - assert.equal(pi.providers.length, 1) - assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) - assert.equal(runtime.getStatus().modelCount, 1) - assert.equal(runtime.getStatus().source, "live") - - await runtime.refresh() - assert.equal(pi.providers.length, 2) - assert.deepEqual(pi.providers[1]?.models, [SECOND_MODEL]) - }) - - it("reports a failed initial refresh without leaking diagnostics", async () => { - const pi = new ExtensionAPITestDouble() - const context = new CommandContext() - const runtime = createCommandCodeRuntime(pi, { - endpoint: "https://api.commandcode.ai/provider/v1/models?api_key=user_initial_secret", - cachePath: "/tmp/commandcode-models.json", - loadModels: async () => { - throw new Error("offline; api_key=user_initial_secret") - }, - loadCachedModels: async () => [], - createProviderConfig: (models) => ({ models }), - logWarning: () => {}, - }) - - await runtime.initialize() - const statusCommand = pi.commands.get("commandcode-status") - assert.ok(statusCommand) - await statusCommand("", context) - const message = context.notifications.at(-1)?.message ?? "" - assert.match(message, /source: empty/) - assert.match(message, /model count: 0/) - assert.match(message, /warning:/) - assert.doesNotMatch(message, /user_initial_secret/) - }) -}) diff --git a/tests/test-smoke.mjs b/tests/test-smoke.mjs deleted file mode 100644 index 483c00a..0000000 --- a/tests/test-smoke.mjs +++ /dev/null @@ -1,412 +0,0 @@ -/** - * Integration smoke test for pi-commandcode-provider. - * - * Tests that the extension: - * 1. Loads without crashing in print mode - * 2. Registers the provider and models - * 3. Can complete a simple prompt (requires Command Code auth) - * - * Run with: node tests/test-smoke.mjs - * Requires: pi on PATH plus COMMAND_CODE_API_KEY (or legacy COMMANDCODE_API_KEY) or live pi auth files. - */ - -import { spawn } from "node:child_process" -import { accessSync, constants, existsSync } from "node:fs" -import { homedir } from "node:os" -import { delimiter, resolve, dirname, join } 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" - -function findPiBinary() { - if (process.env.PI_BIN) return process.env.PI_BIN - const localBin = resolve(PROJECT_DIR, "node_modules", ".bin") - const candidates = (process.env.PATH ?? "") - .split(delimiter) - .map((entry) => resolve(entry, "pi")) - .filter((candidate) => !candidate.startsWith(localBin)) - for (const candidate of candidates) { - try { - accessSync(candidate, constants.X_OK) - return candidate - } catch { - // Try next PATH entry. - } - } - return undefined -} - -const PI_BIN = findPiBinary() -const HAS_PI = !!PI_BIN - -const PRINT_MODE_TIMEOUT = 120_000 // 2 minutes for print mode -const RPC_START_TIMEOUT = 15_000 -const RPC_QUERY_TIMEOUT = 60_000 - -function hasCommandCodeAuth() { - return ( - !!process.env.COMMAND_CODE_API_KEY || - !!process.env.COMMANDCODE_API_KEY || - existsSync(join(homedir(), ".commandcode", "auth.json")) || - existsSync(join(homedir(), ".pi", "agent", "auth.json")) - ) -} - -const HAS_AUTH = hasCommandCodeAuth() - -let passed = 0 -let failed = 0 -let skipped = 0 - -// ------------------------------------------------------------------------- -// Helpers -// ------------------------------------------------------------------------- - -function kill(child) { - try { - child.kill() - } catch { - // ignore - } -} - -// ------------------------------------------------------------------------- -// Test 1: Print mode — extension loads and agent runs -// ------------------------------------------------------------------------- - -async function runPrintMode() { - if (!HAS_AUTH) { - console.log("[smoke] SKIP — Command Code auth not found, skipping print mode test\n") - skipped++ - return - } - if (!HAS_PI) { - console.log("[smoke] SKIP — pi is not on PATH, skipping print mode test\n") - skipped++ - return - } - - console.log(`[smoke] Running pi in print mode with extension: ${EXT_PATH}`) - console.log( - `[smoke] ${PI_BIN} -e ${EXT_PATH} -p "say hi" --provider commandcode --model ${TEST_MODEL}\n`, - ) - - const child = spawn( - PI_BIN, - [ - "-e", - EXT_PATH, - "-p", - "say hi in one word", - "--provider", - "commandcode", - "--model", - TEST_MODEL, - ], - { - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - }, - ) - - let stdout = "" - let stderr = "" - - child.stdout.on("data", (d) => { - stdout += d.toString() - }) - child.stderr.on("data", (d) => { - stderr += d.toString() - }) - - const done = new Promise((resolve) => { - const timer = setTimeout(() => { - kill(child) - console.log("[smoke] TIMEOUT — pi print mode took too long") - resolve(false) - }, PRINT_MODE_TIMEOUT) - - child.on("close", (code) => { - clearTimeout(timer) - if (code === 0) { - console.log("[smoke] PASS — extension loaded and agent ran without crash") - console.log(`[smoke] stdout (last 300 chars): ${stdout.slice(-300).trim()}`) - } else { - console.log(`[smoke] FAIL — exit code ${code}`) - console.log(`[smoke] stderr (last 500 chars): ${stderr.slice(-500).trim()}`) - } - resolve(code === 0) - }) - }) - - const ok = await done - if (ok) passed++ - else failed++ -} - -// ------------------------------------------------------------------------- -// Test 2: Print mode — provider discovery (list models) -// ------------------------------------------------------------------------- - -async function runListModels() { - if (!HAS_AUTH) { - console.log("[smoke] SKIP — Command Code auth not found, skipping model list test\n") - skipped++ - return - } - if (!HAS_PI) { - console.log("[smoke] SKIP — pi is not on PATH, skipping model list test\n") - skipped++ - return - } - - console.log(`[smoke] Checking that models are discoverable via pi --list-models\n`) - - const child = spawn(PI_BIN, ["-e", EXT_PATH, "--list-models"], { - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - }) - - let stdout = "" - - child.stdout.on("data", (d) => { - stdout += d.toString() - }) - - const done = new Promise((resolve) => { - const timer = setTimeout(() => { - kill(child) - console.log("[smoke] TIMEOUT — model listing took too long") - resolve(false) - }, 15_000) - - child.on("close", (code) => { - clearTimeout(timer) - if (code === 0 && stdout.includes("commandcode")) { - console.log("[smoke] PASS — commandcode provider models are listed") - } else { - console.log("[smoke] FAIL — commandcode models not found or error listing") - console.log(`[smoke] stdout (last 500 chars): ${stdout.slice(-500).trim()}`) - } - resolve(code === 0 && stdout.includes("commandcode")) - }) - }) - - const ok = await done - if (ok) passed++ - else failed++ -} - -// ------------------------------------------------------------------------- -// Test 3: RPC mode — extension loads and answers get_state -// ------------------------------------------------------------------------- - -async function runRpcStartup() { - if (!HAS_AUTH) { - console.log("[smoke] SKIP — Command Code auth not found, skipping RPC startup test\n") - skipped++ - return - } - if (!HAS_PI) { - console.log("[smoke] SKIP — pi is not on PATH, skipping RPC startup test\n") - skipped++ - return - } - - console.log(`[smoke] Testing RPC mode startup with extension\n`) - console.log(`[smoke] ${PI_BIN} --mode rpc -e ${EXT_PATH}\n`) - - const child = spawn(PI_BIN, ["--mode", "rpc", "-e", EXT_PATH], { - env: { ...process.env }, - stdio: ["pipe", "pipe", "pipe"], - }) - - let sawStateResponse = false - let sawError = false - const events = [] - - let buf = "" - child.stdout.on("data", (chunk) => { - buf += chunk.toString("utf-8") - const lines = buf.split("\n") - buf = lines.pop() ?? "" - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - try { - const msg = JSON.parse(trimmed) - events.push(msg) - if ( - msg.type === "response" && - msg.id === "state-1" && - msg.command === "get_state" && - msg.success === true - ) { - sawStateResponse = true - console.log("[smoke] RPC received get_state response") - } - if (msg.type === "error" || msg.type === "fatal") { - sawError = true - console.error(`[smoke] RPC error: ${JSON.stringify(msg).slice(0, 300)}`) - } - } catch { - // ignore non-JSON - } - } - }) - - const result = new Promise((resolve) => { - child.stdin.write(JSON.stringify({ id: "state-1", type: "get_state" }) + "\n") - - const timer = setTimeout(async () => { - if (sawStateResponse) { - console.log("[smoke] PASS — extension loaded and RPC get_state works") - resolve(true) - } else { - console.log("[smoke] FAIL — get_state response not received") - resolve(false) - } - // Send quit - try { - child.stdin.write(JSON.stringify({ type: "quit" }) + "\n") - } catch {} - kill(child) - }, RPC_START_TIMEOUT) - - child.on("close", (code) => { - clearTimeout(timer) - if (!sawStateResponse && !sawError) { - console.log(`[smoke] FAIL — pi exited with code ${code} before get_state response`) - resolve(false) - } - }) - }) - - const ok = await result - if (ok) passed++ - else failed++ -} - -// ------------------------------------------------------------------------- -// Test 4: RPC mode — send prompt and receive assistant message -// ------------------------------------------------------------------------- - -async function runRpcQuery() { - if (!HAS_AUTH) { - console.log("[smoke] SKIP — Command Code auth not found, skipping RPC prompt test\n") - skipped++ - return - } - if (!HAS_PI) { - console.log("[smoke] SKIP — pi is not on PATH, skipping RPC prompt test\n") - skipped++ - return - } - - console.log(`[smoke] Testing RPC prompt flow\n`) - console.log(`[smoke] pi --mode rpc -e ${EXT_PATH} → prompt "say hi" → expect response\n`) - - const child = spawn( - PI_BIN, - ["--mode", "rpc", "-e", EXT_PATH, "--provider", "commandcode", "--model", TEST_MODEL], - { - env: { ...process.env }, - stdio: ["pipe", "pipe", "pipe"], - }, - ) - - let sawPromptAccepted = false - let sawAssistantMessage = false - - let buf = "" - child.stdout.on("data", (chunk) => { - buf += chunk.toString("utf-8") - const lines = buf.split("\n") - buf = lines.pop() ?? "" - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - try { - const msg = JSON.parse(trimmed) - if ( - msg.type === "response" && - msg.id === "prompt-1" && - msg.command === "prompt" && - msg.success === true - ) { - sawPromptAccepted = true - } - if (msg.type === "message_end" && msg.message?.role === "assistant") { - sawAssistantMessage = true - console.log("[smoke] PASS — received assistant message_end in RPC mode") - } - } catch { - // ignore - } - } - }) - - const result = new Promise((resolve) => { - child.stdin.write( - JSON.stringify({ - id: "prompt-1", - type: "prompt", - message: "say hi in one word", - }) + "\n", - ) - console.log("[smoke] Sent RPC prompt") - - const timer = setTimeout(() => { - if (sawPromptAccepted && sawAssistantMessage) { - console.log("[smoke] PASS — full RPC prompt/response cycle works") - resolve(true) - } else { - console.log("[smoke] WARN — no assistant message_end received (may still be streaming)") - resolve(false) - } - try { - child.stdin.write(JSON.stringify({ type: "quit" }) + "\n") - } catch {} - kill(child) - }, RPC_QUERY_TIMEOUT) - - child.on("close", (code) => { - clearTimeout(timer) - if (!sawAssistantMessage) { - console.log(`[smoke] FAIL — pi exited before assistant message_end`) - resolve(false) - } - }) - }) - - const ok = await result - if (ok) passed++ - else failed++ -} - -// ------------------------------------------------------------------------- -// Main -// ------------------------------------------------------------------------- - -console.log("=".repeat(60)) -console.log(" pi-commandcode-provider Integration Smoke Test") -console.log("=".repeat(60)) -console.log(` Auth: ${HAS_AUTH ? "✓ found" : "✗ not found (tests will be skipped)"}`) -console.log(` Extension: ${EXT_PATH}`) -console.log("=".repeat(60)) -console.log("") - -await runPrintMode() -await runListModels() -await runRpcStartup() -await runRpcQuery() - -console.log("") -console.log("=".repeat(60)) -console.log(` SUITE RESULT: ${passed} passed, ${failed} failed, ${skipped} skipped`) -console.log("=".repeat(60)) - -process.exit(failed > 0 ? 1 : 0) diff --git a/tests/test-stream.ts b/tests/test-stream.ts deleted file mode 100644 index 61f4b13..0000000 --- a/tests/test-stream.ts +++ /dev/null @@ -1,1147 +0,0 @@ -/** - * Integration tests for the real streamCommandCode core using a local mock - * Command Code server. No real API key or pi runtime required. - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import { COMMAND_CODE_CLI_VERSION } from "../src/commandcode-catalog.ts" -import type { AssistantMessageEvent } from "../src/core.ts" -import { MODEL_EFFORTS, thinkingLevelMapForEfforts } from "../src/models.ts" -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - objectAt, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -function eventTypes(events: readonly AssistantMessageEvent[]): string[] { - return events.map((event) => event.type) -} - -describe("streamCommandCode — auth", () => { - it("emits a missing-key error without touching the network", async () => { - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: {}, - authPaths: [], - }) - const stream = streamCommandCode(makeModel(), makeContext(), { - apiKey: "", - }) - const events = await collectEvents(stream) - - assert.deepEqual(eventTypes(events), ["error"]) - assert.equal(events[0].type, "error") - assert.equal(events[0].reason, "error") - assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/) - assert.equal(server.requestCount(), 0) - }) - - it("ignores the literal env-var name and falls back to env", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: { COMMANDCODE_API_KEY: "env-key" }, - }) - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "COMMANDCODE_API_KEY" }), - ) - - assert.equal( - server.lastRequestHeaders().authorization, - "Bearer env-key", - "should resolve from env, not send the literal var name as the token", - ) - }) - - it("accepts the official CLI API key environment variable", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: { COMMAND_CODE_API_KEY: "official-env-key" }, - }) - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "$COMMAND_CODE_API_KEY" }), - ) - - assert.equal(server.lastRequestHeaders().authorization, "Bearer official-env-key") - }) - - it("uses options.apiKey in the Authorization header", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: { COMMANDCODE_API_KEY: "env-key" }, - }) - - await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "option-key" })) - - assert.equal(server.lastRequestHeaders().authorization, "Bearer option-key") - }) - - it("treats a blank options.apiKey like a missing one", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: { COMMAND_CODE_API_KEY: "env-key" }, - }) - - await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: " " })) - - assert.equal(server.lastRequestHeaders().authorization, "Bearer env-key") - }) -}) - -describe("streamCommandCode — successful streams", () => { - it("emits start → text events → done and accumulates usage", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "Hel" }), - JSON.stringify({ type: "text-delta", text: "lo" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 3124, - outputTokens: 15, - inputTokenDetails: { noCacheTokens: 52, cacheReadTokens: 3072 }, - }, - }), - ], - }) - const { streamCommandCode, calculatedUsages } = createTestDeps({ - apiBase: server.baseUrl(), - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "text_start", - "text_delta", - "text_delta", - "text_end", - "done", - ]) - const done = events.at(-1) - assert.equal(done?.type, "done") - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "stop") - assert.equal(done.message.content[0]?.type, "text") - assert.equal( - done.message.content[0]?.type === "text" ? done.message.content[0].text : "", - "Hello", - ) - assert.equal(done.message.usage.input, 52) - assert.equal(done.message.usage.cacheRead, 3072) - assert.equal(done.message.usage.cacheWrite, 0) - assert.equal(done.message.usage.totalTokens, 3139) - assert.equal(calculatedUsages.length, 1) - }) - - it("sends images for vision-capable models", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode( - makeModel({ id: "gpt-5.6-luna" }), - makeContext({ - messages: [ - { - role: "user", - content: [ - { type: "text", text: "inspect" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, - ], - }, - ], - }), - { apiKey: "mock-key" }, - ), - ) - - assert.equal( - objectAt(server.lastRequestBody(), ["params", "messages", "0", "content", "1", "image"]), - "data:image/png;base64,aGVsbG8=", - ) - }) - - it("forwards a tool-result image as a following user image for vision-capable models", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode( - makeModel({ id: "deepseek/deepseek-v4-flash-vision-exp" }), - makeContext({ - messages: [ - { role: "user", content: "read the image" }, - { - role: "assistant", - content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - content: [ - { type: "text", text: "image attached" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, - ], - }, - ], - }), - { apiKey: "mock-key" }, - ), - ) - - // No error: the tool-result image must not be rejected for this model. - assert.equal(events.at(-1)?.type, "done") - - const body = server.lastRequestBody() - // The tool-result text is forwarded on the tool message at index 2. - assert.equal( - objectAt(body, ["params", "messages", "2", "content", "0", "output", "value"]), - "image attached", - ) - // The tool-result image is forwarded as a following user image message at index 3. - assert.equal(objectAt(body, ["params", "messages", "3", "role"]), "user") - assert.equal( - objectAt(body, ["params", "messages", "3", "content", "0", "image"]), - "data:image/png;base64,aGVsbG8=", - ) - }) - - it("omits a historical tool-result image after switching to a text-only model", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode( - makeModel({ id: "deepseek/deepseek-v4-flash" }), - makeContext({ - messages: [ - { role: "user", content: "read the image" }, - { - role: "assistant", - content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - content: [ - { type: "text", text: "image attached" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, - ], - }, - { role: "user", content: "continue without the image" }, - ], - }), - { apiKey: "mock-key" }, - ), - ) - - assert.equal(events.at(-1)?.type, "done") - assert.equal(server.requestCount(), 1) - const body = server.lastRequestBody() - assert.equal( - objectAt(body, ["params", "messages", "2", "content", "0", "output", "value"]), - "image attached", - ) - assert.equal( - objectAt(body, ["params", "messages", "3", "content"]), - "continue without the image", - ) - }) - - it("rejects images before network access for text-only models", async () => { - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode( - makeModel({ id: "deepseek/deepseek-v4-pro" }), - makeContext({ - messages: [ - { - role: "user", - content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], - }, - ], - }), - { apiKey: "mock-key" }, - ), - ) - - assert.equal(events.at(-1)?.type, "error") - assert.equal(server.requestCount(), 0) - }) - - it("derives uncached input when noCacheTokens is missing", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 100, - outputTokens: 10, - inputTokenDetails: { cacheReadTokens: 75 }, - }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - assert.equal(done?.type, "done") - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.message.usage.input, 25) - assert.equal(done.message.usage.cacheRead, 75) - assert.equal(done.message.usage.cacheWrite, 0) - assert.equal(done.message.usage.totalTokens, 110) - }) - - it("accounts for cache writes separately from uncached input", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 100, - outputTokens: 10, - inputTokenDetails: { - noCacheTokens: 20, - cacheReadTokens: 70, - cacheWriteTokens: 10, - }, - }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - assert.equal(done?.type, "done") - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.message.usage.input, 20) - assert.equal(done.message.usage.cacheRead, 70) - assert.equal(done.message.usage.cacheWrite, 10) - assert.equal(done.message.usage.totalTokens, 110) - }) - - it("ends on finish without waiting for an open upstream connection", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "done" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - hangAfterLast: true, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - 500, - ) - - assert.equal(events.at(-1)?.type, "done") - await new Promise((resolve) => setTimeout(resolve, 50)) - assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body") - }) - - it("emits reasoning and tool-call blocks in order", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "think" }), - JSON.stringify({ type: "reasoning-end" }), - JSON.stringify({ type: "text-delta", text: "Using tool" }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: JSON.stringify({ path: "/tmp/x" }), - }), - JSON.stringify({ type: "finish", finishReason: "tool-calls" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "text_start", - "text_delta", - "text_end", - "toolcall_start", - "toolcall_end", - "done", - ]) - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "toolUse") - assert.deepEqual( - done.message.content.map((content) => content.type), - ["thinking", "text", "toolCall"], - ) - const toolCall = done.message.content[2] - assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file") - }) - - it("streams incremental tool-call arguments from generate events", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "tool-input-start", - id: "call_1", - toolName: "read_file", - }), - JSON.stringify({ type: "tool-input-delta", id: "call_1", delta: '{"path":"' }), - JSON.stringify({ type: "tool-input-delta", id: "call_1", delta: '/tmp/x"}' }), - JSON.stringify({ type: "tool-input-end", id: "call_1" }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: { path: "/tmp/x" }, - }), - JSON.stringify({ type: "finish", finishReason: "tool-calls" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "toolcall_start", - "toolcall_delta", - "toolcall_delta", - "toolcall_end", - "done", - ]) - const deltas = events.flatMap((event) => (event.type === "toolcall_delta" ? [event.delta] : [])) - assert.deepEqual(deltas, ['{"path":"', '/tmp/x"}']) - - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "toolUse") - const toolCall = done.message.content[0] - assert.equal(toolCall?.type, "toolCall") - if (toolCall?.type !== "toolCall") throw new Error("expected tool call") - assert.equal(toolCall.id, "call_1") - assert.equal(toolCall.name, "read_file") - assert.deepEqual(toolCall.arguments, { path: "/tmp/x" }) - }) - - it("keeps concurrent incremental tool calls separate", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "tool-input-start", id: "call_1", toolName: "read_file" }), - JSON.stringify({ type: "tool-input-start", id: "call_2", toolName: "read_file" }), - JSON.stringify({ type: "tool-input-delta", id: "call_1", delta: '{"path":"/a"}' }), - JSON.stringify({ type: "tool-input-delta", id: "call_2", delta: '{"path":"/b"}' }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_2", - toolName: "read_file", - input: { path: "/b" }, - }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: { path: "/a" }, - }), - JSON.stringify({ type: "finish", finishReason: "tool-calls" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const starts = events.flatMap((event) => - event.type === "toolcall_start" ? [event.contentIndex] : [], - ) - const deltas = events.flatMap((event) => - event.type === "toolcall_delta" ? [[event.contentIndex, event.delta] as const] : [], - ) - const ends = events.flatMap((event) => - event.type === "toolcall_end" ? [[event.contentIndex, event.toolCall.id] as const] : [], - ) - assert.deepEqual(starts, [0, 1]) - assert.deepEqual(deltas, [ - [0, '{"path":"/a"}'], - [1, '{"path":"/b"}'], - ]) - assert.deepEqual(ends, [ - [1, "call_2"], - [0, "call_1"], - ]) - }) - - it("flushes reasoning if finish arrives without reasoning-end", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-delta", text: "unfinished thought" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.message.content[0]?.type, "thinking") - }) - - it("closes thinking block before text when reasoning-end is missing", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "thinking" }), - JSON.stringify({ type: "text-delta", text: "answer" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "text_start", - "text_delta", - "text_end", - "done", - ]) - }) - - it("closes thinking block before tool-call when reasoning-end is missing", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "thinking" }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: JSON.stringify({ path: "/tmp/x" }), - }), - JSON.stringify({ type: "finish", finishReason: "tool-calls" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "toolcall_start", - "toolcall_end", - "done", - ]) - }) -}) - -describe("streamCommandCode — request serialization", () => { - it("rejects image input before sending a lossy request", async () => { - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const events = await collectEvents( - streamCommandCode( - makeModel(), - makeContext({ - messages: [ - { - role: "user", - content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], - }, - ], - }), - { apiKey: "mock-key" }, - ), - ) - - assert.deepEqual(eventTypes(events), ["start", "error"]) - const lastEvent = events.at(-1) - assert.equal(lastEvent?.type, "error") - if (lastEvent?.type === "error") { - assert.match(lastEvent.error.errorMessage ?? "", /does not support image content/i) - } - assert.equal(server.requestCount(), 0) - }) - it("sends the expected request body and default headers", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const context = makeContext({ - messages: [ - { role: "user", content: "first" }, - { - role: "assistant", - content: [{ type: "text", text: "first response" }], - }, - { role: "user", content: "second" }, - ], - tools: [ - { - name: "get_weather", - description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - ], - }) - - await collectEvents( - streamCommandCode(makeModel(), context, { - apiKey: "mock-key", - maxTokens: 500, - }), - ) - - const body = server.lastRequestBody() - assert.equal(objectAt(body, ["config", "workingDir"]), "/repo") - assert.equal(objectAt(body, ["config", "date"]), "2026-05-05") - assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash") - assert.equal(objectAt(body, ["params", "stream"]), true) - assert.equal(objectAt(body, ["params", "max_tokens"]), 500) - assert.equal(objectAt(body, ["params", "reasoning_effort"]), undefined) - assert.equal(objectAt(body, ["params", "temperature"]), undefined) - assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.") - assert.equal(objectAt(body, ["memory"]), null) - assert.equal(objectAt(body, ["taste"]), null) - assert.equal(objectAt(body, ["skills"]), null) - assert.equal(objectAt(body, ["permissionMode"]), undefined) - assert.equal(objectAt(body, ["threadId"]), "00000000-0000-4000-8000-000000000000") - assert.equal( - objectAt(body, ["params", "messages", "1", "content", "0", "text"]), - "first response", - ) - assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "get_weather") - - const headers = server.lastRequestHeaders() - assert.equal(headers.authorization, "Bearer mock-key") - assert.equal(headers["x-command-code-version"], COMMAND_CODE_CLI_VERSION) - assert.equal(headers["x-project-slug"], "repo") - assert.equal(headers["x-taste-learning"], "true") - assert.equal(headers["user-agent"], "cli") - assert.equal(headers["x-co-flag"], undefined) - assert.equal(headers["x-session-id"], undefined) - }) - - it("sends developer advisories as user messages in position, without system hoisting", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const advisory = - '\nStop and correct the benchmark.\n' - const context = makeContext({ - messages: [ - { role: "user", content: "run the benchmark" }, - { - role: "assistant", - content: [ - { type: "text", text: "running it" }, - { type: "toolCall", id: "c1", name: "bash", arguments: { command: "bench" } }, - ], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "bash", - content: [{ type: "text", text: "benchmark output" }], - }, - { role: "developer", content: advisory }, - { role: "user", content: "continue" }, - ], - }) - - await collectEvents(streamCommandCode(makeModel(), context, { apiKey: "mock-key" })) - - const body = server.lastRequestBody() - assert.deepEqual( - objectAt(body, ["params", "messages", "3"]), - { role: "user", content: advisory }, - "developer advisory should arrive as an in-position user message with identical content", - ) - assert.deepEqual(objectAt(body, ["params", "messages", "4"]), { - role: "user", - content: "continue", - }) - assert.equal(objectAt(body, ["params", "messages", "5"]), undefined) - assert.equal( - objectAt(body, ["params", "system"]), - "You are a test assistant.", - "advisory must not be hoisted into the system prompt", - ) - assert.doesNotMatch(String(objectAt(body, ["params", "system"])), /advisory/) - }) - - it("forwards explicit temperature and stable session metadata", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - temperature: 0.7, - sessionId: "11111111-1111-4111-8111-111111111111", - }), - ) - - const body = server.lastRequestBody() - assert.equal(objectAt(body, ["params", "temperature"]), 0.7) - assert.equal(objectAt(body, ["threadId"]), "11111111-1111-4111-8111-111111111111") - assert.equal( - server.lastRequestHeaders()["x-session-id"], - "11111111-1111-4111-8111-111111111111", - ) - }) - - it("omits non-UUID session ids from the generate thread id", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - sessionId: "human-readable-session", - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["threadId"]), undefined) - assert.equal(server.lastRequestHeaders()["x-session-id"], "human-readable-session") - }) - - it("accepts the legacy OMP nested reasoning map", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const model = makeModel({ - id: "omp-compat-reasoning-model", - reasoning: true, - thinking: { effortMap: { high: "legacy-high" } }, - }) - - await collectEvents( - streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "high" }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "legacy-high") - }) - - it("forwards a supported Pi reasoning level as reasoning_effort", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const model = makeModel({ - id: "deepseek/deepseek-v4-flash", - reasoning: true, - thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), - }) - - await collectEvents( - streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "max" }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "max") - }) - - it("omits reasoning_effort for off, unsupported, and unknown reasoning levels", async () => { - const model = makeModel({ - id: "deepseek/deepseek-v4-flash", - reasoning: true, - thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), - }) - - for (const reasoning of ["off", "low"] as const) { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning }), - ) - assert.equal( - objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), - undefined, - `${reasoning} should not be sent when it has no supported Command Code field`, - ) - server.reset() - } - - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - await collectEvents( - streamCommandCode( - makeModel({ id: "new-model-without-metadata", reasoning: false }), - makeContext(), - { apiKey: "mock-key", reasoning: "high" }, - ), - ) - assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), undefined) - }) - - it("caps maxTokens and passes custom headers", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), { - apiKey: "mock-key", - maxTokens: 500_000, - headers: { "x-custom": "value" }, - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 64_000) - assert.equal(server.lastRequestHeaders()["x-custom"], "value") - }) - - it("caps default maxTokens by the selected model", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(makeModel({ maxTokens: 8_192 }), makeContext(), { - apiKey: "mock-key", - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 8_192) - }) - - it("serializes OMP system prompt arrays as a string", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode( - makeModel(), - makeContext({ - systemPrompt: ["You are a test assistant.", "Use concise answers."] as unknown as string, - }), - { apiKey: "mock-key" }, - ), - ) - - assert.equal( - objectAt(server.lastRequestBody(), ["params", "system"]), - "You are a test assistant.\n\nUse concise answers.", - ) - }) - - it("times out a hung onResponse callback", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const started = Date.now() - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - timeoutMs: 25, - onResponse: async () => new Promise(() => {}), - }), - 1_000, - ) - - assert.ok(Date.now() - started < 500) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /timed out after 25ms/) - }) - - it("times out a hung onPayload callback", async () => { - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const started = Date.now() - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - timeoutMs: 25, - onPayload: async () => new Promise(() => {}), - }), - 1_000, - ) - - assert.ok(Date.now() - started < 500) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /timed out after 25ms/) - assert.equal(server.requestCount(), 0) - }) - - it("runs onPayload and onResponse hooks", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - let responseStatus = 0 - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - onPayload: () => ({ replaced: true }), - onResponse: (response) => { - responseStatus = response.status - }, - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true) - assert.equal(responseStatus, 200) - }) -}) - -describe("streamCommandCode — upstream errors and malformed streams", () => { - it("emits error for HTTP failures", async () => { - server.mockResponse({ type: "error", status: 429, body: "rate limited" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /429/) - }) - - it("emits error for provider error events", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: { message: "provider failed" }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.error.errorMessage, "provider failed") - }) - - it("rejects a truncated stream without a finish event", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "text-delta", text: "truncated" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /no finish event/i) - }) - - it("maps an upstream abort event to an aborted request", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "abort" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - }) - - it("rejects terminal upstream network failure reasons", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "finish", - finishReason: "stop", - rawFinishReason: "upstream_error", - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /upstream connection failed/i) - }) - - it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => { - const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n` - const finishEvent = JSON.stringify({ - type: "finish", - finishReason: "max_tokens", - }) - server.mockResponse({ - type: "success", - chunks: [ - "not json\n", - textEvent.slice(0, 12), - textEvent.slice(12), - "event: ignored\n", - "data: [DONE]\n", - finishEvent, - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "length") - assert.equal( - done.message.content[0]?.type === "text" ? done.message.content[0].text : "", - "split", - ) - }) -}) diff --git a/tests/test-transport.ts b/tests/test-transport.ts deleted file mode 100644 index 82e9771..0000000 --- a/tests/test-transport.ts +++ /dev/null @@ -1,247 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { createCommandCodeTransportRouter } from "../src/transport.ts" -import type { - AssistantMessageEvent, - AssistantMessageEventStreamLike, - StreamOptions, -} from "../src/types.ts" -import { collectEvents, createTestEventStream, makeContext, makeModel } from "./helpers.ts" - -function completedStream(text: string): AssistantMessageEventStreamLike { - const stream = createTestEventStream() - const model = makeModel() - const message = { - role: "assistant" as const, - content: [{ type: "text" as const, text }], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop" as const, - timestamp: Date.now(), - } - const events: AssistantMessageEvent[] = [ - { type: "start", partial: message }, - { type: "text_start", contentIndex: 0, partial: message }, - { type: "text_delta", contentIndex: 0, delta: text, partial: message }, - { type: "text_end", contentIndex: 0, content: text, partial: message }, - { type: "done", reason: "stop", message }, - ] - for (const event of events) stream.push(event) - stream.end() - return stream -} - -function providerStream( - response: Response, - text: string, - options?: StreamOptions, -): AssistantMessageEventStreamLike { - const stream = createTestEventStream() - const run = async () => { - const received = await (options?.fetch ?? fetch)("https://provider.test", {}) - await options?.onResponse?.( - { status: received.status, headers: {} }, - makeModel({ api: "openai-completions" }), - ) - const source = completedStream(text) - for await (const event of source) stream.push(event) - stream.end() - } - run().catch(() => stream.end()) - return stream -} - -describe("Command Code transport router", () => { - it("keeps using the Provider API after a successful request", async () => { - let providerCalls = 0 - let generateCalls = 0 - const router = createCommandCodeTransportRouter({ - createStream: createTestEventStream, - streamProvider: (_model, _context, options) => { - providerCalls += 1 - return providerStream(new Response("ok", { status: 200 }), "provider", options) - }, - streamGenerate: () => { - generateCalls += 1 - return completedStream("generate") - }, - }) - - const options: StreamOptions = { - fetch: () => Promise.resolve(new Response("ok", { status: 200 })), - } - const first = await collectEvents(router.stream(makeModel(), makeContext(), options)) - const second = await collectEvents(router.stream(makeModel(), makeContext(), options)) - - assert.equal(first.at(-1)?.type, "done") - assert.equal(second.at(-1)?.type, "done") - assert.equal(router.getTransport(), "provider") - assert.equal(providerCalls, 2) - assert.equal(generateCalls, 0) - }) - - it("falls back only for 403 upgrade_required and remembers generate", async () => { - let providerCalls = 0 - let generateCalls = 0 - const responseBody = JSON.stringify({ - error: { code: "upgrade_required", type: "permission_error" }, - }) - const router = createCommandCodeTransportRouter({ - createStream: createTestEventStream, - streamProvider: (_model, _context, options) => { - providerCalls += 1 - return providerStream(new Response(responseBody, { status: 403 }), "blocked", options) - }, - streamGenerate: () => { - generateCalls += 1 - return completedStream("generate") - }, - }) - const options: StreamOptions = { - fetch: () => Promise.resolve(new Response(responseBody, { status: 403 })), - } - - const first = await collectEvents(router.stream(makeModel(), makeContext(), options)) - const second = await collectEvents(router.stream(makeModel(), makeContext(), options)) - - assert.equal(first.at(-1)?.type, "done") - assert.equal(second.at(-1)?.type, "done") - assert.equal(router.getTransport(), "generate") - assert.equal(providerCalls, 1) - assert.equal(generateCalls, 2) - }) - - it("re-detects the transport after the API key changes", async () => { - let providerCalls = 0 - let generateCalls = 0 - const upgradeBody = JSON.stringify({ error: { code: "upgrade_required" } }) - const router = createCommandCodeTransportRouter({ - createStream: createTestEventStream, - streamProvider: (_model, _context, options) => { - providerCalls += 1 - const response = - options?.apiKey === "go-key" - ? new Response(upgradeBody, { status: 403 }) - : new Response("ok", { status: 200 }) - return providerStream(response, "provider", options) - }, - streamGenerate: () => { - generateCalls += 1 - return completedStream("generate") - }, - }) - - await collectEvents( - router.stream(makeModel(), makeContext(), { - apiKey: "go-key", - fetch: () => Promise.resolve(new Response(upgradeBody, { status: 403 })), - }), - ) - await collectEvents( - router.stream(makeModel(), makeContext(), { - apiKey: "provider-key", - fetch: () => Promise.resolve(new Response("ok", { status: 200 })), - }), - ) - - assert.equal(router.getTransport(), "provider") - assert.equal(providerCalls, 2) - assert.equal(generateCalls, 1) - }) - - it("does not let a stale request overwrite the transport for a new API key", async () => { - let releaseGoRequest: (() => void) | undefined - const goRequestGate = new Promise((resolve) => { - releaseGoRequest = resolve - }) - let providerCalls = 0 - let generateCalls = 0 - const upgradeBody = JSON.stringify({ error: { code: "upgrade_required" } }) - const router = createCommandCodeTransportRouter({ - createStream: createTestEventStream, - streamProvider: (_model, _context, options) => { - providerCalls += 1 - const response = - options?.apiKey === "go-key" - ? new Response(upgradeBody, { status: 403 }) - : new Response("ok", { status: 200 }) - const stream = createTestEventStream() - const run = async () => { - if (options?.apiKey === "go-key") await goRequestGate - const received = await (options?.fetch ?? fetch)("https://provider.test", {}) - await options?.onResponse?.( - { status: received.status, headers: {} }, - makeModel({ api: "openai-completions" }), - ) - if (response.ok) { - for await (const event of completedStream("provider")) stream.push(event) - } - stream.end() - } - run().catch(() => stream.end()) - return stream - }, - streamGenerate: () => { - generateCalls += 1 - return completedStream("generate") - }, - }) - - const staleGoRequest = collectEvents( - router.stream(makeModel(), makeContext(), { - apiKey: "go-key", - fetch: () => Promise.resolve(new Response(upgradeBody, { status: 403 })), - }), - ) - await collectEvents( - router.stream(makeModel(), makeContext(), { - apiKey: "provider-key", - fetch: () => Promise.resolve(new Response("ok", { status: 200 })), - }), - ) - releaseGoRequest?.() - await staleGoRequest - await collectEvents( - router.stream(makeModel(), makeContext(), { - apiKey: "provider-key", - fetch: () => Promise.resolve(new Response("ok", { status: 200 })), - }), - ) - - assert.equal(router.getTransport(), "provider") - assert.equal(providerCalls, 3) - assert.equal(generateCalls, 1) - }) - - it("does not fall back for other 403 errors", async () => { - let generateCalls = 0 - const responseBody = JSON.stringify({ error: { code: "permission_denied" } }) - const router = createCommandCodeTransportRouter({ - createStream: createTestEventStream, - streamProvider: (_model, _context, options) => - providerStream(new Response(responseBody, { status: 403 }), "blocked", options), - streamGenerate: () => { - generateCalls += 1 - return completedStream("generate") - }, - }) - const options: StreamOptions = { - fetch: () => Promise.resolve(new Response(responseBody, { status: 403 })), - } - - await collectEvents(router.stream(makeModel(), makeContext(), options)) - - assert.equal(router.getTransport(), "provider") - assert.equal(generateCalls, 0) - }) -}) diff --git a/tsconfig.json b/tsconfig.json index aac5ebe..fa0f9ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,18 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, "allowImportingTsExtensions": true, + "allowJs": true, "noEmit": true, "skipLibCheck": true, - "strict": true, - "types": ["node"] + "verbatimModuleSyntax": true }, - "include": [".github/scripts/**/*.ts", ".agents/skills/**/*.ts", "src/**/*.ts", "tests/**/*.ts"] + "include": ["index.ts", "src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"] }