diff --git a/.github/scripts/check-commandcode-model-metadata.ts b/.github/scripts/check-commandcode-model-metadata.ts index 292a4d3..b83c386 100644 --- a/.github/scripts/check-commandcode-model-metadata.ts +++ b/.github/scripts/check-commandcode-model-metadata.ts @@ -9,6 +9,8 @@ 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) @@ -21,7 +23,9 @@ 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 { @@ -30,7 +34,12 @@ export interface ModelMetadataDiff { removedImageModelIds: readonly string[] addedReasoningModelIds: readonly string[] removedReasoningModelIds: readonly string[] - changedReasoningModelIds: readonly string[] + addedEffortModelIds: readonly string[] + removedEffortModelIds: readonly string[] + changedEffortModelIds: readonly string[] + addedMaxOutputModelIds: readonly string[] + removedMaxOutputModelIds: readonly string[] + changedMaxOutputModelIds: readonly string[] } interface PackedPackage { @@ -123,27 +132,93 @@ export function parseKnownTextOnlyModelIds(bundle: string): readonly string[] { 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)), + ), } } @@ -155,10 +230,16 @@ export function diffModelMetadata( ): ModelMetadataDiff { const currentImages = new Set(current.imageModelIds) const upstreamImages = new Set(upstream.imageModelIds) - const currentReasoningIds = Object.keys(current.reasoningEfforts) - const upstreamReasoningIds = Object.keys(upstream.reasoningEfforts) - const currentReasoningSet = new Set(currentReasoningIds) - const upstreamReasoningSet = new Set(upstreamReasoningIds) + 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, @@ -169,19 +250,38 @@ export function diffModelMetadata( current.imageModelIds.filter((modelId) => !upstreamImages.has(modelId)), ), addedReasoningModelIds: sorted( - upstreamReasoningIds.filter((modelId) => !currentReasoningSet.has(modelId)), + upstream.reasoningModelIds.filter((modelId) => !currentReasoning.has(modelId)), ), removedReasoningModelIds: sorted( - currentReasoningIds.filter((modelId) => !upstreamReasoningSet.has(modelId)), + current.reasoningModelIds.filter((modelId) => !upstreamReasoning.has(modelId)), ), - changedReasoningModelIds: sorted( - upstreamReasoningIds.filter( + addedEffortModelIds: sorted( + upstreamEffortIds.filter((modelId) => !currentEffortSet.has(modelId)), + ), + removedEffortModelIds: sorted( + currentEffortIds.filter((modelId) => !upstreamEffortSet.has(modelId)), + ), + changedEffortModelIds: sorted( + upstreamEffortIds.filter( (modelId) => - currentReasoningSet.has(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], + ), + ), } } @@ -229,14 +329,24 @@ export function renderCommandCodeCatalog( const imageEntries = sorted(metadata.imageModelIds) .map((modelId) => ` ${quoted(modelId)}: ["text", "image"],`) .join("\n") - const reasoningEntries = recordEntries(metadata.reasoningEfforts) + 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_EFFORTS: Readonly> = {\n${reasoningEntries}\n}\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( @@ -279,16 +389,23 @@ function metadataReport( `- 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: ${Object.keys(current.reasoningEfforts).length} repository / ${Object.keys(upstream.reasoningEfforts).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 metadata | ${formatList(diff.addedReasoningModelIds)} |`, - `| Removed reasoning metadata | ${formatList(diff.removedReasoningModelIds)} |`, - `| Changed reasoning efforts | ${formatReasoningChanges(diff.changedReasoningModelIds, current, upstream)} |`, + `| 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") } diff --git a/.github/workflows/model-metadata.yml b/.github/workflows/model-metadata.yml index de5a26f..88c9ca5 100644 --- a/.github/workflows/model-metadata.yml +++ b/.github/workflows/model-metadata.yml @@ -79,7 +79,8 @@ jobs: This updates only machine-readable compatibility metadata: - CLI version used in the `x-command-code-version` header - image-input capabilities - - supported reasoning efforts + - 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. diff --git a/.semgrep/pi-extension-audit.yaml b/.semgrep/pi-extension-audit.yaml index b56c540..120a0ec 100644 --- a/.semgrep/pi-extension-audit.yaml +++ b/.semgrep/pi-extension-audit.yaml @@ -256,10 +256,10 @@ rules: - pattern: process.env.$VAR - metavariable-regex: metavariable: $VAR - regex: "(?!COMMANDCODE_|NODE_|PATH|HOME|SHELL|USER|LANG|LC_|TERM|TMPDIR|NIX_).*" + 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 COMMANDCODE_* variables. + read documented Command Code or standard runtime variables. severity: WARNING languages: [javascript, typescript] paths: @@ -270,4 +270,3 @@ rules: # ──────────────────────────────────────────────────────────────────────── # OAuth flow manipulation # ──────────────────────────────────────────────────────────────────────── - diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b71dd..4fc4d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,17 +3,23 @@ ## Unreleased - 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, and reasoning-effort changes in the latest published Command Code catalog. -- Refresh static model capabilities from `command-code@1.32.2`, including new image and reasoning metadata. +- 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 `COMMANDCODE_ZDR=1`. +- 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-plan and Provider-API credentials, including an explicit selected-transport assertion and packed-package validation. +- 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. ## 0.5.1 - 2026-08-11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6adb7bb..0a6b84f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,10 +46,11 @@ 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 both file variables to run them sequentially. Store the 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. The direct `COMMANDCODE_E2E_GO_API_KEY` and `COMMANDCODE_E2E_PROVIDER_API_KEY` variables are intended primarily for protected CI secrets. +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. Before opening a PR, run: diff --git a/README.md b/README.md index 83c8295..7256c32 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,6 @@ A custom provider for [pi](https://github.com/earendil-works/pi) that connects t > **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. -The extension uses one provider and automatically selects the transport supported by the authenticated account: - -- `GET /provider/v1/models` for model discovery -- `POST /provider/v1/chat/completions` for non-Claude models with Provider API access -- `POST /provider/v1/messages` for Claude models with Provider API access -- `/alpha/generate` after the Provider API explicitly returns `403 upgrade_required`, which currently identifies Go-plan accounts - -The detected transport is remembered only for the running process and is re-evaluated when the credential changes. Other authentication, permission, rate-limit, network, and server errors never trigger the fallback. - ## Install ```sh @@ -53,7 +44,7 @@ If automatic transfer from the browser fails, copy the API key shown by Command ### Environment variable ```sh -export COMMANDCODE_API_KEY="user_..." +export COMMAND_CODE_API_KEY="user_..." ``` ### Auth file @@ -93,7 +84,7 @@ Open `/model` and select one of the models provided by Command Code. Model avail ### Reasoning support -Reasoning metadata is enriched only for models whose Command Code effort support is known. Those models register a model-specific `thinkingLevelMap`, so pi and OMP expose only supported levels. 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. Unsupported levels and newly discovered models without metadata do not claim 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 also register a model-specific `thinkingLevelMap`, so pi and OMP expose only valid levels. 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: @@ -133,7 +124,7 @@ While pi is running, use these provider commands without restarting: 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 `COMMANDCODE_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header. +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: @@ -144,7 +135,7 @@ The following environment variables are intended for tests, local mocks, and com ## 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.32.2`; unknown models default to text-only until their upstream metadata is reviewed. A daily GitHub Actions job synchronizes the CLI version, image capabilities, and reasoning efforts with the latest published CLI package and opens or updates a reviewable pull request when they change. Pricing remains manually reviewed because the CLI catalog does not expose every pricing tier and temporary promotion used by the provider. +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.32.2`; 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. @@ -195,23 +186,26 @@ Both commands accept additional pi arguments after `--`, for example `npm run pi ### Live transport tests -Keep the Go-plan and Provider-API test keys in separate secret-manager entries. Pass them through protected files so the keys do not enter shell history: +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_PROVIDER_API_KEY_FILE=/path/to/provider-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 the selected transport through `/commandcode-status`: Go must select `generate`, while a Provider API account must select `provider`. The profile-specific `*_API_KEY` environment variables are also supported for CI secrets, but key files are preferred for local use. +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. -Override the default DeepSeek test model with `COMMANDCODE_E2E_GO_MODEL` or `COMMANDCODE_E2E_PROVIDER_MODEL`. A successful live Anthropic `/provider/v1/messages` test requires a Provider API account whose plan includes the selected Claude model. +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`. 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. diff --git a/index.ts b/index.ts index d3005d4..09222bd 100644 --- a/index.ts +++ b/index.ts @@ -26,6 +26,7 @@ import { getModelsTimeoutMs, inputModalitiesForModel, loadCommandCodeModels, + MODEL_EFFORTS, thinkingMetadataForModel, type CommandCodeModel, } from "./src/models.ts" @@ -37,7 +38,7 @@ import { createCommandCodeRuntime } from "./src/runtime.ts" import { createCommandCodeTransportRouter } from "./src/transport.ts" function commandCodeHeaders(): Record | undefined { - if (process.env.COMMANDCODE_ZDR === "1") { + if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") { return { "x-cmd-zdr": "1" } } return undefined @@ -52,7 +53,7 @@ function createProviderConfig( return { name: "Command Code", baseUrl: apiBase, - apiKey: getConfiguredApiKey() ?? "$COMMANDCODE_API_KEY", + apiKey: getConfiguredApiKey() ?? "$COMMAND_CODE_API_KEY", api: "commandcode-custom", streamSimple: streamCommandCode, headers, @@ -79,7 +80,7 @@ function createProviderConfig( ? { supportsStore: false, supportsDeveloperRole: false, - supportsReasoningEffort: true, + supportsReasoningEffort: MODEL_EFFORTS[model.id] !== undefined, maxTokensField: "max_tokens", } : { diff --git a/package.json b/package.json index 660b6c1..568cdb3 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,9 @@ "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 provider", + "test:e2e:live:all": "node scripts/live-e2e-profile.mjs go goat", "test:cost": "tsx tests/test-cost.ts" }, "pi": { diff --git a/scripts/live-e2e-profile.mjs b/scripts/live-e2e-profile.mjs index 01e9bab..b2cca8d 100644 --- a/scripts/live-e2e-profile.mjs +++ b/scripts/live-e2e-profile.mjs @@ -11,14 +11,19 @@ const profiles = process.argv.slice(2) if ( profiles.length === 0 || - profiles.some((profile) => profile !== "go" && profile !== "provider") + profiles.some((profile) => profile !== "go" && profile !== "goat" && profile !== "provider") ) { - console.error("Usage: node scripts/live-e2e-profile.mjs [go|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" : "COMMANDCODE_E2E_PROVIDER" + 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`] @@ -35,15 +40,23 @@ async function credentialFor(profile) { function runProfile(profile, apiKey) { const modelVariable = - profile === "go" ? "COMMANDCODE_E2E_GO_MODEL" : "COMMANDCODE_E2E_PROVIDER_MODEL" - const model = process.env[modelVariable] ?? "deepseek/deepseek-v4-flash" + 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, - COMMANDCODE_API_KEY: apiKey, + 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) => { diff --git a/scripts/pi-authenticated.mjs b/scripts/pi-authenticated.mjs index b0fe10b..974d590 100644 --- a/scripts/pi-authenticated.mjs +++ b/scripts/pi-authenticated.mjs @@ -11,6 +11,7 @@ const env = { ...process.env, PI_SKIP_VERSION_CHECK: "1", } +delete env.COMMAND_CODE_API_KEY delete env.COMMANDCODE_API_KEY const child = spawn( diff --git a/scripts/pi-isolated.mjs b/scripts/pi-isolated.mjs index a725f4e..b66c622 100644 --- a/scripts/pi-isolated.mjs +++ b/scripts/pi-isolated.mjs @@ -22,6 +22,7 @@ const env = { PI_CODING_AGENT_SESSION_DIR: sessionDir, PI_SKIP_VERSION_CHECK: "1", } +delete env.COMMAND_CODE_API_KEY delete env.COMMANDCODE_API_KEY let activeChild diff --git a/src/api-key.ts b/src/api-key.ts index 0ab52f5..f09e155 100644 --- a/src/api-key.ts +++ b/src/api-key.ts @@ -34,6 +34,7 @@ export function getConfiguredApiKey( } = {}, ): 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() diff --git a/src/auth-server.ts b/src/auth-server.ts index e815c10..ee9e6de 100644 --- a/src/auth-server.ts +++ b/src/auth-server.ts @@ -28,6 +28,7 @@ export interface AuthServer { export interface AuthServerOptions { startPort?: number portRange?: number + expectedState?: string } function listenOnAvailablePort( @@ -181,6 +182,12 @@ export async function startAuthServer(options: AuthServerOptions = {}): Promise< 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 })) diff --git a/src/commandcode-catalog.ts b/src/commandcode-catalog.ts index 4249514..6840f5f 100644 --- a/src/commandcode-catalog.ts +++ b/src/commandcode-catalog.ts @@ -51,6 +51,57 @@ export const MODEL_INPUT_MODALITIES: Readonly> = { + "claude-fable-5": 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-vision-exp": true, + "deepseek/deepseek-v4-pro": 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, + "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, + "meta/muse-spark-1.1": true, + "meta/muse-spark-1.2": true, + "meta/muse-spark-1.2-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-Max": true, + "sakana/fugu-ultra": true, + "stealth/ox-alpha": true, + "stepfun/Step-3.5-Flash": true, + "stepfun/Step-3.7-Flash": true, + "tencent/hy3-paid": true, + "thinkingmachines/inkling": true, + "thinkingmachines/inkling-small": true, + "xai/grok-4.5": true, + "xai/grok-4.6": 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-opus-4-7": ["low", "medium", "high", "xhigh", "max"], @@ -82,3 +133,9 @@ export const MODEL_EFFORTS: Readonly> = { + "poolside/laguna-s-2.1-free": 32_768, + "Qwen/Qwen3.8-27B": 32_768, + "stealth/ox-alpha": 131_072, +} diff --git a/src/converters.ts b/src/converters.ts index bb60df2..1e7ea97 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -107,6 +107,7 @@ export function getApiKey( } = {}, ): 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() @@ -138,10 +139,11 @@ export function getApiKey( return undefined } -// Hosts such as OMP may pass the literal env-var name "$COMMANDCODE_API_KEY" -// (or "COMMANDCODE_API_KEY") as the "resolved" registry key instead of the -// actual credential. Treat those as unresolved. +// 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", ]) @@ -162,6 +164,16 @@ export function pickCommandCodeApiKey( } 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) ?? "") @@ -182,7 +194,12 @@ export function toolsToJson(tools?: readonly ToolLike[]): unknown[] { })) } -function completeToolCallIds(messages?: readonly MessageLike[]): Set { +interface ToolCallState { + callIds: ReadonlySet + resultIds: ReadonlySet +} + +function toolCallState(messages?: readonly MessageLike[]): ToolCallState { const callIds = new Set() const resultIds = new Set() @@ -194,12 +211,12 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set { if (id) callIds.add(id) } } - } else if (message.role === "toolResult") { - if (message.toolCallId) resultIds.add(message.toolCallId) + } else if (message.role === "toolResult" && message.toolCallId) { + resultIds.add(message.toolCallId) } } - return new Set([...callIds].filter((id) => resultIds.has(id))) + return { callIds, resultIds } } export function messagesToCC( @@ -210,7 +227,7 @@ export function messagesToCC( if (!allowImages) assertTextOnlyMessages(messages) const out: unknown[] = [] - const pairedToolCallIds = completeToolCallIds(messages) + const { callIds, resultIds } = toolCallState(messages) for (const message of messages ?? []) { if (message.role === "user") { @@ -220,23 +237,37 @@ export function messagesToCC( }) } 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) ?? "" - if (!pairedToolCallIds.has(toolCallId)) continue + const toolName = stringValue(content.name) ?? "" + if (!toolCallId) continue parts.push({ type: "tool-call", toolCallId, - toolName: stringValue(content.name) ?? "", + 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 || !pairedToolCallIds.has(message.toolCallId)) continue + if (!message.toolCallId || !callIds.has(message.toolCallId)) continue out.push({ role: "tool", content: [ diff --git a/src/core.ts b/src/core.ts index a988a5d..44d3fa7 100644 --- a/src/core.ts +++ b/src/core.ts @@ -148,6 +148,10 @@ function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): strin 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() @@ -232,17 +236,15 @@ export function createStreamCommandCode(deps: CoreDependencies) { const stream = deps.createStream() async function run() { - // OMP may pass the legacy env-var name "COMMANDCODE_API_KEY" (old pi) - // or "$COMMANDCODE_API_KEY" (new pi) as the apiKey value instead of - // resolving it. Filter out these specific strings. - const LEGACY_API_KEY_REF = "$COMMANDCODE_API_KEY" - const OLD_API_KEY_REF = "COMMANDCODE_API_KEY" + // Some hosts pass a literal env-var reference instead of resolving it. + const PLACEHOLDER_API_KEYS = new Set([ + "$COMMAND_CODE_API_KEY", + "COMMAND_CODE_API_KEY", + "$COMMANDCODE_API_KEY", + "COMMANDCODE_API_KEY", + ]) const hostKey = - options?.apiKey && - options.apiKey !== LEGACY_API_KEY_REF && - options.apiKey !== OLD_API_KEY_REF - ? options.apiKey - : undefined + options?.apiKey && !PLACEHOLDER_API_KEYS.has(options.apiKey) ? options.apiKey : undefined const apiKey = hostKey ?? @@ -262,7 +264,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { usage: defaultUsage(), stopReason: "error", errorMessage: - "No Command Code API key. Run /login and select Command Code, set the COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json", + "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 }) @@ -484,6 +486,15 @@ export function createStreamCommandCode(deps: CoreDependencies) { } 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) @@ -507,6 +518,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { break } + case "abort": { + throw abortError("Request aborted") + } + case "error": { const message = commandCodeErrorMessage(event.error) ?? @@ -524,7 +539,11 @@ export function createStreamCommandCode(deps: CoreDependencies) { if (controller.signal.aborted) throw abortError("Aborted") const workingDir = cwd() - const threadId = uuid() + const threadId = options?.sessionId + ? isUuid(options.sessionId) + ? options.sessionId + : undefined + : uuid() const reasoningEffort = mappedReasoningEffort(model, options) const timeoutMs = options?.timeoutMs @@ -552,8 +571,8 @@ export function createStreamCommandCode(deps: CoreDependencies) { tools: toolsToJson(context.tools), system: systemPromptToText(context.systemPrompt), max_tokens: generateMaxTokens(model, options), - temperature: 0.3, stream: true, + ...(options?.temperature !== undefined ? { temperature: options.temperature } : {}), ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), }, threadId, @@ -583,7 +602,8 @@ export function createStreamCommandCode(deps: CoreDependencies) { "x-cli-environment": "production", "x-project-slug": projectSlugFromPath(workingDir), "x-taste-learning": "true", - "x-co-flag": "false", + ...(options?.sessionId ? { "x-session-id": options.sessionId } : {}), + "User-Agent": "cli", ...options?.headers, } const bodyStr = JSON.stringify(body) @@ -696,6 +716,11 @@ export function createStreamCommandCode(deps: CoreDependencies) { 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") @@ -719,7 +744,12 @@ export function createStreamCommandCode(deps: CoreDependencies) { } catch {} reader = undefined - if (controller.signal.aborted) throw streamError + 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 @@ -756,7 +786,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { } } } catch (error: unknown) { - const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error" + const reason: ErrorReason = + controller.signal.aborted || (error instanceof Error && error.name === "AbortError") + ? "aborted" + : "error" output.stopReason = reason output.errorMessage = reason === "aborted" diff --git a/src/models.ts b/src/models.ts index 9b1ec93..be7c674 100644 --- a/src/models.ts +++ b/src/models.ts @@ -4,11 +4,13 @@ import { dirname } from "node:path" import { MODEL_EFFORTS, MODEL_INPUT_MODALITIES, + MODEL_MAX_OUTPUT_TOKENS, + MODEL_REASONING, type CommandCodeInputType, type CommandCodeReasoningEffort, } from "./commandcode-catalog.ts" -export { MODEL_EFFORTS, MODEL_INPUT_MODALITIES } +export { MODEL_EFFORTS, MODEL_INPUT_MODALITIES, MODEL_MAX_OUTPUT_TOKENS, MODEL_REASONING } export type { CommandCodeInputType } export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1" @@ -55,7 +57,7 @@ export function thinkingLevelMapForEfforts( export interface ThinkingMetadata { thinkingLevelMap: Partial> - thinking: { + thinking?: { mode: "effort" effortMap: Partial> efforts: readonly CommandCodeReasoningEffort[] @@ -64,19 +66,26 @@ export interface ThinkingMetadata { export function thinkingMetadataForModel(modelId: string): ThinkingMetadata | undefined { const efforts = MODEL_EFFORTS[modelId] - if (!efforts) return undefined - return { - thinkingLevelMap: thinkingLevelMapForEfforts(efforts), - thinking: { - mode: "effort", - effortMap: Object.fromEntries(efforts.map((effort) => [effort, effort])), - efforts, - }, + 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_EFFORTS[modelId] !== undefined + 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 { @@ -162,13 +171,15 @@ function parseCachedModel(value: unknown): CommandCodeModel { 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: positiveNumberField(value, "contextWindow"), - maxTokens: positiveNumberField(value, "maxTokens"), + contextWindow, + maxTokens: maxOutputTokensForModel(id, contextWindow), } } @@ -273,7 +284,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma api: apiForModelId(model.id), reasoning: isReasoningModel(model.id), contextWindow: model.contextLength, - maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS), + maxTokens: maxOutputTokensForModel(model.id, model.contextLength), })) } diff --git a/src/oauth.ts b/src/oauth.ts index 0a68ac5..470ebd3 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -18,7 +18,8 @@ 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 = 15_000 +const DEFAULT_AUTH_TIMEOUT_MS = 120_000 +const DEFAULT_API_BASE = "https://api.commandcode.ai" export interface OAuthLoginCallbacks { onAuth(params: { url: string }): void @@ -95,9 +96,34 @@ export function sanitizeApiKey(input: string): string { .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) } @@ -130,9 +156,10 @@ async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise { + const stateToken = generateStateToken() let authServer try { - authServer = await startAuthServer() + authServer = await startAuthServer({ expectedState: stateToken }) } catch { return promptForApiKey( callbacks, @@ -140,7 +167,6 @@ async function browserLogin(callbacks: OAuthLoginCallbacks): Promise { const choice = await chooseLoginFlow(callbacks) - if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey) + 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:") } diff --git a/src/pricing.ts b/src/pricing.ts index 5f3a951..fddd2a3 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -20,7 +20,7 @@ export interface TemporaryPricing { } export const PRICING_SOURCE_URL = "https://commandcode.ai/docs/resources/pricing-limits" -export const PRICING_LAST_VERIFIED = "2026-08-22" +export const PRICING_LAST_VERIFIED = "2026-08-25" export const ZERO_MODEL_COST: CommandCodeModelCost = { input: 0, @@ -40,7 +40,7 @@ export const ZERO_MODEL_COST: CommandCodeModelCost = { export const MODEL_COSTS: Readonly> = { // Free models "poolside/laguna-s-2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "inclusionai/ling-3.0-flash-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "stealth/ox-alpha": { 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 }, @@ -76,7 +76,14 @@ export const MODEL_COSTS: Readonly> = { cacheRead: 0.007, cacheWrite: 0, }, + "deepseek/deepseek-v4-flash-vision-exp": { + input: 0.22, + output: 0.66, + cacheRead: 0.007, + 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.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, "Qwen/Qwen3.7-Plus": { input: 0.4, @@ -142,6 +149,13 @@ export const MODEL_COSTS: Readonly> = { 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 // Introductory pricing through 2026-08-31. @@ -168,6 +182,12 @@ export const MODEL_COSTS: Readonly> = { "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, // Google and xAI + "google/gemini-3.7-flash": { + input: 0.75, + output: 3.75, + cacheRead: 0.075, + cacheWrite: 0.04167, + }, "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": { @@ -183,6 +203,21 @@ export const MODEL_COSTS: Readonly> = { 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[] = [ @@ -191,4 +226,9 @@ export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [ expiresOn: "2026-08-31", description: "introductory pricing", }, + { + models: ["google/gemini-3.7-flash"], + expiresOn: "2026-12-31", + description: "50% promotional pricing", + }, ] diff --git a/src/quota-command.ts b/src/quota-command.ts index 47f24d9..81a8779 100644 --- a/src/quota-command.ts +++ b/src/quota-command.ts @@ -45,7 +45,7 @@ export function registerCommandCodeQuota( 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 COMMANDCODE_API_KEY.", + "Command Code quota requires an API key. Run /login and select Command Code, or set COMMAND_CODE_API_KEY.", "warning", ) return diff --git a/src/types.ts b/src/types.ts index 2688976..d10f823 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,6 +112,8 @@ export interface StreamOptions { 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 diff --git a/tests/fixtures/commandcode-model-ids.json b/tests/fixtures/commandcode-model-ids.json index 1b9898a..2a69168 100644 --- a/tests/fixtures/commandcode-model-ids.json +++ b/tests/fixtures/commandcode-model-ids.json @@ -1,5 +1,5 @@ { - "fetchedAt": "2026-08-22T21:19:37.782Z", + "fetchedAt": "2026-08-25T13:32:11.631Z", "source": "https://api.commandcode.ai/provider/v1/models", "modelIds": [ "claude-sonnet-5", @@ -18,6 +18,7 @@ "gpt-5.4-mini", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-flash-vision-exp", "moonshotai/Kimi-K3", "moonshotai/Kimi-K2.7-Code", "moonshotai/Kimi-K2.7-Code-Highspeed", @@ -34,6 +35,7 @@ "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "Qwen/Qwen3.8-Max", + "Qwen/Qwen3.8-27B", "Qwen/Qwen3.7-Max", "Qwen/Qwen3.7-Plus", "Qwen/Qwen3.7-Flash", @@ -42,6 +44,7 @@ "stepfun/Step-3.7-Flash", "stepfun/Step-3.5-Flash", "tencent/hy3-paid", + "google/gemini-3.7-flash", "google/gemini-3.6-flash", "google/gemini-3.5-flash", "google/gemini-3.5-flash-lite", @@ -50,9 +53,12 @@ "nvidia/nemotron-3-ultra-550b-a55b", "thinkingmachines/inkling", "thinkingmachines/inkling-small", + "stealth/ox-alpha", "poolside/laguna-s-2.1-free", - "inclusionai/ling-3.0-flash-free", "meta/muse-spark-1.1", - "xai/grok-4.5" + "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 index 5b2070c..c65ae0c 100644 --- a/tests/fixtures/commandcode-pricing.json +++ b/tests/fixtures/commandcode-pricing.json @@ -1,5 +1,5 @@ { - "verifiedAt": "2026-08-22", + "verifiedAt": "2026-08-25", "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": { @@ -7,12 +7,13 @@ "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], - "inclusionai/ling-3.0-flash-free": [0, 0, 0, 0], - "tencent/hy3-paid": [0.14, 0.58, 0.035, 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], "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], @@ -26,9 +27,10 @@ "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], + "xiaomi/mimo-v2.5-pro": [0.435, 0.87, 0.0036, 0], + "xiaomi/mimo-v2.5": [0.14, 0.28, 0.0028, 0], "Qwen/Qwen3.8-Max": [2, 6, 0.25, 2.5], + "Qwen/Qwen3.8-27B": [0.4, 3, 0.04, 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], @@ -36,13 +38,12 @@ "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], + "tencent/hy3-paid": [0.14, 0.58, 0.035, 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], + "poolside/laguna-s-2.1-free": [0, 0, 0, 0], + "stealth/ox-alpha": [0, 0, 0, 0], "claude-sonnet-5": [2, 10, 0.2, 2.5], "claude-sonnet-4-6": [3, 15, 0.3, 3.75], "claude-fable-5": [10, 50, 1, 12.5], @@ -57,10 +58,16 @@ "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": [0.75, 3.75, 0.075, 0.04167], "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] + "sakana/fugu-ultra": [5, 30, 0.5, 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], + "xai/grok-4.5": [2, 6, 0.5, 0], + "xai/grok-4.6": [2, 6, 0.5, 0] } } diff --git a/tests/test-api-key.ts b/tests/test-api-key.ts index a497978..f3029cb 100644 --- a/tests/test-api-key.ts +++ b/tests/test-api-key.ts @@ -21,10 +21,17 @@ async function withAuthFile( } describe("getConfiguredApiKey()", () => { - it("prefers the environment variable", () => { + it("prefers the official environment variable and keeps the legacy alias", () => { assert.equal( - getConfiguredApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), - "env-key", + 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", ) }) diff --git a/tests/test-live-e2e.mjs b/tests/test-live-e2e.mjs index 88ca636..f17ffa5 100644 --- a/tests/test-live-e2e.mjs +++ b/tests/test-live-e2e.mjs @@ -27,7 +27,20 @@ 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 === "provider" ? "provider" : undefined + testProfile === "go" + ? "generate" + : testProfile === "goat" || testProfile === "provider" + ? "provider" + : undefined +const expectedPlan = + testProfile === "go" + ? "go" + : testProfile === "goat" + ? "goat" + : testProfile === "provider" + ? "provider" + : undefined +const goatVisionModel = process.env.COMMANDCODE_E2E_GOAT_VISION_MODEL ?? "google/gemini-3.7-flash" const marker = "commandcode-live-e2e-ok" function findPiBinary() { @@ -48,6 +61,7 @@ function findPiBinary() { 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")) @@ -70,6 +84,7 @@ function safeEnv(overrides = {}) { 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 @@ -102,7 +117,7 @@ function run(command, args, options = {}) { }) } -async function runRpc(extension, action, timeoutMs = 120_000) { +async function runRpc(extension, action, timeoutMs = 120_000, model = testModel) { const child = spawn( piBin, [ @@ -114,7 +129,9 @@ async function runRpc(extension, action, timeoutMs = 120_000) { "--provider", "commandcode", "--model", - testModel, + model, + "--thinking", + "high", ], { cwd: projectDir, env: safeEnv(), stdio: ["pipe", "pipe", "pipe"] }, ) @@ -222,8 +239,19 @@ try { await waitFor( (event) => event.type === "response" && event.id === "reasoning-turn-1" && event.success, ) - await waitFor((event) => event.type === "agent_settled") - const firstThinkingDeltas = countThinkingDeltas(firstStart) + 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({ @@ -235,15 +263,16 @@ try { await waitFor( (event) => event.type === "response" && event.id === "reasoning-turn-2" && event.success, ) - await waitFor((event) => event.type === "agent_settled" && events.indexOf(event) >= secondStart) + 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() } }) - if (testProfile !== "provider") { - assert.ok(multiTurn.firstThinkingDeltas > 0, "first turn should stream reasoning") - assert.ok(multiTurn.secondThinkingDeltas > 0, "follow-up turn should stream fresh reasoning") - } + 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") @@ -281,15 +310,66 @@ try { typeof event.message === "string" && event.message.includes("source:"), ) - return { names, refresh: refresh.message, status: status.message, stderr: getStderr() } + + 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]*/) - assert.doesNotMatch(`${runtime.refresh}\n${runtime.status}\n${runtime.stderr}`, /Bearer\s+\S+/i) + 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") @@ -317,9 +397,45 @@ try { ) assert.equal(toolResult.code, 0, toolResult.stderr) assert.match(toolResult.stdout, new RegExp(marker)) - assert.equal(readFileSync(targetPath, "utf-8"), marker) + assert.equal(readFileSync(targetPath, "utf-8").trimEnd(), marker) - if (testProfile !== "provider") { + 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({ diff --git a/tests/test-model-metadata-check.ts b/tests/test-model-metadata-check.ts index ac1b61f..97fe30e 100644 --- a/tests/test-model-metadata-check.ts +++ b/tests/test-model-metadata-check.ts @@ -5,6 +5,7 @@ import { commandCodeModelMetadataFromContents, diffModelMetadata, hasModelMetadataDiff, + parseBundleModelCapabilities, parseKnownTextOnlyModelIds, parseModelsReference, parsePackageVersion, @@ -21,7 +22,7 @@ const MODELS_REFERENCE = ` ` const CLI_BUNDLE = - 'const catalog=new Set(["text-model"]),__name(isKnownTextOnlyModel,"isKnownTextOnlyModel")' + '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", () => { @@ -42,29 +43,39 @@ describe("Command Code model metadata checker", () => { assert.throws(() => parsePackageVersion("latest"), /one semantic version/) }) - it("derives image support by excluding known text-only models", () => { + 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-reasoning": ["low"], - "removed-reasoning": ["high"], - "stable-reasoning": ["low", "high"], + "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-reasoning": ["max"], - "changed-reasoning": ["low", "high"], - "stable-reasoning": ["low", "high"], + "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) @@ -75,7 +86,12 @@ describe("Command Code model metadata checker", () => { removedImageModelIds: ["removed-image"], addedReasoningModelIds: ["added-reasoning"], removedReasoningModelIds: ["removed-reasoning"], - changedReasoningModelIds: ["changed-reasoning"], + addedEffortModelIds: ["added-effort"], + removedEffortModelIds: ["removed-effort"], + changedEffortModelIds: ["changed-effort"], + addedMaxOutputModelIds: ["added-output"], + removedMaxOutputModelIds: ["removed-output"], + changedMaxOutputModelIds: ["changed-output"], }) assert.equal(hasModelMetadataDiff(diff), true) }) @@ -83,7 +99,9 @@ describe("Command Code model metadata checker", () => { 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") @@ -96,10 +114,12 @@ describe("Command Code model metadata checker", () => { 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" @@ -115,10 +135,19 @@ export const MODEL_INPUT_MODALITIES: 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( diff --git a/tests/test-models.ts b/tests/test-models.ts index 391e06b..c40d321 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -16,6 +16,8 @@ import { loadCommandCodeModels, MODEL_EFFORTS, MODEL_INPUT_MODALITIES, + MODEL_MAX_OUTPUT_TOKENS, + MODEL_REASONING, modelSupportsImageInput, thinkingLevelMapForEfforts, thinkingMetadataForModel, @@ -41,7 +43,7 @@ const EXPECTED_MODELS: readonly CommandCodeModel[] = [ id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max (CC)", api: "openai-completions", - reasoning: false, + reasoning: true, contextWindow: 1_000_000, maxTokens: 65_536, }, @@ -124,17 +126,55 @@ describe("commandCodeModelsFromApiResponse()", () => { } }) - it("marks only known reasoning models as reasoning-capable", () => { + it("tracks reasoning independently from selectable effort levels", () => { const models = commandCodeModelsFromApiResponse({ object: "list", data: [ { ...API_RESPONSE.data[0], id: "deepseek/deepseek-v4-flash" }, + { ...API_RESPONSE.data[0], id: "moonshotai/Kimi-K3" }, { ...API_RESPONSE.data[0], id: "new-model-without-metadata" }, ], }) assert.equal(models[0]?.reasoning, true) - assert.equal(models[1]?.reasoning, false) + assert.equal(models[1]?.reasoning, true) + assert.deepEqual(thinkingMetadataForModel("moonshotai/Kimi-K3"), { + thinkingLevelMap: { + minimal: null, + low: null, + medium: null, + high: null, + xhigh: null, + max: null, + }, + }) + assert.equal(models[2]?.reasoning, false) + assert.equal(Object.keys(MODEL_REASONING).length, 48) + }) + + it("uses model-specific output limits from the CLI catalog", () => { + const models = commandCodeModelsFromApiResponse({ + object: "list", + data: [ + { ...API_RESPONSE.data[0], id: "Qwen/Qwen3.8-27B", context_length: 262_144 }, + { ...API_RESPONSE.data[0], id: "stealth/ox-alpha", context_length: 1_048_576 }, + { + ...API_RESPONSE.data[0], + id: "poolside/laguna-s-2.1-free", + context_length: 256_000, + }, + ], + }) + + assert.deepEqual( + models.map(({ id, maxTokens }) => ({ id, maxTokens })), + [ + { id: "Qwen/Qwen3.8-27B", maxTokens: 32_768 }, + { id: "stealth/ox-alpha", maxTokens: 131_072 }, + { id: "poolside/laguna-s-2.1-free", maxTokens: 32_768 }, + ], + ) + assert.equal(Object.keys(MODEL_MAX_OUTPUT_TOKENS).length, 3) }) it(`uses the command-code@${COMMAND_CODE_CLI_VERSION} reasoning effort catalog`, () => { @@ -151,6 +191,7 @@ describe("commandCodeModelsFromApiResponse()", () => { 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( diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts index 00d94a0..6a64381 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -9,7 +9,7 @@ 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 } from "../src/oauth.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. @@ -24,9 +24,27 @@ function waitForClose(server: { }) } +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 }) + const { server, port, waitForCallback } = await startAuthServer({ + startPort: 0, + expectedState: "test-state-token", + }) const callbackData: AuthCallback = { apiKey: "user_testKey123", @@ -57,6 +75,42 @@ describe("startAuthServer()", () => { 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 }) @@ -176,6 +230,18 @@ describe("OAuth functions", () => { 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()", () => { @@ -242,15 +308,17 @@ describe("login()", () => { const promptMessages: string[] = [] try { - const result = await 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~" - }, - }) + 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/) @@ -265,14 +333,16 @@ describe("login()", () => { it("accepts a directly pasted API key", async () => { let authOpened = false - const result = await login({ - onAuth() { - authOpened = true - }, - onPrompt(): Promise { - return Promise.resolve("user_directApiKey") - }, - }) + 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") @@ -280,21 +350,23 @@ describe("login()", () => { it("offers an explicit API key prompt", async () => { let promptCount = 0 - const result = await login({ - onAuth() { - throw new Error("browser should not open") - }, - onPrompt(): Promise { - promptCount += 1 - return Promise.resolve(promptCount === 1 ? "key" : "user_promptedApiKey") - }, - }) + 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("rejects on state token mismatch", async () => { + it("keeps waiting after a state mismatch and accepts the legitimate callback", async () => { let authUrl = "" const callbacks = { onAuth(params: { url: string }) { @@ -305,12 +377,7 @@ describe("login()", () => { }, } - const loginPromise: Promise = login(callbacks).then( - () => { - throw new Error("Expected login to reject") - }, - (e: Error) => e.message, - ) + const loginPromise = login(callbacks) // Wait for onAuth to be called asynchronously while (!authUrl) await new Promise((resolve) => setTimeout(resolve, 10)) @@ -318,8 +385,8 @@ describe("login()", () => { const url = new URL(authUrl) const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0") - // Post back with a wrong state token - await fetch(`http://127.0.0.1:${port}/callback`, { + // 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({ @@ -331,7 +398,20 @@ describe("login()", () => { }), }) - const errorMsg = await loginPromise - assert.match(errorMsg, /State token mismatch/) + 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 index eeb5793..ae64d1c 100644 --- a/tests/test-omp-compat.mjs +++ b/tests/test-omp-compat.mjs @@ -133,7 +133,7 @@ function runOmp(args, timeoutMs = 30_000) { HOME: tempHome, USERPROFILE: tempHome, PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"), - COMMANDCODE_API_KEY: "mock-key", + COMMAND_CODE_API_KEY: "mock-key", COMMANDCODE_API_BASE: `${apiBase}/provider/v1`, COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, }, diff --git a/tests/test-pi-authenticated.mjs b/tests/test-pi-authenticated.mjs index 7f992b3..39fa536 100644 --- a/tests/test-pi-authenticated.mjs +++ b/tests/test-pi-authenticated.mjs @@ -22,7 +22,7 @@ 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.COMMANDCODE_API_KEY ?? null, + apiKey: process.env.COMMAND_CODE_API_KEY ?? process.env.COMMANDCODE_API_KEY ?? null, skipVersionCheck: process.env.PI_SKIP_VERSION_CHECK, })) NODE @@ -38,7 +38,8 @@ NODE PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, FAKE_PI_LOG: logPath, PI_CODING_AGENT_DIR: "/existing/pi-agent", - COMMANDCODE_API_KEY: "existing-key", + COMMAND_CODE_API_KEY: "official-existing-key", + COMMANDCODE_API_KEY: "legacy-existing-key", }, encoding: "utf8", }) diff --git a/tests/test-pi-isolated.mjs b/tests/test-pi-isolated.mjs index 0b809a6..3ca85c6 100644 --- a/tests/test-pi-isolated.mjs +++ b/tests/test-pi-isolated.mjs @@ -26,7 +26,8 @@ appendFileSync(process.env.FAKE_PI_LOG, JSON.stringify({ skipVersionCheck: process.env.PI_SKIP_VERSION_CHECK, home: process.env.HOME, userProfile: process.env.USERPROFILE, - inheritedApiKey: process.env.COMMANDCODE_API_KEY ?? null, + inheritedApiKey: + process.env.COMMAND_CODE_API_KEY ?? process.env.COMMANDCODE_API_KEY ?? null, }) + "\\n") NODE if [ "$1" = "install" ]; then exit 0; fi @@ -42,7 +43,8 @@ exit ${exitStatus} ...process.env, PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, FAKE_PI_LOG: logPath, - COMMANDCODE_API_KEY: "must-not-leak", + COMMAND_CODE_API_KEY: "must-not-leak-official", + COMMANDCODE_API_KEY: "must-not-leak-legacy", }, encoding: "utf8", }) diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 2d0a999..3bce2c8 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -215,8 +215,8 @@ const env = { PI_CODING_AGENT_DIR: agentDir, PI_CODING_AGENT_SESSION_DIR: join(tempHome, "sessions"), COMMANDCODE_API_BASE: `${apiBase}/provider/v1`, - COMMANDCODE_API_KEY: "mock-key", - COMMANDCODE_ZDR: "1", + COMMAND_CODE_API_KEY: "mock-key", + CMD_ZDR: "1", COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, } diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts index 202d8aa..d7141a5 100644 --- a/tests/test-pricing.ts +++ b/tests/test-pricing.ts @@ -27,7 +27,7 @@ const fixtureUrl = new URL("./fixtures/commandcode-model-ids.json", import.meta. 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", "inclusionai/ling-3.0-flash-free"]) +const freeModels = new Set(["poolside/laguna-s-2.1-free", "stealth/ox-alpha"]) function assertCost( modelId: string, @@ -50,7 +50,7 @@ function assertCost( 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-08-22T/) + assert.match(fixture.fetchedAt, /^2026-08-25T/) const catalogIds = [...fixture.modelIds].sort() const pricedIds = Object.keys(MODEL_COSTS).sort() @@ -138,6 +138,24 @@ describe("MODEL_COSTS pricing overlay", () => { cacheRead: 0.03, cacheWrite: 0, }) + assertCost("Qwen/Qwen3.8-27B", { + input: 0.4, + output: 3, + cacheRead: 0.04, + cacheWrite: 0, + }) + assertCost("google/gemini-3.7-flash", { + input: 0.75, + output: 3.75, + cacheRead: 0.075, + cacheWrite: 0.04167, + }) + 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", () => { @@ -165,11 +183,20 @@ describe("MODEL_COSTS pricing overlay", () => { 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-08-22") + assert.equal(PRICING_LAST_VERIFIED, "2026-08-25") }) it("fails once temporary pricing needs review", () => { diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index d71dba0..17a4c78 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -27,8 +27,18 @@ import { redactCommandCodeErrorText } from "../src/overflow.ts" import { objectAt } from "./helpers.ts" describe("getApiKey()", () => { - it("uses COMMANDCODE_API_KEY from provided env", () => { - assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key") + 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", () => { @@ -109,11 +119,14 @@ describe("error redaction", () => { 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) }) @@ -199,6 +212,12 @@ describe("textContent()", () => { ) }) + 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({}), "") @@ -531,6 +550,23 @@ describe("messagesToCC()", () => { 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( @@ -632,7 +668,7 @@ describe("messagesToCC()", () => { ]) }) - it("drops orphaned tool calls that have no matching tool result", () => { + it("synthesizes missing results for orphaned tool calls", () => { const result = messagesToCC([ { role: "user", content: "edit a file" }, { @@ -651,7 +687,12 @@ describe("messagesToCC()", () => { assert.equal(objectAt(result, ["1", "role"]), "assistant") assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") - assert.equal(objectAt(result, ["1", "content", "1"]), undefined) + 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", () => { diff --git a/tests/test-quota-command.ts b/tests/test-quota-command.ts index 95692cd..3ae753c 100644 --- a/tests/test-quota-command.ts +++ b/tests/test-quota-command.ts @@ -68,7 +68,7 @@ describe("commandcode-quota command", () => { }) assert.ok(pi.handler) - const ctx = context("$COMMANDCODE_API_KEY") + const ctx = context("$COMMAND_CODE_API_KEY") await pi.handler("", ctx.value) assert.equal(ctx.waited(), true) assert.equal(requestKey, "fallback-key") diff --git a/tests/test-smoke.mjs b/tests/test-smoke.mjs index 2665710..483c00a 100644 --- a/tests/test-smoke.mjs +++ b/tests/test-smoke.mjs @@ -7,7 +7,7 @@ * 3. Can complete a simple prompt (requires Command Code auth) * * Run with: node tests/test-smoke.mjs - * Requires: pi on PATH plus COMMANDCODE_API_KEY or live pi auth files. + * 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" @@ -48,6 +48,7 @@ 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")) diff --git a/tests/test-stream.ts b/tests/test-stream.ts index a54bd85..ea92508 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -77,6 +77,23 @@ describe("streamCommandCode — auth", () => { ) }) + 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", @@ -653,7 +670,7 @@ describe("streamCommandCode — request serialization", () => { 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"]), 0.3) + 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) @@ -671,10 +688,53 @@ describe("streamCommandCode — request serialization", () => { 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["x-co-flag"], "false") + assert.equal(headers["user-agent"], "cli") + assert.equal(headers["x-co-flag"], undefined) assert.equal(headers["x-session-id"], undefined) }) + 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", @@ -917,6 +977,63 @@ describe("streamCommandCode — upstream errors and malformed streams", () => { 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({