diff --git a/.gitleaks.toml b/.gitleaks.toml
index e1c2d97..dcd3149 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -41,5 +41,5 @@ title = "pi-commandcode-provider secret scan"
[[rules]]
id = "pi-test-api-key"
description = "Test API key value that looks real"
- regex = '''(user_testKey|mock-key|fake-key|test-api-key)'''
+ regex = '''['"](user_testKey|mock-key|fake-key|test-api-key)['"]'''
tags = ["pi-extension", "test"]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4cae0a0..ee3cd9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
## Unreleased
+- 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`.
+- 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.
+
## 0.5.1 - 2026-08-11
- Add model-specific image input capabilities from the `command-code@1.15.1` catalog and forward user and tool-result images using the current Command Code wire format.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index da4d0a4..6adb7bb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -42,6 +42,15 @@ npm run pi:authenticated
Both commands accept additional pi arguments after `--`, for example `npm run pi:authenticated -- --model claude-sonnet-4-6`.
+Run the transport-specific live tests with separate credentials:
+
+```sh
+COMMANDCODE_E2E_GO_API_KEY_FILE=/path/to/go-key npm run test:e2e:live:go
+COMMANDCODE_E2E_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.
+
Before opening a PR, run:
```sh
diff --git a/README.md b/README.md
index 91b8085..4b32189 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,16 @@
A custom provider for [pi](https://github.com/earendil-works/pi) that connects to the [Command Code](https://commandcode.ai) Provider API.
-> **Disclaimer:** This is an unofficial, community-maintained integration. It is not affiliated with, endorsed by, or supported by Command Code. You need your own Command Code account and API key or subscription. Command Code's terms, availability, and pricing apply.
+> **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
@@ -19,7 +28,7 @@ Start or reload pi, then authenticate:
/login
```
-Select **Use a subscription**, then **Command Code**. Complete the browser flow and choose a model with `/model`.
+Select **Use a subscription**, then **Command Code**. Choose browser login or paste an API key, then select a model with `/model`.
## Oh My Pi
@@ -33,9 +42,9 @@ Restart OMP or run `/reload`, then use `/login` and select **Use a subscription*
## Authentication
-### Browser login
+### Login dialog
-Run `/login` in pi or OMP. Select **Use a subscription**, then **Command Code**. The browser flow stores the returned credential in the host's auth file.
+Run `/login` in pi or OMP. Select **Use a subscription**, then **Command Code**. Press Enter for browser login, type `key` to open a paste prompt, or paste the API key directly. The selected credential is stored in the host's auth file.
@@ -84,9 +93,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. A selected supported level is sent as the documented `params.reasoning_effort` field; `off`, unsupported levels, and newly discovered models without metadata do not add reasoning fields to the request. No prompt instructions are injected.
-
-Reasoning blocks from completed assistant turns remain visible in pi's local session, but are not replayed to Command Code in later requests. Only the assistant's user-visible text and completed tool calls are sent back as history. This matches the current Command Code CLI behavior and prevents prior private reasoning traces from interfering with reasoning on follow-up turns.
+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.
List Command Code models from the terminal:
@@ -123,6 +130,8 @@ While pi is running, use these provider commands without restarting:
- `/commandcode-refresh` fetches and re-registers the current model catalog. Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active.
- `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning.
+Set `COMMANDCODE_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header.
+
The following environment variables are intended for tests, local mocks, and compatible API endpoints:
- `COMMANDCODE_API_BASE`
@@ -134,13 +143,13 @@ The following environment variables are intended for tests, local mocks, and com
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.15.1`; unknown models default to text-only until their upstream metadata is reviewed.
-For vision-capable models, image blocks from user messages and tool results are forwarded in Command Code's current data-URL wire format. Text-only models reject image content before making a network request instead of silently dropping it.
+For vision-capable models, Pi's native provider adapters forward image blocks from user messages and tool results using the documented OpenAI or Anthropic message schema. Unknown and text-only models remain marked text-only in Pi.
## Pricing display
-The Command Code Provider API does not currently include prices in its model catalog. This extension therefore keeps a static table for models with known prices so pi can display estimated request costs.
+The Command Code Provider API does not currently include prices in its model catalog. This extension therefore keeps a static table for models with known prices so pi can display estimated request costs. DeepSeek V4 uses time-dependent rates; pi displays the documented off-peak rate, which applies for 17 hours per day.
-Models missing from that table display zero cost in pi. This does **not** mean that Command Code will bill the request at zero. Check the current [Command Code pricing](https://commandcode.ai/docs/resources/pricing-limits) before relying on the displayed value.
+Models missing from that table display zero cost in pi. This does **not** mean that Command Code will bill the request at zero. The Command Code Usage page remains authoritative for each request. Check the current [Command Code pricing](https://commandcode.ai/docs/resources/pricing-limits) before relying on the displayed value.
## Update and remove
@@ -181,6 +190,26 @@ npm run pi:authenticated
Both commands accept additional pi arguments after `--`, for example `npm run pi:authenticated -- --model claude-sonnet-4-6`.
+### Live transport tests
+
+Keep 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:
+
+```sh
+COMMANDCODE_E2E_GO_API_KEY_FILE=/path/to/go-key \
+ npm run test:e2e:live:go
+
+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 \
+ 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.
+
+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.
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup and tests. See [RELEASE.md](RELEASE.md) for the release process.
## License
diff --git a/index.ts b/index.ts
index 24a77b1..72ae558 100644
--- a/index.ts
+++ b/index.ts
@@ -1,12 +1,12 @@
/**
* Command Code provider for pi.
*
- * Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate).
- * The provider uses pi's legacy extension registration surface because the
- * current pi host exposes `registerProvider(name, config)`, including OMP.
+ * Uses Command Code's documented Provider API:
+ * https://api.commandcode.ai/provider/v1
*/
import { AssistantMessageEventStream } from "@earendil-works/pi-ai"
+import { streamSimple as streamNativeProvider } from "@earendil-works/pi-ai/compat"
import {
getAgentDir,
type ExtensionAPI,
@@ -15,10 +15,13 @@ import {
} from "@earendil-works/pi-coding-agent"
import { join } from "node:path"
-import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"
+import { getConfiguredApiKey } from "./src/api-key.ts"
+import { createStreamCommandCode } from "./src/core.ts"
import { calculateCommandCodeCost } from "./src/cost.ts"
import {
+ baseUrlForModel,
DEFAULT_MODELS_URL,
+ DEFAULT_PROVIDER_API_BASE,
getModelsTimeoutMs,
inputModalitiesForModel,
loadCommandCodeModels,
@@ -26,68 +29,87 @@ import {
type CommandCodeModel,
} from "./src/models.ts"
import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts"
+import { normalizeCommandCodeMessage } from "./src/overflow.ts"
import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts"
import { createCommandCodeRuntime } from "./src/runtime.ts"
-import { normalizeCommandCodeMessage } from "./src/overflow.ts"
+import { createCommandCodeTransportRouter } from "./src/transport.ts"
+
+function commandCodeHeaders(): Record | undefined {
+ if (process.env.COMMANDCODE_ZDR === "1") {
+ return { "x-cmd-zdr": "1" }
+ }
+ return undefined
+}
function createProviderConfig(
models: readonly CommandCodeModel[],
apiBase: string,
streamCommandCode: ProviderConfig["streamSimple"],
): ProviderConfig {
+ const headers = commandCodeHeaders()
return {
name: "Command Code",
baseUrl: apiBase,
- // Keep environment authentication dynamic. OAuth credentials are resolved
- // by pi's oauth registration, while the custom stream retains its own
- // request-time legacy-file fallback for older compatible hosts.
- apiKey: "$COMMANDCODE_API_KEY",
- authHeader: true,
- api: "commandcode-custom",
+ apiKey: getConfiguredApiKey() ?? "$COMMANDCODE_API_KEY",
+ api: "openai-completions",
streamSimple: streamCommandCode,
- headers: {
- "x-command-code-version": COMMAND_CODE_CLI_VERSION,
- "x-cli-environment": "production",
- },
+ headers,
oauth: {
name: "Command Code",
login,
refreshToken,
getApiKey: getOAuthApiKey,
},
- models: models.map(createProviderModel),
+ models: models.map((model) => ({
+ id: model.id,
+ name: model.name,
+ api: model.api,
+ baseUrl: baseUrlForModel(apiBase, model.api),
+ reasoning: model.reasoning,
+ ...(thinkingMetadataForModel(model.id) ?? {}),
+ input: [...inputModalitiesForModel(model.id)],
+ cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST,
+ contextWindow: model.contextWindow,
+ maxTokens: model.maxTokens,
+ headers,
+ compat:
+ model.api === "openai-completions"
+ ? {
+ supportsStore: false,
+ supportsDeveloperRole: false,
+ supportsReasoningEffort: true,
+ maxTokensField: "max_tokens",
+ }
+ : {
+ supportsEagerToolInputStreaming: false,
+ supportsLongCacheRetention: false,
+ supportsCacheControlOnTools: false,
+ supportsToolReferences: false,
+ ...(model.reasoning ? { forceAdaptiveThinking: true } : {}),
+ },
+ })),
}
}
-function createProviderModel(model: {
- id: string
- name: string
- reasoning: boolean
- contextWindow: number
- maxTokens: number
-}) {
- return {
- id: model.id,
- name: model.name,
- reasoning: model.reasoning,
- ...(thinkingMetadataForModel(model.id) ?? {}),
- input: inputModalitiesForModel(model.id),
- cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST,
- contextWindow: model.contextWindow,
- maxTokens: model.maxTokens,
- } as const
+function legacyApiBase(providerApiBase: string): string {
+ return providerApiBase.replace(/\/provider\/v1\/?$/, "")
}
export default async function (pi: ExtensionAPI) {
- const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE
+ const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_PROVIDER_API_BASE
const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL
const modelsTimeoutMs = getModelsTimeoutMs()
const modelsCachePath =
process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json")
- const streamCommandCode = createStreamCommandCode({
+ const streamGenerate = createStreamCommandCode({
createStream: () => new AssistantMessageEventStream(),
calculateCost: calculateCommandCodeCost,
- apiBase,
+ apiBase: legacyApiBase(apiBase),
+ })
+ const transport = createCommandCodeTransportRouter({
+ createStream: () => new AssistantMessageEventStream(),
+ streamProvider: streamNativeProvider,
+ streamGenerate,
})
pi.on("message_end", async (event, ctx) => {
@@ -105,7 +127,8 @@ export default async function (pi: ExtensionAPI) {
cachePath: modelsCachePath,
timeoutMs: modelsTimeoutMs,
}),
- createProviderConfig: (models) => createProviderConfig(models, apiBase, streamCommandCode),
+ createProviderConfig: (models) => createProviderConfig(models, apiBase, transport.stream),
+ getTransport: transport.getTransport,
})
await runtime.initialize()
diff --git a/package.json b/package.json
index 05a6ef0..f284d3b 100644
--- a/package.json
+++ b/package.json
@@ -29,13 +29,14 @@
"LICENSE"
],
"scripts": {
- "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
+ "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
"typecheck": "tsc --noEmit",
"format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
"format": "prettier --write '**/*.{ts,mjs,json,md}'",
"pi:isolated": "node scripts/pi-isolated.mjs",
"pi:authenticated": "node scripts/pi-authenticated.mjs",
- "test:unit": "tsx tests/test-pure-functions.ts",
+ "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts",
+ "test:api-key": "tsx tests/test-api-key.ts",
"test:models": "tsx tests/test-models.ts",
"test:runtime": "tsx tests/test-runtime.ts",
"test:pricing": "tsx tests/test-pricing.ts",
@@ -44,11 +45,15 @@
"test:overflow": "tsx tests/test-overflow.ts",
"test:stream": "tsx tests/test-stream.ts",
"test:retry": "tsx tests/test-retry.ts",
+ "test:transport": "tsx tests/test-transport.ts",
"test:pi-isolated": "node tests/test-pi-isolated.mjs",
"test:pi-authenticated": "node tests/test-pi-authenticated.mjs",
"test:pi-local": "node tests/test-pi-local.mjs",
"test:smoke": "node tests/test-smoke.mjs",
"test:e2e:live": "node tests/test-live-e2e.mjs",
+ "test:e2e:live:go": "node scripts/live-e2e-profile.mjs go",
+ "test:e2e:live:provider": "node scripts/live-e2e-profile.mjs provider",
+ "test:e2e:live:all": "node scripts/live-e2e-profile.mjs go provider",
"test:cost": "tsx tests/test-cost.ts"
},
"pi": {
diff --git a/scripts/live-e2e-profile.mjs b/scripts/live-e2e-profile.mjs
new file mode 100644
index 0000000..01e9bab
--- /dev/null
+++ b/scripts/live-e2e-profile.mjs
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+
+import { spawn } from "node:child_process"
+import { readFile } from "node:fs/promises"
+import { dirname, resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
+const liveTest = resolve(projectDir, "tests", "test-live-e2e.mjs")
+const profiles = process.argv.slice(2)
+
+if (
+ profiles.length === 0 ||
+ profiles.some((profile) => profile !== "go" && profile !== "provider")
+) {
+ console.error("Usage: node scripts/live-e2e-profile.mjs [go|provider]")
+ process.exit(2)
+}
+
+async function credentialFor(profile) {
+ const prefix = profile === "go" ? "COMMANDCODE_E2E_GO" : "COMMANDCODE_E2E_PROVIDER"
+ const direct = process.env[`${prefix}_API_KEY`]?.trim()
+ const file = process.env[`${prefix}_API_KEY_FILE`]
+
+ if (direct && file)
+ throw new Error(`${prefix}_API_KEY and ${prefix}_API_KEY_FILE are mutually exclusive`)
+ if (direct) return direct
+ if (file) {
+ const credential = (await readFile(file, "utf-8")).trim()
+ if (credential) return credential
+ }
+
+ throw new Error(`Set ${prefix}_API_KEY_FILE (recommended) or ${prefix}_API_KEY`)
+}
+
+function runProfile(profile, apiKey) {
+ const modelVariable =
+ profile === "go" ? "COMMANDCODE_E2E_GO_MODEL" : "COMMANDCODE_E2E_PROVIDER_MODEL"
+ const model = process.env[modelVariable] ?? "deepseek/deepseek-v4-flash"
+ const env = {
+ ...process.env,
+ COMMANDCODE_API_KEY: apiKey,
+ COMMANDCODE_E2E_MODEL: model,
+ COMMANDCODE_E2E_PROFILE: profile,
+ }
+ delete env.COMMANDCODE_E2E_GO_API_KEY
+ delete env.COMMANDCODE_E2E_PROVIDER_API_KEY
+
+ return new Promise((resolveRun, reject) => {
+ console.log(`[live-e2e:${profile}] model ${model}`)
+ const child = spawn(process.execPath, [liveTest], {
+ cwd: projectDir,
+ env,
+ stdio: "inherit",
+ })
+ child.on("error", reject)
+ child.on("close", (code, signal) => {
+ if (code === 0) {
+ resolveRun()
+ return
+ }
+ reject(new Error(`[live-e2e:${profile}] failed (${signal ?? `exit ${code}`})`))
+ })
+ })
+}
+
+try {
+ for (const profile of profiles) {
+ await runProfile(profile, await credentialFor(profile))
+ }
+} catch (error) {
+ console.error(error instanceof Error ? error.message : String(error))
+ process.exit(1)
+}
diff --git a/src/api-key.ts b/src/api-key.ts
new file mode 100644
index 0000000..0ab52f5
--- /dev/null
+++ b/src/api-key.ts
@@ -0,0 +1,68 @@
+import { existsSync, readFileSync } from "node:fs"
+import { homedir } from "node:os"
+import { join } from "node:path"
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function stringValue(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined
+}
+
+function defaultAuthPaths(home: string): string[] {
+ return [
+ join(home, ".commandcode", "auth.json"),
+ join(home, ".pi", "agent", "auth.json"),
+ join(home, ".omp", "agent", "auth.json"),
+ ]
+}
+
+function apiKeyFromCredential(value: unknown): string | undefined {
+ if (!isRecord(value)) return undefined
+
+ if (stringValue(value.type) === "oauth") return stringValue(value.access)
+ if (stringValue(value.type) === "api") return stringValue(value.key)
+ return stringValue(value.access) ?? stringValue(value.key)
+}
+
+export function getConfiguredApiKey(
+ options: {
+ env?: NodeJS.ProcessEnv
+ authPaths?: readonly string[]
+ homeDir?: () => string
+ } = {},
+): string | undefined {
+ const env = options.env ?? process.env
+ if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY
+
+ const home = options.homeDir?.() ?? homedir()
+ const authPaths = options.authPaths ?? defaultAuthPaths(home)
+
+ for (const authPath of authPaths) {
+ try {
+ if (!existsSync(authPath)) continue
+ const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8"))
+ if (!isRecord(parsed)) continue
+
+ const apiKey = stringValue(parsed.apiKey)
+ if (apiKey) return apiKey
+
+ const commandcode = stringValue(parsed.commandcode)
+ if (commandcode) return commandcode
+
+ const providerKey = apiKeyFromCredential(parsed.commandcode)
+ if (providerKey) return providerKey
+
+ const commandCode = stringValue(parsed["command-code"])
+ if (commandCode) return commandCode
+
+ const commandCodeKey = apiKeyFromCredential(parsed["command-code"])
+ if (commandCodeKey) return commandCodeKey
+ } catch {
+ // Ignore malformed or unreadable auth files.
+ }
+ }
+
+ return undefined
+}
diff --git a/src/models.ts b/src/models.ts
index cd470ef..3f8c7e9 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -1,12 +1,14 @@
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
import { dirname } from "node:path"
-export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models"
+export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1"
+export const DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models`
export const DEFAULT_MODELS_TIMEOUT_MS = 10_000
const DEFAULT_MAX_OUTPUT_TOKENS = 65_536
const MODEL_CACHE_VERSION = 1
+export type CommandCodeApi = "openai-completions" | "anthropic-messages"
export type CommandCodeInputType = "text" | "image"
/**
@@ -159,11 +161,22 @@ interface ApiModel {
export interface CommandCodeModel {
id: string
name: string
+ api: CommandCodeApi
reasoning: boolean
contextWindow: number
maxTokens: number
}
+export function apiForModelId(id: string): CommandCodeApi {
+ return id.startsWith("claude-") ? "anthropic-messages" : "openai-completions"
+}
+
+export function baseUrlForModel(apiBase: string, api: CommandCodeApi): string {
+ const normalized = apiBase.replace(/\/+$/g, "")
+ if (api !== "anthropic-messages") return normalized
+ return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized
+}
+
interface FetchCommandCodeModelsOptions {
url?: string
fetchImpl?: typeof fetch
@@ -225,6 +238,7 @@ function parseCachedModel(value: unknown): CommandCodeModel {
return {
id,
name: stringField(value, "name"),
+ api: apiForModelId(id),
reasoning: isReasoningModel(id),
contextWindow: positiveNumberField(value, "contextWindow"),
maxTokens: positiveNumberField(value, "maxTokens"),
@@ -329,6 +343,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma
return data.map(parseApiModel).map((model) => ({
id: model.id,
name: `${model.name} (CC)`,
+ api: apiForModelId(model.id),
reasoning: isReasoningModel(model.id),
contextWindow: model.contextLength,
maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS),
diff --git a/src/oauth.ts b/src/oauth.ts
index be77391..0a68ac5 100644
--- a/src/oauth.ts
+++ b/src/oauth.ts
@@ -1,13 +1,13 @@
/**
* Command Code OAuth provider for pi's /login flow.
*
- * Implements a browser-assisted API key retrieval flow:
- * 1. Starts a local HTTP server on a Command Code CLI-compatible port
- * 2. Opens the Command Code Studio auth page in the browser
- * 3. The user authenticates on the Command Code website
- * 4. The website POSTs the API key back to the local server
- * 5. If browser transfer fails, the user can paste the API key manually
- * 6. The API key is stored in pi's auth.json as OAuth credentials
+ * Implements two API key retrieval flows:
+ * 1. Browser-assisted login opens Command Code Studio and waits for the
+ * website to POST the API key back to a local callback server.
+ * 2. Direct API key login prompts the user to paste a Studio API key.
+ *
+ * If browser transfer fails, the user can still paste the API key manually.
+ * The API key is stored in pi's auth.json as OAuth credentials.
*
* Since Command Code API keys don't expire, we store them as
* OAuth credentials with a far-future expiry.
@@ -101,13 +101,35 @@ async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string)
return credentialsFromApiKey(apiKey)
}
-/**
- * Starts the browser-based login flow for Command Code.
- *
- * Returns OAuth credentials where access == refresh == the user's API key.
- * The keys don't expire, so we set a far-future expiry.
- */
-export async function login(callbacks: OAuthLoginCallbacks): Promise {
+type LoginChoice = { type: "browser" } | { type: "prompt" } | { type: "apiKey"; apiKey: string }
+
+async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise {
+ const input = sanitizeApiKey(
+ await callbacks.onPrompt({
+ message:
+ "Command Code login: press Enter for browser login, type 'key' to paste an API key, or paste the API key directly:",
+ }),
+ )
+ const normalized = input.toLowerCase()
+
+ if (!input || normalized === "1" || normalized === "b" || normalized === "browser") {
+ return { type: "browser" }
+ }
+
+ if (
+ normalized === "2" ||
+ normalized === "k" ||
+ normalized === "key" ||
+ normalized === "api" ||
+ normalized === "paste"
+ ) {
+ return { type: "prompt" }
+ }
+
+ return { type: "apiKey", apiKey: input }
+}
+
+async function browserLogin(callbacks: OAuthLoginCallbacks): Promise {
let authServer
try {
authServer = await startAuthServer()
@@ -151,6 +173,23 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise {
+ const choice = await chooseLoginFlow(callbacks)
+
+ if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey)
+ if (choice.type === "prompt") {
+ return promptForApiKey(callbacks, "Paste your Command Code API key:")
+ }
+
+ return browserLogin(callbacks)
+}
+
/**
* Command Code API keys don't expire, so "refresh" is a no-op.
* Returns the same credentials with an updated far-future expiry.
diff --git a/src/pricing.ts b/src/pricing.ts
index aa801c0..0dd9115 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-04"
+export const PRICING_LAST_VERIFIED = "2026-08-20"
export const ZERO_MODEL_COST: CommandCodeModelCost = {
input: 0,
@@ -61,17 +61,18 @@ export const MODEL_COSTS: Readonly> = {
"MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
"MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
"MiniMaxAI/MiniMax-M2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 },
- // Permanent 75% discount.
+ // DeepSeek V4 uses time-dependent rates. Display the documented off-peak
+ // rates, which apply for 17 hours per day; the Usage page remains authoritative.
"deepseek/deepseek-v4-pro": {
- input: 0.435,
- output: 0.87,
- cacheRead: 0.003625,
+ input: 0.66,
+ output: 1.98,
+ cacheRead: 0.022,
cacheWrite: 0,
},
"deepseek/deepseek-v4-flash": {
- input: 0.14,
- output: 0.28,
- cacheRead: 0.0028,
+ 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 },
@@ -158,37 +159,8 @@ export const MODEL_COSTS: Readonly> = {
// OpenAI
"gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
- // Discounted rates through 2026-08-14.
- "gpt-5.6-terra": {
- input: 1,
- output: 6,
- cacheRead: 0.1,
- cacheWrite: 1.25,
- tiers: [
- {
- inputTokensAbove: 272_000,
- input: 2,
- output: 9,
- cacheRead: 0.2,
- cacheWrite: 2.5,
- },
- ],
- },
- "gpt-5.6-luna": {
- input: 0.1,
- output: 0.6,
- cacheRead: 0.01,
- cacheWrite: 0.125,
- tiers: [
- {
- inputTokensAbove: 272_000,
- input: 0.2,
- output: 0.9,
- cacheRead: 0.02,
- cacheWrite: 0.25,
- },
- ],
- },
+ "gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
+ "gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
"gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
"gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 },
@@ -213,11 +185,6 @@ export const MODEL_COSTS: Readonly> = {
}
export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [
- {
- models: ["gpt-5.6-terra", "gpt-5.6-luna"],
- expiresOn: "2026-08-14",
- description: "50% promotional rates",
- },
{
models: ["claude-sonnet-5"],
expiresOn: "2026-08-31",
diff --git a/src/runtime.ts b/src/runtime.ts
index 34c034a..e103f96 100644
--- a/src/runtime.ts
+++ b/src/runtime.ts
@@ -28,11 +28,13 @@ export interface CommandCodeRuntimeOptions {
cachePath: string
loadModels: () => Promise
createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig
+ getTransport?: () => "unknown" | "provider" | "generate"
now?: () => number
logWarning?: (message: string) => void
}
export interface CommandCodeRuntimeStatus {
+ transport: "unknown" | "provider" | "generate"
source: LoadCommandCodeModelsResult["source"]
modelCount: number
lastSuccess?: number
@@ -86,6 +88,7 @@ function formatTimestamp(timestamp: number | undefined): string {
export function formatCommandCodeStatus(status: CommandCodeRuntimeStatus): string {
const lines = [
+ `transport: ${status.transport}`,
`source: ${status.source}`,
`model count: ${status.modelCount}`,
`last success: ${formatTimestamp(status.lastSuccess)}`,
@@ -113,6 +116,7 @@ export class CommandCodeRuntime console.warn(`[commandcode] ${message}`))
const initialStatus: CommandCodeRuntimeStatus = {
+ transport: "unknown",
source: "empty",
modelCount: 0,
cachePath: options.cachePath,
@@ -123,7 +127,10 @@ export class CommandCodeRuntime {
@@ -259,10 +266,8 @@ export class CommandCodeRuntime {
- ctx.ui.notify(
- formatCommandCodeStatus(this.status),
- this.status.warning ? "warning" : "info",
- )
+ const status = this.getStatus()
+ ctx.ui.notify(formatCommandCodeStatus(status), status.warning ? "warning" : "info")
},
})
}
diff --git a/src/transport.ts b/src/transport.ts
new file mode 100644
index 0000000..c30171c
--- /dev/null
+++ b/src/transport.ts
@@ -0,0 +1,140 @@
+import type {
+ AssistantMessageEvent,
+ AssistantMessageEventStreamLike,
+ ContextLike,
+ ModelLike,
+ StreamOptions,
+} from "./types.ts"
+
+export type CommandCodeTransport = "unknown" | "provider" | "generate"
+
+interface TransportDependencies {
+ createStream: () => AssistantMessageEventStreamLike
+ streamProvider: (
+ model: ModelLike,
+ context: ContextLike,
+ options?: StreamOptions,
+ ) => AssistantMessageEventStreamLike
+ streamGenerate: (
+ model: ModelLike,
+ context: ContextLike,
+ options?: StreamOptions,
+ ) => AssistantMessageEventStreamLike
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+async function isUpgradeRequired(response: Response): Promise {
+ if (response.status !== 403) return false
+
+ try {
+ const body: unknown = await response.clone().json()
+ if (!isRecord(body)) return false
+ const error = isRecord(body.error) ? body.error : body
+ return error.code === "upgrade_required"
+ } catch {
+ return false
+ }
+}
+
+export function createCommandCodeTransportRouter(deps: TransportDependencies) {
+ let transport: CommandCodeTransport = "unknown"
+ let apiKey: string | undefined
+
+ function pipe(
+ source: AssistantMessageEventStreamLike,
+ target: AssistantMessageEventStreamLike,
+ ): Promise {
+ return (async () => {
+ for await (const event of source) target.push(event)
+ })()
+ }
+
+ return {
+ getTransport(): CommandCodeTransport {
+ return transport
+ },
+
+ reset(): void {
+ transport = "unknown"
+ apiKey = undefined
+ },
+
+ stream(
+ model: ModelLike,
+ context: ContextLike,
+ options?: StreamOptions,
+ ): AssistantMessageEventStreamLike {
+ if (options?.apiKey !== apiKey) {
+ apiKey = options?.apiKey
+ transport = "unknown"
+ }
+ const requestApiKey = options?.apiKey
+ if (transport === "generate") return deps.streamGenerate(model, context, options)
+
+ const output = deps.createStream()
+ let upgradeRequired = false
+ const fetchImpl = options?.fetch ?? fetch
+ const providerOptions: StreamOptions = {
+ ...options,
+ fetch: async (input, init) => {
+ const response = await fetchImpl(input, init)
+ if (await isUpgradeRequired(response)) upgradeRequired = true
+ return response
+ },
+ onResponse: async (response, responseModel) => {
+ if (upgradeRequired) return
+ await options?.onResponse?.(response, responseModel)
+ },
+ }
+
+ const run = async () => {
+ const providerStream = deps.streamProvider(model, context, providerOptions)
+
+ for await (const event of providerStream) {
+ if (!upgradeRequired) {
+ if (apiKey === requestApiKey) transport = "provider"
+ output.push(event)
+ }
+ }
+
+ if (upgradeRequired) {
+ if (apiKey === requestApiKey) transport = "generate"
+ await pipe(deps.streamGenerate(model, context, options), output)
+ }
+ output.end()
+ }
+
+ run().catch((error: unknown) => {
+ const message = error instanceof Error ? error.message : String(error)
+ output.push({
+ type: "error",
+ reason: "error",
+ error: {
+ role: "assistant",
+ content: [],
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: "error",
+ errorMessage: message,
+ timestamp: Date.now(),
+ },
+ })
+ output.end()
+ })
+
+ return output
+ },
+ }
+}
diff --git a/src/types.ts b/src/types.ts
index c520543..dc680ea 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -110,6 +110,7 @@ export interface StreamOptions {
apiKey?: string
signal?: AbortSignal
headers?: Record
+ fetch?: typeof fetch
maxTokens?: number
/** Resolved pi thinking level; forwarded only through the model's map. */
reasoning?: string
diff --git a/tests/fixtures/commandcode-pricing.json b/tests/fixtures/commandcode-pricing.json
index deaee75..6a508fd 100644
--- a/tests/fixtures/commandcode-pricing.json
+++ b/tests/fixtures/commandcode-pricing.json
@@ -1,5 +1,5 @@
{
- "verifiedAt": "2026-08-04",
+ "verifiedAt": "2026-08-20",
"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,9 +7,7 @@
"Qwen/Qwen3.7-Flash": [
[32000, 0.1, 0.4, 0.02, 0.125],
[256000, 0.2, 0.8, 0.04, 0.25]
- ],
- "gpt-5.6-terra": [[272000, 2, 9, 0.2, 2.5]],
- "gpt-5.6-luna": [[272000, 0.2, 0.9, 0.02, 0.25]]
+ ]
},
"costs": {
"poolside/laguna-s-2.1-free": [0, 0, 0, 0],
@@ -27,8 +25,8 @@
"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.435, 0.87, 0.003625, 0],
- "deepseek/deepseek-v4-flash": [0.14, 0.28, 0.0028, 0],
+ "deepseek/deepseek-v4-pro": [0.66, 1.98, 0.022, 0],
+ "deepseek/deepseek-v4-flash": [0.22, 0.66, 0.007, 0],
"Qwen/Qwen3.8-Max": [2, 6, 0.25, 2.5],
"Qwen/Qwen3.7-Max": [2.5, 7.5, 0.5, 3.13],
"Qwen/Qwen3.7-Plus": [0.4, 1.6, 0.08, 0.5],
@@ -52,8 +50,8 @@
"claude-opus-4-7": [5, 25, 0.5, 6.25],
"claude-haiku-4-5-20251001": [1, 5, 0.1, 1.25],
"gpt-5.6-sol": [5, 30, 0.5, 6.25],
- "gpt-5.6-terra": [1, 6, 0.1, 1.25],
- "gpt-5.6-luna": [0.1, 0.6, 0.01, 0.125],
+ "gpt-5.6-terra": [2, 12, 0.2, 2.5],
+ "gpt-5.6-luna": [0.2, 1.2, 0.02, 0.25],
"gpt-5.5": [5, 30, 0.5, 0],
"gpt-5.4": [2.5, 15, 0.25, 0],
"gpt-5.3-codex": [2, 8, 0.5, 0],
diff --git a/tests/test-api-key.ts b/tests/test-api-key.ts
new file mode 100644
index 0000000..a497978
--- /dev/null
+++ b/tests/test-api-key.ts
@@ -0,0 +1,59 @@
+import assert from "node:assert/strict"
+import { mkdtemp, rm, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { describe, it } from "node:test"
+
+import { getConfiguredApiKey } from "../src/api-key.ts"
+
+async function withAuthFile(
+ value: unknown,
+ run: (authPath: string) => Promise,
+): Promise {
+ const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-auth-"))
+ const authPath = join(directory, "auth.json")
+ try {
+ await writeFile(authPath, JSON.stringify(value), "utf-8")
+ await run(authPath)
+ } finally {
+ await rm(directory, { recursive: true, force: true })
+ }
+}
+
+describe("getConfiguredApiKey()", () => {
+ it("prefers the environment variable", () => {
+ assert.equal(
+ getConfiguredApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }),
+ "env-key",
+ )
+ })
+
+ it("reads pi OAuth and API credentials", async () => {
+ const cases: readonly { credential: unknown; expected: string }[] = [
+ {
+ credential: { commandcode: { type: "oauth", access: "oauth-key" } },
+ expected: "oauth-key",
+ },
+ { credential: { commandcode: { type: "api", key: "api-key" } }, expected: "api-key" },
+ { credential: { "command-code": { type: "api", key: "cli-key" } }, expected: "cli-key" },
+ { credential: { apiKey: "legacy-key" }, expected: "legacy-key" },
+ ]
+
+ for (const testCase of cases) {
+ await withAuthFile(testCase.credential, async (authPath) => {
+ assert.equal(getConfiguredApiKey({ env: {}, authPaths: [authPath] }), testCase.expected)
+ })
+ }
+ })
+
+ it("ignores malformed files", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-auth-"))
+ const authPath = join(directory, "auth.json")
+ try {
+ await writeFile(authPath, "not json", "utf-8")
+ assert.equal(getConfiguredApiKey({ env: {}, authPaths: [authPath] }), undefined)
+ } finally {
+ await rm(directory, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/tests/test-live-e2e.mjs b/tests/test-live-e2e.mjs
index 160882a..88ca636 100644
--- a/tests/test-live-e2e.mjs
+++ b/tests/test-live-e2e.mjs
@@ -25,6 +25,9 @@ import { fileURLToPath } from "node:url"
const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const extensionPath = join(projectDir, "index.ts")
const testModel = process.env.COMMANDCODE_E2E_MODEL ?? "deepseek/deepseek-v4-flash"
+const testProfile = process.env.COMMANDCODE_E2E_PROFILE
+const expectedTransport =
+ testProfile === "go" ? "generate" : testProfile === "provider" ? "provider" : undefined
const marker = "commandcode-live-e2e-ok"
function findPiBinary() {
@@ -57,9 +60,18 @@ if (!piBin || !hasAuthMetadata()) {
process.exit(0)
}
+const profileAgentDir = testProfile
+ ? mkdtempSync(join(tmpdir(), `pi-commandcode-live-${testProfile}-agent-`))
+ : undefined
+
function safeEnv(overrides = {}) {
const env = { ...process.env, PI_SKIP_VERSION_CHECK: "1", ...overrides }
- delete env.COMMANDCODE_API_KEY
+ if (testProfile && profileAgentDir) {
+ env.PI_CODING_AGENT_DIR = profileAgentDir
+ env.COMMANDCODE_MODELS_CACHE = join(profileAgentDir, "commandcode-models.json")
+ } else {
+ delete env.COMMANDCODE_API_KEY
+ }
return env
}
@@ -228,12 +240,22 @@ try {
return { firstThinkingDeltas, secondThinkingDeltas, stderr: getStderr() }
})
- assert.ok(multiTurn.firstThinkingDeltas > 0, "first turn should stream reasoning")
- assert.ok(multiTurn.secondThinkingDeltas > 0, "follow-up turn should stream fresh reasoning")
+ 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.doesNotMatch(multiTurn.stderr, /Bearer\s+\S+/i)
console.log("[live-e2e] live runtime refresh/status commands")
const runtime = await runRpc(extensionPath, async ({ send, waitFor, getStderr }) => {
+ if (expectedTransport) {
+ send({ id: "transport-probe", type: "prompt", message: `Reply exactly: ${marker}` })
+ await waitFor(
+ (event) => event.type === "response" && event.id === "transport-probe" && event.success,
+ )
+ await waitFor((event) => event.type === "agent_settled")
+ }
+
send({ id: "commands", type: "get_commands" })
const commands = await waitFor(
(event) => event.type === "response" && event.id === "commands" && event.success,
@@ -264,6 +286,7 @@ try {
assert.ok(runtime.names.includes("commandcode-refresh"))
assert.ok(runtime.names.includes("commandcode-status"))
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)
@@ -296,30 +319,32 @@ try {
assert.match(toolResult.stdout, new RegExp(marker))
assert.equal(readFileSync(targetPath, "utf-8"), marker)
- console.log("[live-e2e] image rejection through real RPC host")
- const image = await runRpc(extensionPath, async ({ send, waitFor, events }) => {
- send({
- id: "image",
- type: "prompt",
- message: "Describe this image",
- images: [{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }],
+ if (testProfile !== "provider") {
+ console.log("[live-e2e] image rejection through real RPC host")
+ const image = await runRpc(extensionPath, async ({ send, waitFor, events }) => {
+ send({
+ id: "image",
+ type: "prompt",
+ message: "Describe this image",
+ images: [{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }],
+ })
+ await waitFor((event) => event.type === "response" && event.id === "image")
+ await waitFor(
+ (event) =>
+ event.type === "message_end" &&
+ event.message?.role === "assistant" &&
+ event.message?.stopReason === "error",
+ )
+ return events
})
- await waitFor((event) => event.type === "response" && event.id === "image")
- await waitFor(
- (event) =>
- event.type === "message_end" &&
- event.message?.role === "assistant" &&
- event.message?.stopReason === "error",
+ assert.ok(
+ image.some(
+ (event) =>
+ event.type === "message_end" &&
+ /does not support image content/i.test(event.message?.errorMessage ?? ""),
+ ),
)
- return events
- })
- assert.ok(
- image.some(
- (event) =>
- event.type === "message_end" &&
- /does not support image content/i.test(event.message?.errorMessage ?? ""),
- ),
- )
+ }
console.log("[live-e2e] packed artifact with existing authentication")
const packDir = join(tempRoot, "pack")
@@ -361,4 +386,5 @@ try {
console.log("[live-e2e] PASS")
} finally {
rmSync(tempRoot, { recursive: true, force: true })
+ if (profileAgentDir) rmSync(profileAgentDir, { recursive: true, force: true })
}
diff --git a/tests/test-models.ts b/tests/test-models.ts
index af96ebb..3c7e33c 100644
--- a/tests/test-models.ts
+++ b/tests/test-models.ts
@@ -5,6 +5,8 @@ import { join } from "node:path"
import { describe, it } from "node:test"
import {
+ apiForModelId,
+ baseUrlForModel,
commandCodeModelsFromApiResponse,
commandCodeModelsFromCache,
DEFAULT_MODELS_TIMEOUT_MS,
@@ -37,6 +39,7 @@ const EXPECTED_MODELS: readonly CommandCodeModel[] = [
{
id: "Qwen/Qwen3.7-Max",
name: "Qwen 3.7 Max (CC)",
+ api: "openai-completions",
reasoning: false,
contextWindow: 1_000_000,
maxTokens: 65_536,
@@ -84,6 +87,19 @@ describe("commandCodeModelsFromApiResponse()", () => {
assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS)
})
+ it("routes Claude models to Anthropic Messages and all others to Chat Completions", () => {
+ assert.equal(apiForModelId("claude-sonnet-4-6"), "anthropic-messages")
+ assert.equal(apiForModelId("gpt-5.6-sol"), "openai-completions")
+ assert.equal(
+ baseUrlForModel("https://api.commandcode.ai/provider/v1/", "openai-completions"),
+ "https://api.commandcode.ai/provider/v1",
+ )
+ assert.equal(
+ baseUrlForModel("https://api.commandcode.ai/provider/v1/", "anthropic-messages"),
+ "https://api.commandcode.ai/provider",
+ )
+ })
+
it("matches command-code@1.15.1 image input capabilities", () => {
assert.deepEqual(inputModalitiesForModel("gpt-5.6-luna"), ["text", "image"])
assert.deepEqual(inputModalitiesForModel("meta/muse-spark-1.2"), ["text", "image"])
diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts
index 0ff0fab..00d94a0 100644
--- a/tests/test-oauth.ts
+++ b/tests/test-oauth.ts
@@ -186,7 +186,7 @@ describe("login()", () => {
authUrl = params.url
},
onPrompt(_params: { message: string }): Promise {
- throw new Error("onPrompt should not be called in browser flow")
+ return Promise.resolve("")
},
}
@@ -239,7 +239,7 @@ describe("login()", () => {
process.env.COMMANDCODE_AUTH_TIMEOUT_MS = "1"
let authUrl = ""
- let promptMessage = ""
+ const promptMessages: string[] = []
try {
const result = await login({
@@ -247,13 +247,13 @@ describe("login()", () => {
authUrl = params.url
},
async onPrompt(params: { message: string }): Promise {
- promptMessage = params.message
- return "\u001b[200~ user_manualApiKey\n\u001b[201~"
+ 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(promptMessage, /Paste your Command Code API key/)
+ assert.match(promptMessages[1] ?? "", /Paste your Command Code API key/)
assert.equal(result.access, "user_manualApiKey")
assert.equal(result.refresh, "user_manualApiKey")
assert.ok(result.expires > Date.now(), "expiry should be far in the future")
@@ -263,6 +263,37 @@ 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")
+ },
+ })
+
+ assert.equal(authOpened, false)
+ assert.equal(result.access, "user_directApiKey")
+ })
+
+ 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")
+ },
+ })
+
+ assert.equal(result.access, "user_promptedApiKey")
+ assert.equal(promptCount, 2)
+ })
+
it("rejects on state token mismatch", async () => {
let authUrl = ""
const callbacks = {
@@ -270,7 +301,7 @@ describe("login()", () => {
authUrl = params.url
},
onPrompt(_params: { message: string }): Promise {
- throw new Error("should not prompt")
+ return Promise.resolve("")
},
}
diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs
index af7c2bd..9daae39 100644
--- a/tests/test-omp-compat.mjs
+++ b/tests/test-omp-compat.mjs
@@ -77,7 +77,7 @@ const server = createServer((req, res) => {
return
}
- if (req.method !== "POST" || req.url !== "/alpha/generate") {
+ if (req.method !== "POST" || req.url !== "/provider/v1/chat/completions") {
res.writeHead(404)
res.end("Not found")
return
@@ -103,14 +103,19 @@ const server = createServer((req, res) => {
}
res.writeHead(200, {
- "Content-Type": "text/plain; charset=utf-8",
+ "Content-Type": "text/event-stream; charset=utf-8",
"Transfer-Encoding": "chunked",
})
- res.write(`${JSON.stringify({ type: "text-delta", text: "mock-omp-ok" })}\n`)
res.write(
- `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`,
+ `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: "mock-omp-ok" }, finish_reason: null }] })}\n\n`,
)
- res.end()
+ res.write(
+ `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`,
+ )
+ res.write(
+ `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`,
+ )
+ res.end("data: [DONE]\n\n")
})
})
@@ -129,7 +134,7 @@ function runOmp(args, timeoutMs = 30_000) {
USERPROFILE: tempHome,
PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"),
COMMANDCODE_API_KEY: "mock-key",
- COMMANDCODE_API_BASE: apiBase,
+ COMMANDCODE_API_BASE: `${apiBase}/provider/v1`,
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -185,8 +190,8 @@ try {
"Bearer mock-key",
"should send the resolved env-var value, not the literal var name",
)
- assert.equal(lastRequestBody?.params?.model, TEST_MODEL)
- assert.equal(typeof lastRequestBody?.params?.system, "string")
+ assert.equal(lastRequestBody?.model, TEST_MODEL)
+ assert.ok(Array.isArray(lastRequestBody?.messages))
console.log("[omp-compat] PASS")
} finally {
diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs
index 5099d08..2d0a999 100644
--- a/tests/test-pi-local.mjs
+++ b/tests/test-pi-local.mjs
@@ -15,7 +15,8 @@ import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const PROJECT_DIR = resolve(__dirname, "..")
const EXT_PATH = resolve(PROJECT_DIR, "index.ts")
-const TEST_MODEL = "deepseek/deepseek-v4-flash"
+const TEST_MODEL = "gpt-5.4"
+const CLAUDE_TEST_MODEL = "claude-sonnet-4-6"
function findPiBinary() {
if (process.env.PI_BIN) return process.env.PI_BIN
@@ -63,9 +64,17 @@ function modelCatalog() {
object: "model",
created: 1779824324,
owned_by: "command-code",
- name: "DeepSeek V4 Flash",
+ name: "GPT 5.4",
context_length: 1_000_000,
},
+ {
+ id: CLAUDE_TEST_MODEL,
+ object: "model",
+ created: 1779824324,
+ owned_by: "command-code",
+ name: "Claude Sonnet 4.6",
+ context_length: 200_000,
+ },
{
id: "cc-second-model",
object: "model",
@@ -101,7 +110,9 @@ const server = createServer((req, res) => {
return
}
- if (req.method !== "POST" || req.url !== "/alpha/generate") {
+ const isOpenAIRequest = req.method === "POST" && req.url === "/provider/v1/chat/completions"
+ const isAnthropicRequest = req.method === "POST" && req.url === "/provider/v1/messages"
+ if (!isOpenAIRequest && !isAnthropicRequest) {
res.writeHead(404)
res.end("Not found")
return
@@ -129,12 +140,20 @@ const server = createServer((req, res) => {
if (overflowMode && overflowRequestCount === 2) {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" })
- res.end(JSON.stringify({ error: { message: "Input exceeds context limit" } }))
+ res.end(
+ JSON.stringify({
+ error: {
+ message: "Input exceeds context limit",
+ type: "invalid_request_error",
+ code: "context_length_exceeded",
+ },
+ }),
+ )
return
}
res.writeHead(200, {
- "Content-Type": "text/plain; charset=utf-8",
+ "Content-Type": "text/event-stream; charset=utf-8",
"Transfer-Encoding": "chunked",
})
const text = overflowMode
@@ -144,11 +163,36 @@ const server = createServer((req, res) => {
? "compaction-summary"
: "overflow-recovered"
: "mock-pi-ok"
- res.write(`${JSON.stringify({ type: "text-delta", text })}\n`)
+ if (isAnthropicRequest) {
+ res.write(
+ `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "mock", type: "message", role: "assistant", content: [], model: CLAUDE_TEST_MODEL, stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } })}\n\n`,
+ )
+ res.write(
+ `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`,
+ )
+ res.write(
+ `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } })}\n\n`,
+ )
+ res.write(
+ `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`,
+ )
+ res.write(
+ `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } })}\n\n`,
+ )
+ res.end(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`)
+ return
+ }
+
res.write(
- `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`,
+ `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] })}\n\n`,
)
- res.end()
+ res.write(
+ `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`,
+ )
+ res.write(
+ `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`,
+ )
+ res.end("data: [DONE]\n\n")
})
})
@@ -170,8 +214,9 @@ const env = {
USERPROFILE: tempHome,
PI_CODING_AGENT_DIR: agentDir,
PI_CODING_AGENT_SESSION_DIR: join(tempHome, "sessions"),
- COMMANDCODE_API_BASE: apiBase,
+ COMMANDCODE_API_BASE: `${apiBase}/provider/v1`,
COMMANDCODE_API_KEY: "mock-key",
+ COMMANDCODE_ZDR: "1",
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
}
@@ -403,7 +448,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
event.type === "extension_ui_request" &&
event.method === "notify" &&
typeof event.message === "string" &&
- event.message.includes("model count: 2"),
+ event.message.includes("model count: 3"),
)
includeRefreshedModel = true
@@ -414,7 +459,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
event.type === "extension_ui_request" &&
event.method === "notify" &&
typeof event.message === "string" &&
- event.message.includes("3 models from live"),
+ event.message.includes("4 models from live"),
)
send({ id: "status-after", type: "prompt", message: "/commandcode-status" })
@@ -426,7 +471,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
event.type === "extension_ui_request" &&
event.method === "notify" &&
typeof event.message === "string" &&
- event.message.includes("model count: 3"),
+ event.message.includes("model count: 4"),
)
return {
@@ -565,7 +610,7 @@ try {
)
assert.equal(recoveryList.code, 0, recoveryList.stderr)
const recoveryOutput = recoveryList.stdout || recoveryList.stderr
- assert.match(recoveryOutput, /deepseek\/deepseek-v4-flash/)
+ assert.match(recoveryOutput, /gpt-5\.4/)
assert.match(recoveryOutput, /cc-second-model/)
assert.doesNotMatch(recoveryList.stderr, /no valid cached catalog/)
assert.doesNotMatch(recoveryList.stderr, /Failed to load extension/)
@@ -578,7 +623,7 @@ try {
assert.equal(list.code, 0, list.stderr)
const listOutput = list.stdout || list.stderr
assert.match(listOutput, /commandcode/)
- assert.match(listOutput, /deepseek\/deepseek-v4-flash/)
+ assert.match(listOutput, /gpt-5\.4/)
assert.match(listOutput, /cc-second-model/)
assert.equal(modelListRequestCount, 1)
assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK))
@@ -591,7 +636,7 @@ try {
)
assert.equal(offlineList.code, 0, offlineList.stderr)
const offlineListOutput = offlineList.stdout || offlineList.stderr
- assert.match(offlineListOutput, /deepseek\/deepseek-v4-flash/)
+ assert.match(offlineListOutput, /gpt-5\.4/)
assert.match(offlineListOutput, /cc-second-model/)
assert.match(offlineList.stderr, /Using the cached catalog/)
@@ -659,27 +704,55 @@ try {
lastRequestHeaders.authorization.startsWith("Bearer "),
"should send a bearer Authorization header",
)
- assert.equal(lastRequestBody?.params?.model, TEST_MODEL)
- assert.equal(lastRequestBody?.params?.reasoning_effort, "high")
- const sentTools = lastRequestBody?.params?.tools
+ assert.equal(lastRequestHeaders["x-cmd-zdr"], "1")
+ assert.equal(lastRequestBody?.model, TEST_MODEL)
+ assert.equal(lastRequestBody?.reasoning_effort, "high")
+ const sentTools = lastRequestBody?.tools
assert.ok(Array.isArray(sentTools) && sentTools.length > 0)
- const editTool = sentTools.find((tool) => tool.name === "edit")
- assert.equal(editTool?.input_schema?.type, "object")
- assert.equal(editTool?.input_schema?.properties?.edits?.type, "array")
- assert.equal(editTool?.input_schema?.properties?.edits?.items?.type, "object")
+ const editTool = sentTools.find((tool) => tool.function?.name === "edit")
+ assert.equal(editTool?.function?.parameters?.type, "object")
+ assert.equal(editTool?.function?.parameters?.properties?.edits?.type, "array")
+ assert.equal(editTool?.function?.parameters?.properties?.edits?.items?.type, "object")
assert.equal(
- editTool?.input_schema?.properties?.edits?.items?.properties?.oldText?.type,
+ editTool?.function?.parameters?.properties?.edits?.items?.properties?.oldText?.type,
"string",
)
+ console.log("[pi-local] Claude request through Anthropic Messages endpoint")
+ requestCount = 0
+ const claudePrint = await runPi(
+ [
+ "--no-extensions",
+ "-e",
+ EXT_PATH,
+ "-p",
+ "say mock token",
+ "--provider",
+ "commandcode",
+ "--model",
+ CLAUDE_TEST_MODEL,
+ "--thinking",
+ "high",
+ ],
+ 30_000,
+ )
+ assert.equal(claudePrint.code, 0, claudePrint.stderr)
+ assert.match(claudePrint.stdout, /mock-pi-ok/)
+ assert.equal(requestCount, 1)
+ assert.equal(lastRequestBody?.model, CLAUDE_TEST_MODEL)
+ assert.equal(lastRequestBody?.thinking?.type, "adaptive")
+ assert.deepEqual(lastRequestBody?.output_config, { effort: "high" })
+ assert.equal(lastRequestHeaders["x-api-key"], "mock-key")
+ assert.equal(lastRequestHeaders["x-cmd-zdr"], "1")
+
console.log("[pi-local] runtime commands through real RPC extension lifecycle")
includeRefreshedModel = false
const runtimeCommands = await runRpcExtensionCommands()
assert.ok(runtimeCommands.commandNames.includes("commandcode-refresh"))
assert.ok(runtimeCommands.commandNames.includes("commandcode-status"))
assert.match(runtimeCommands.statusBefore, /source: live/)
- assert.match(runtimeCommands.refreshNotification, /3 models from live/)
- assert.match(runtimeCommands.statusAfter, /model count: 3/)
+ assert.match(runtimeCommands.refreshNotification, /4 models from live/)
+ assert.match(runtimeCommands.statusAfter, /model count: 4/)
assert.doesNotMatch(
`${runtimeCommands.statusBefore}\n${runtimeCommands.statusAfter}\n${runtimeCommands.stderr}`,
/mock-key/,
@@ -702,7 +775,7 @@ try {
assert.equal(rpc.sawTextDelta, true)
assert.equal(requestCount, 1)
- console.log("[pi-local] reject image input through real RPC preflight/provider path")
+ console.log("[pi-local] forward image input through the documented provider schema")
requestCount = 0
const imageRpc = await runRpcQuery(10_000, "describe image", [], {
images: [
@@ -713,14 +786,15 @@ try {
},
],
})
- assert.equal(requestCount, 0)
+ assert.equal(imageRpc.ok, true, imageRpc.stderr)
+ assert.equal(requestCount, 1)
+ const imageContent = lastRequestBody?.messages?.find(
+ (message) => message.role === "user",
+ )?.content
+ assert.ok(Array.isArray(imageContent), JSON.stringify(lastRequestBody?.messages))
assert.ok(
- imageRpc.events.some(
- (event) =>
- event.type === "message_end" &&
- event.message?.role === "assistant" &&
- event.message?.stopReason === "error",
- ) || imageRpc.stderr.includes("does not support image"),
+ imageContent.some((part) => part.type === "image_url"),
+ JSON.stringify(imageContent),
)
console.log("[pi-local] verify overflow normalization and compaction recovery")
@@ -729,8 +803,7 @@ try {
const overflowRpc = await runRpcOverflowRecovery()
assert.equal(overflowRpc.ok, true)
assert.ok(overflowRpc.requests >= 4)
- assert.equal(overflowRpc.sawNormalizedOverflow, true)
- assert.equal(overflowRpc.sawCompactionRetry, true)
+ assert.equal(overflowRpc.sawCompactionRetry, true, JSON.stringify(overflowRpc))
assert.equal(overflowRpc.stderrHasSecrets, false)
overflowMode = false
diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts
index a782a73..6e17400 100644
--- a/tests/test-pricing.ts
+++ b/tests/test-pricing.ts
@@ -108,10 +108,16 @@ describe("MODEL_COSTS pricing overlay", () => {
})
it("matches corrected official rates", () => {
+ assertCost("deepseek/deepseek-v4-pro", {
+ input: 0.66,
+ output: 1.98,
+ cacheRead: 0.022,
+ cacheWrite: 0,
+ })
assertCost("deepseek/deepseek-v4-flash", {
- input: 0.14,
- output: 0.28,
- cacheRead: 0.0028,
+ input: 0.22,
+ output: 0.66,
+ cacheRead: 0.007,
cacheWrite: 0,
})
assertCost("Qwen/Qwen3.7-Max", {
@@ -148,16 +154,22 @@ describe("MODEL_COSTS pricing overlay", () => {
cacheWrite: 0.038,
})
assertCost("gpt-5.6-terra", {
- input: 1,
- output: 6,
- cacheRead: 0.1,
- cacheWrite: 1.25,
+ input: 2,
+ output: 12,
+ cacheRead: 0.2,
+ cacheWrite: 2.5,
+ })
+ assertCost("gpt-5.6-luna", {
+ input: 0.2,
+ output: 1.2,
+ cacheRead: 0.02,
+ cacheWrite: 0.25,
})
})
it("tracks pricing provenance", () => {
assert.equal(PRICING_SOURCE_URL, "https://commandcode.ai/docs/resources/pricing-limits")
- assert.equal(PRICING_LAST_VERIFIED, "2026-08-04")
+ assert.equal(PRICING_LAST_VERIFIED, "2026-08-20")
})
it("fails once temporary pricing needs review", () => {
diff --git a/tests/test-runtime.ts b/tests/test-runtime.ts
index c8b0af0..2a89f12 100644
--- a/tests/test-runtime.ts
+++ b/tests/test-runtime.ts
@@ -49,6 +49,7 @@ class CommandContext implements CommandCodeCommandContext {
const FIRST_MODEL: CommandCodeModel = {
id: "first-model",
name: "First Model",
+ api: "openai-completions",
reasoning: true,
contextWindow: 128_000,
maxTokens: 16_384,
@@ -57,6 +58,7 @@ const FIRST_MODEL: CommandCodeModel = {
const SECOND_MODEL: CommandCodeModel = {
id: "second-model",
name: "Second Model",
+ api: "openai-completions",
reasoning: true,
contextWindow: 256_000,
maxTokens: 32_768,
@@ -96,6 +98,7 @@ describe("Command Code runtime", () => {
cachePath: "/tmp/commandcode-models.json",
loadModels: () => firstLoad.promise,
createProviderConfig: (models) => ({ models }),
+ getTransport: () => "provider",
now: () => now,
logWarning: () => {},
})
@@ -113,6 +116,7 @@ describe("Command Code runtime", () => {
assert.ok(statusCommand)
await statusCommand("", context)
const statusMessage = context.notifications.at(-1)?.message ?? ""
+ assert.match(statusMessage, /transport: provider/)
assert.match(statusMessage, /source: live/)
assert.match(statusMessage, /model count: 1/)
assert.match(statusMessage, /last success:/)
diff --git a/tests/test-transport.ts b/tests/test-transport.ts
new file mode 100644
index 0000000..82e9771
--- /dev/null
+++ b/tests/test-transport.ts
@@ -0,0 +1,247 @@
+import assert from "node:assert/strict"
+import { describe, it } from "node:test"
+
+import { createCommandCodeTransportRouter } from "../src/transport.ts"
+import type {
+ AssistantMessageEvent,
+ AssistantMessageEventStreamLike,
+ StreamOptions,
+} from "../src/types.ts"
+import { collectEvents, createTestEventStream, makeContext, makeModel } from "./helpers.ts"
+
+function completedStream(text: string): AssistantMessageEventStreamLike {
+ const stream = createTestEventStream()
+ const model = makeModel()
+ const message = {
+ role: "assistant" as const,
+ content: [{ type: "text" as const, text }],
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: "stop" as const,
+ timestamp: Date.now(),
+ }
+ const events: AssistantMessageEvent[] = [
+ { type: "start", partial: message },
+ { type: "text_start", contentIndex: 0, partial: message },
+ { type: "text_delta", contentIndex: 0, delta: text, partial: message },
+ { type: "text_end", contentIndex: 0, content: text, partial: message },
+ { type: "done", reason: "stop", message },
+ ]
+ for (const event of events) stream.push(event)
+ stream.end()
+ return stream
+}
+
+function providerStream(
+ response: Response,
+ text: string,
+ options?: StreamOptions,
+): AssistantMessageEventStreamLike {
+ const stream = createTestEventStream()
+ const run = async () => {
+ const received = await (options?.fetch ?? fetch)("https://provider.test", {})
+ await options?.onResponse?.(
+ { status: received.status, headers: {} },
+ makeModel({ api: "openai-completions" }),
+ )
+ const source = completedStream(text)
+ for await (const event of source) stream.push(event)
+ stream.end()
+ }
+ run().catch(() => stream.end())
+ return stream
+}
+
+describe("Command Code transport router", () => {
+ it("keeps using the Provider API after a successful request", async () => {
+ let providerCalls = 0
+ let generateCalls = 0
+ const router = createCommandCodeTransportRouter({
+ createStream: createTestEventStream,
+ streamProvider: (_model, _context, options) => {
+ providerCalls += 1
+ return providerStream(new Response("ok", { status: 200 }), "provider", options)
+ },
+ streamGenerate: () => {
+ generateCalls += 1
+ return completedStream("generate")
+ },
+ })
+
+ const options: StreamOptions = {
+ fetch: () => Promise.resolve(new Response("ok", { status: 200 })),
+ }
+ const first = await collectEvents(router.stream(makeModel(), makeContext(), options))
+ const second = await collectEvents(router.stream(makeModel(), makeContext(), options))
+
+ assert.equal(first.at(-1)?.type, "done")
+ assert.equal(second.at(-1)?.type, "done")
+ assert.equal(router.getTransport(), "provider")
+ assert.equal(providerCalls, 2)
+ assert.equal(generateCalls, 0)
+ })
+
+ it("falls back only for 403 upgrade_required and remembers generate", async () => {
+ let providerCalls = 0
+ let generateCalls = 0
+ const responseBody = JSON.stringify({
+ error: { code: "upgrade_required", type: "permission_error" },
+ })
+ const router = createCommandCodeTransportRouter({
+ createStream: createTestEventStream,
+ streamProvider: (_model, _context, options) => {
+ providerCalls += 1
+ return providerStream(new Response(responseBody, { status: 403 }), "blocked", options)
+ },
+ streamGenerate: () => {
+ generateCalls += 1
+ return completedStream("generate")
+ },
+ })
+ const options: StreamOptions = {
+ fetch: () => Promise.resolve(new Response(responseBody, { status: 403 })),
+ }
+
+ const first = await collectEvents(router.stream(makeModel(), makeContext(), options))
+ const second = await collectEvents(router.stream(makeModel(), makeContext(), options))
+
+ assert.equal(first.at(-1)?.type, "done")
+ assert.equal(second.at(-1)?.type, "done")
+ assert.equal(router.getTransport(), "generate")
+ assert.equal(providerCalls, 1)
+ assert.equal(generateCalls, 2)
+ })
+
+ it("re-detects the transport after the API key changes", async () => {
+ let providerCalls = 0
+ let generateCalls = 0
+ const upgradeBody = JSON.stringify({ error: { code: "upgrade_required" } })
+ const router = createCommandCodeTransportRouter({
+ createStream: createTestEventStream,
+ streamProvider: (_model, _context, options) => {
+ providerCalls += 1
+ const response =
+ options?.apiKey === "go-key"
+ ? new Response(upgradeBody, { status: 403 })
+ : new Response("ok", { status: 200 })
+ return providerStream(response, "provider", options)
+ },
+ streamGenerate: () => {
+ generateCalls += 1
+ return completedStream("generate")
+ },
+ })
+
+ await collectEvents(
+ router.stream(makeModel(), makeContext(), {
+ apiKey: "go-key",
+ fetch: () => Promise.resolve(new Response(upgradeBody, { status: 403 })),
+ }),
+ )
+ await collectEvents(
+ router.stream(makeModel(), makeContext(), {
+ apiKey: "provider-key",
+ fetch: () => Promise.resolve(new Response("ok", { status: 200 })),
+ }),
+ )
+
+ assert.equal(router.getTransport(), "provider")
+ assert.equal(providerCalls, 2)
+ assert.equal(generateCalls, 1)
+ })
+
+ it("does not let a stale request overwrite the transport for a new API key", async () => {
+ let releaseGoRequest: (() => void) | undefined
+ const goRequestGate = new Promise((resolve) => {
+ releaseGoRequest = resolve
+ })
+ let providerCalls = 0
+ let generateCalls = 0
+ const upgradeBody = JSON.stringify({ error: { code: "upgrade_required" } })
+ const router = createCommandCodeTransportRouter({
+ createStream: createTestEventStream,
+ streamProvider: (_model, _context, options) => {
+ providerCalls += 1
+ const response =
+ options?.apiKey === "go-key"
+ ? new Response(upgradeBody, { status: 403 })
+ : new Response("ok", { status: 200 })
+ const stream = createTestEventStream()
+ const run = async () => {
+ if (options?.apiKey === "go-key") await goRequestGate
+ const received = await (options?.fetch ?? fetch)("https://provider.test", {})
+ await options?.onResponse?.(
+ { status: received.status, headers: {} },
+ makeModel({ api: "openai-completions" }),
+ )
+ if (response.ok) {
+ for await (const event of completedStream("provider")) stream.push(event)
+ }
+ stream.end()
+ }
+ run().catch(() => stream.end())
+ return stream
+ },
+ streamGenerate: () => {
+ generateCalls += 1
+ return completedStream("generate")
+ },
+ })
+
+ const staleGoRequest = collectEvents(
+ router.stream(makeModel(), makeContext(), {
+ apiKey: "go-key",
+ fetch: () => Promise.resolve(new Response(upgradeBody, { status: 403 })),
+ }),
+ )
+ await collectEvents(
+ router.stream(makeModel(), makeContext(), {
+ apiKey: "provider-key",
+ fetch: () => Promise.resolve(new Response("ok", { status: 200 })),
+ }),
+ )
+ releaseGoRequest?.()
+ await staleGoRequest
+ await collectEvents(
+ router.stream(makeModel(), makeContext(), {
+ apiKey: "provider-key",
+ fetch: () => Promise.resolve(new Response("ok", { status: 200 })),
+ }),
+ )
+
+ assert.equal(router.getTransport(), "provider")
+ assert.equal(providerCalls, 3)
+ assert.equal(generateCalls, 1)
+ })
+
+ it("does not fall back for other 403 errors", async () => {
+ let generateCalls = 0
+ const responseBody = JSON.stringify({ error: { code: "permission_denied" } })
+ const router = createCommandCodeTransportRouter({
+ createStream: createTestEventStream,
+ streamProvider: (_model, _context, options) =>
+ providerStream(new Response(responseBody, { status: 403 }), "blocked", options),
+ streamGenerate: () => {
+ generateCalls += 1
+ return completedStream("generate")
+ },
+ })
+ const options: StreamOptions = {
+ fetch: () => Promise.resolve(new Response(responseBody, { status: 403 })),
+ }
+
+ await collectEvents(router.stream(makeModel(), makeContext(), options))
+
+ assert.equal(router.getTransport(), "provider")
+ assert.equal(generateCalls, 0)
+ })
+})