Merge pull request #49 from patlux/rebuild/issue-5-provider-api
feat(account): Official CommandCode API
This commit is contained in:
+1
-1
@@ -41,5 +41,5 @@ title = "pi-commandcode-provider secret scan"
|
|||||||
[[rules]]
|
[[rules]]
|
||||||
id = "pi-test-api-key"
|
id = "pi-test-api-key"
|
||||||
description = "Test API key value that looks real"
|
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"]
|
tags = ["pi-extension", "test"]
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -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`.
|
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:
|
Before opening a PR, run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -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.
|
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
|
## Install
|
||||||
|
|
||||||
@@ -19,7 +28,7 @@ Start or reload pi, then authenticate:
|
|||||||
/login
|
/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
|
## Oh My Pi
|
||||||
|
|
||||||
@@ -33,9 +42,9 @@ Restart OMP or run `/reload`, then use `/login` and select **Use a subscription*
|
|||||||
|
|
||||||
## Authentication
|
## 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.
|
||||||
|
|
||||||
<img width="1520" height="554" alt="Select Command Code in pi's login dialog" src="https://github.com/user-attachments/assets/071e929a-6f49-4803-bfec-7a31368fb12a" />
|
<img width="1520" height="554" alt="Select Command Code in pi's login dialog" src="https://github.com/user-attachments/assets/071e929a-6f49-4803-bfec-7a31368fb12a" />
|
||||||
|
|
||||||
@@ -84,9 +93,7 @@ Open `/model` and select one of the models provided by Command Code. Model avail
|
|||||||
|
|
||||||
### Reasoning support
|
### 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 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 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.
|
|
||||||
|
|
||||||
List Command Code models from the terminal:
|
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-refresh` fetches and re-registers the current model catalog. Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active.
|
||||||
- `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning.
|
- `/commandcode-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:
|
The following environment variables are intended for tests, local mocks, and compatible API endpoints:
|
||||||
|
|
||||||
- `COMMANDCODE_API_BASE`
|
- `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.
|
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
|
## 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
|
## 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`.
|
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.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup and tests. See [RELEASE.md](RELEASE.md) for the release process.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Command Code provider for pi.
|
* Command Code provider for pi.
|
||||||
*
|
*
|
||||||
* Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate).
|
* Uses Command Code's documented Provider API:
|
||||||
* The provider uses pi's legacy extension registration surface because the
|
* https://api.commandcode.ai/provider/v1
|
||||||
* current pi host exposes `registerProvider(name, config)`, including OMP.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AssistantMessageEventStream } from "@earendil-works/pi-ai"
|
import { AssistantMessageEventStream } from "@earendil-works/pi-ai"
|
||||||
|
import { streamSimple as streamNativeProvider } from "@earendil-works/pi-ai/compat"
|
||||||
import {
|
import {
|
||||||
getAgentDir,
|
getAgentDir,
|
||||||
type ExtensionAPI,
|
type ExtensionAPI,
|
||||||
@@ -15,10 +15,13 @@ import {
|
|||||||
} from "@earendil-works/pi-coding-agent"
|
} from "@earendil-works/pi-coding-agent"
|
||||||
import { join } from "node:path"
|
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 { calculateCommandCodeCost } from "./src/cost.ts"
|
||||||
import {
|
import {
|
||||||
|
baseUrlForModel,
|
||||||
DEFAULT_MODELS_URL,
|
DEFAULT_MODELS_URL,
|
||||||
|
DEFAULT_PROVIDER_API_BASE,
|
||||||
getModelsTimeoutMs,
|
getModelsTimeoutMs,
|
||||||
inputModalitiesForModel,
|
inputModalitiesForModel,
|
||||||
loadCommandCodeModels,
|
loadCommandCodeModels,
|
||||||
@@ -26,68 +29,87 @@ import {
|
|||||||
type CommandCodeModel,
|
type CommandCodeModel,
|
||||||
} from "./src/models.ts"
|
} from "./src/models.ts"
|
||||||
import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.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 { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts"
|
||||||
import { createCommandCodeRuntime } from "./src/runtime.ts"
|
import { createCommandCodeRuntime } from "./src/runtime.ts"
|
||||||
import { normalizeCommandCodeMessage } from "./src/overflow.ts"
|
import { createCommandCodeTransportRouter } from "./src/transport.ts"
|
||||||
|
|
||||||
|
function commandCodeHeaders(): Record<string, string> | undefined {
|
||||||
|
if (process.env.COMMANDCODE_ZDR === "1") {
|
||||||
|
return { "x-cmd-zdr": "1" }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function createProviderConfig(
|
function createProviderConfig(
|
||||||
models: readonly CommandCodeModel[],
|
models: readonly CommandCodeModel[],
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
streamCommandCode: ProviderConfig["streamSimple"],
|
streamCommandCode: ProviderConfig["streamSimple"],
|
||||||
): ProviderConfig {
|
): ProviderConfig {
|
||||||
|
const headers = commandCodeHeaders()
|
||||||
return {
|
return {
|
||||||
name: "Command Code",
|
name: "Command Code",
|
||||||
baseUrl: apiBase,
|
baseUrl: apiBase,
|
||||||
// Keep environment authentication dynamic. OAuth credentials are resolved
|
apiKey: getConfiguredApiKey() ?? "$COMMANDCODE_API_KEY",
|
||||||
// by pi's oauth registration, while the custom stream retains its own
|
api: "openai-completions",
|
||||||
// request-time legacy-file fallback for older compatible hosts.
|
|
||||||
apiKey: "$COMMANDCODE_API_KEY",
|
|
||||||
authHeader: true,
|
|
||||||
api: "commandcode-custom",
|
|
||||||
streamSimple: streamCommandCode,
|
streamSimple: streamCommandCode,
|
||||||
headers: {
|
headers,
|
||||||
"x-command-code-version": COMMAND_CODE_CLI_VERSION,
|
|
||||||
"x-cli-environment": "production",
|
|
||||||
},
|
|
||||||
oauth: {
|
oauth: {
|
||||||
name: "Command Code",
|
name: "Command Code",
|
||||||
login,
|
login,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
getApiKey: getOAuthApiKey,
|
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: {
|
function legacyApiBase(providerApiBase: string): string {
|
||||||
id: string
|
return providerApiBase.replace(/\/provider\/v1\/?$/, "")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function (pi: ExtensionAPI) {
|
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 modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL
|
||||||
const modelsTimeoutMs = getModelsTimeoutMs()
|
const modelsTimeoutMs = getModelsTimeoutMs()
|
||||||
const modelsCachePath =
|
const modelsCachePath =
|
||||||
process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json")
|
process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json")
|
||||||
const streamCommandCode = createStreamCommandCode({
|
const streamGenerate = createStreamCommandCode({
|
||||||
createStream: () => new AssistantMessageEventStream(),
|
createStream: () => new AssistantMessageEventStream(),
|
||||||
calculateCost: calculateCommandCodeCost,
|
calculateCost: calculateCommandCodeCost,
|
||||||
apiBase,
|
apiBase: legacyApiBase(apiBase),
|
||||||
|
})
|
||||||
|
const transport = createCommandCodeTransportRouter({
|
||||||
|
createStream: () => new AssistantMessageEventStream(),
|
||||||
|
streamProvider: streamNativeProvider,
|
||||||
|
streamGenerate,
|
||||||
})
|
})
|
||||||
|
|
||||||
pi.on("message_end", async (event, ctx) => {
|
pi.on("message_end", async (event, ctx) => {
|
||||||
@@ -105,7 +127,8 @@ export default async function (pi: ExtensionAPI) {
|
|||||||
cachePath: modelsCachePath,
|
cachePath: modelsCachePath,
|
||||||
timeoutMs: modelsTimeoutMs,
|
timeoutMs: modelsTimeoutMs,
|
||||||
}),
|
}),
|
||||||
createProviderConfig: (models) => createProviderConfig(models, apiBase, streamCommandCode),
|
createProviderConfig: (models) => createProviderConfig(models, apiBase, transport.stream),
|
||||||
|
getTransport: transport.getTransport,
|
||||||
})
|
})
|
||||||
|
|
||||||
await runtime.initialize()
|
await runtime.initialize()
|
||||||
|
|||||||
+7
-2
@@ -29,13 +29,14 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
|
"format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
|
||||||
"format": "prettier --write '**/*.{ts,mjs,json,md}'",
|
"format": "prettier --write '**/*.{ts,mjs,json,md}'",
|
||||||
"pi:isolated": "node scripts/pi-isolated.mjs",
|
"pi:isolated": "node scripts/pi-isolated.mjs",
|
||||||
"pi:authenticated": "node scripts/pi-authenticated.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:models": "tsx tests/test-models.ts",
|
||||||
"test:runtime": "tsx tests/test-runtime.ts",
|
"test:runtime": "tsx tests/test-runtime.ts",
|
||||||
"test:pricing": "tsx tests/test-pricing.ts",
|
"test:pricing": "tsx tests/test-pricing.ts",
|
||||||
@@ -44,11 +45,15 @@
|
|||||||
"test:overflow": "tsx tests/test-overflow.ts",
|
"test:overflow": "tsx tests/test-overflow.ts",
|
||||||
"test:stream": "tsx tests/test-stream.ts",
|
"test:stream": "tsx tests/test-stream.ts",
|
||||||
"test:retry": "tsx tests/test-retry.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-isolated": "node tests/test-pi-isolated.mjs",
|
||||||
"test:pi-authenticated": "node tests/test-pi-authenticated.mjs",
|
"test:pi-authenticated": "node tests/test-pi-authenticated.mjs",
|
||||||
"test:pi-local": "node tests/test-pi-local.mjs",
|
"test:pi-local": "node tests/test-pi-local.mjs",
|
||||||
"test:smoke": "node tests/test-smoke.mjs",
|
"test:smoke": "node tests/test-smoke.mjs",
|
||||||
"test:e2e:live": "node tests/test-live-e2e.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"
|
"test:cost": "tsx tests/test-cost.ts"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
|
|||||||
@@ -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> [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)
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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
|
||||||
|
}
|
||||||
+16
-1
@@ -1,12 +1,14 @@
|
|||||||
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||||
import { dirname } from "node:path"
|
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
|
export const DEFAULT_MODELS_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
const DEFAULT_MAX_OUTPUT_TOKENS = 65_536
|
const DEFAULT_MAX_OUTPUT_TOKENS = 65_536
|
||||||
const MODEL_CACHE_VERSION = 1
|
const MODEL_CACHE_VERSION = 1
|
||||||
|
|
||||||
|
export type CommandCodeApi = "openai-completions" | "anthropic-messages"
|
||||||
export type CommandCodeInputType = "text" | "image"
|
export type CommandCodeInputType = "text" | "image"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,11 +161,22 @@ interface ApiModel {
|
|||||||
export interface CommandCodeModel {
|
export interface CommandCodeModel {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
api: CommandCodeApi
|
||||||
reasoning: boolean
|
reasoning: boolean
|
||||||
contextWindow: number
|
contextWindow: number
|
||||||
maxTokens: 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 {
|
interface FetchCommandCodeModelsOptions {
|
||||||
url?: string
|
url?: string
|
||||||
fetchImpl?: typeof fetch
|
fetchImpl?: typeof fetch
|
||||||
@@ -225,6 +238,7 @@ function parseCachedModel(value: unknown): CommandCodeModel {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: stringField(value, "name"),
|
name: stringField(value, "name"),
|
||||||
|
api: apiForModelId(id),
|
||||||
reasoning: isReasoningModel(id),
|
reasoning: isReasoningModel(id),
|
||||||
contextWindow: positiveNumberField(value, "contextWindow"),
|
contextWindow: positiveNumberField(value, "contextWindow"),
|
||||||
maxTokens: positiveNumberField(value, "maxTokens"),
|
maxTokens: positiveNumberField(value, "maxTokens"),
|
||||||
@@ -329,6 +343,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma
|
|||||||
return data.map(parseApiModel).map((model) => ({
|
return data.map(parseApiModel).map((model) => ({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
name: `${model.name} (CC)`,
|
name: `${model.name} (CC)`,
|
||||||
|
api: apiForModelId(model.id),
|
||||||
reasoning: isReasoningModel(model.id),
|
reasoning: isReasoningModel(model.id),
|
||||||
contextWindow: model.contextLength,
|
contextWindow: model.contextLength,
|
||||||
maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS),
|
maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS),
|
||||||
|
|||||||
+53
-14
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Command Code OAuth provider for pi's /login flow.
|
* Command Code OAuth provider for pi's /login flow.
|
||||||
*
|
*
|
||||||
* Implements a browser-assisted API key retrieval flow:
|
* Implements two API key retrieval flows:
|
||||||
* 1. Starts a local HTTP server on a Command Code CLI-compatible port
|
* 1. Browser-assisted login opens Command Code Studio and waits for the
|
||||||
* 2. Opens the Command Code Studio auth page in the browser
|
* website to POST the API key back to a local callback server.
|
||||||
* 3. The user authenticates on the Command Code website
|
* 2. Direct API key login prompts the user to paste a Studio API key.
|
||||||
* 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
|
* If browser transfer fails, the user can still paste the API key manually.
|
||||||
* 6. The API key is stored in pi's auth.json as OAuth credentials
|
* 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
|
* Since Command Code API keys don't expire, we store them as
|
||||||
* OAuth credentials with a far-future expiry.
|
* OAuth credentials with a far-future expiry.
|
||||||
@@ -101,13 +101,35 @@ async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string)
|
|||||||
return credentialsFromApiKey(apiKey)
|
return credentialsFromApiKey(apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type LoginChoice = { type: "browser" } | { type: "prompt" } | { type: "apiKey"; apiKey: string }
|
||||||
* Starts the browser-based login flow for Command Code.
|
|
||||||
*
|
async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise<LoginChoice> {
|
||||||
* Returns OAuth credentials where access == refresh == the user's API key.
|
const input = sanitizeApiKey(
|
||||||
* The keys don't expire, so we set a far-future expiry.
|
await callbacks.onPrompt({
|
||||||
*/
|
message:
|
||||||
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
"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<OAuthCredentials> {
|
||||||
let authServer
|
let authServer
|
||||||
try {
|
try {
|
||||||
authServer = await startAuthServer()
|
authServer = await startAuthServer()
|
||||||
@@ -151,6 +173,23 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
|
|||||||
return credentialsFromApiKey(callback.apiKey)
|
return credentialsFromApiKey(callback.apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the login flow for Command Code.
|
||||||
|
*
|
||||||
|
* Returns OAuth credentials where access == refresh == the user's API key.
|
||||||
|
* The keys don't expire, so we set a far-future expiry.
|
||||||
|
*/
|
||||||
|
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||||
|
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.
|
* Command Code API keys don't expire, so "refresh" is a no-op.
|
||||||
* Returns the same credentials with an updated far-future expiry.
|
* Returns the same credentials with an updated far-future expiry.
|
||||||
|
|||||||
+11
-44
@@ -20,7 +20,7 @@ export interface TemporaryPricing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PRICING_SOURCE_URL = "https://commandcode.ai/docs/resources/pricing-limits"
|
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 = {
|
export const ZERO_MODEL_COST: CommandCodeModelCost = {
|
||||||
input: 0,
|
input: 0,
|
||||||
@@ -61,17 +61,18 @@ export const MODEL_COSTS: Readonly<Record<string, CommandCodeModelCost>> = {
|
|||||||
"MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
|
"MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
|
||||||
"MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
|
"MiniMaxAI/MiniMax-M2.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 },
|
"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": {
|
"deepseek/deepseek-v4-pro": {
|
||||||
input: 0.435,
|
input: 0.66,
|
||||||
output: 0.87,
|
output: 1.98,
|
||||||
cacheRead: 0.003625,
|
cacheRead: 0.022,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
"deepseek/deepseek-v4-flash": {
|
"deepseek/deepseek-v4-flash": {
|
||||||
input: 0.14,
|
input: 0.22,
|
||||||
output: 0.28,
|
output: 0.66,
|
||||||
cacheRead: 0.0028,
|
cacheRead: 0.007,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
"Qwen/Qwen3.8-Max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 },
|
"Qwen/Qwen3.8-Max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 },
|
||||||
@@ -158,37 +159,8 @@ export const MODEL_COSTS: Readonly<Record<string, CommandCodeModelCost>> = {
|
|||||||
|
|
||||||
// OpenAI
|
// OpenAI
|
||||||
"gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
|
"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: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
|
||||||
"gpt-5.6-terra": {
|
"gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
|
||||||
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.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
"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.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
||||||
"gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 },
|
"gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 },
|
||||||
@@ -213,11 +185,6 @@ export const MODEL_COSTS: Readonly<Record<string, CommandCodeModelCost>> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [
|
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"],
|
models: ["claude-sonnet-5"],
|
||||||
expiresOn: "2026-08-31",
|
expiresOn: "2026-08-31",
|
||||||
|
|||||||
+10
-5
@@ -28,11 +28,13 @@ export interface CommandCodeRuntimeOptions<TProviderConfig> {
|
|||||||
cachePath: string
|
cachePath: string
|
||||||
loadModels: () => Promise<LoadCommandCodeModelsResult>
|
loadModels: () => Promise<LoadCommandCodeModelsResult>
|
||||||
createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig
|
createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig
|
||||||
|
getTransport?: () => "unknown" | "provider" | "generate"
|
||||||
now?: () => number
|
now?: () => number
|
||||||
logWarning?: (message: string) => void
|
logWarning?: (message: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandCodeRuntimeStatus {
|
export interface CommandCodeRuntimeStatus {
|
||||||
|
transport: "unknown" | "provider" | "generate"
|
||||||
source: LoadCommandCodeModelsResult["source"]
|
source: LoadCommandCodeModelsResult["source"]
|
||||||
modelCount: number
|
modelCount: number
|
||||||
lastSuccess?: number
|
lastSuccess?: number
|
||||||
@@ -86,6 +88,7 @@ function formatTimestamp(timestamp: number | undefined): string {
|
|||||||
|
|
||||||
export function formatCommandCodeStatus(status: CommandCodeRuntimeStatus): string {
|
export function formatCommandCodeStatus(status: CommandCodeRuntimeStatus): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
|
`transport: ${status.transport}`,
|
||||||
`source: ${status.source}`,
|
`source: ${status.source}`,
|
||||||
`model count: ${status.modelCount}`,
|
`model count: ${status.modelCount}`,
|
||||||
`last success: ${formatTimestamp(status.lastSuccess)}`,
|
`last success: ${formatTimestamp(status.lastSuccess)}`,
|
||||||
@@ -113,6 +116,7 @@ export class CommandCodeRuntime<TProviderConfig, TContext extends CommandCodeCom
|
|||||||
this.now = options.now ?? Date.now
|
this.now = options.now ?? Date.now
|
||||||
this.logWarning = options.logWarning ?? ((message) => console.warn(`[commandcode] ${message}`))
|
this.logWarning = options.logWarning ?? ((message) => console.warn(`[commandcode] ${message}`))
|
||||||
const initialStatus: CommandCodeRuntimeStatus = {
|
const initialStatus: CommandCodeRuntimeStatus = {
|
||||||
|
transport: "unknown",
|
||||||
source: "empty",
|
source: "empty",
|
||||||
modelCount: 0,
|
modelCount: 0,
|
||||||
cachePath: options.cachePath,
|
cachePath: options.cachePath,
|
||||||
@@ -123,7 +127,10 @@ export class CommandCodeRuntime<TProviderConfig, TContext extends CommandCodeCom
|
|||||||
}
|
}
|
||||||
|
|
||||||
getStatus(): CommandCodeRuntimeStatus {
|
getStatus(): CommandCodeRuntimeStatus {
|
||||||
return { ...this.status }
|
return {
|
||||||
|
...this.status,
|
||||||
|
transport: this.options.getTransport?.() ?? "unknown",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
@@ -259,10 +266,8 @@ export class CommandCodeRuntime<TProviderConfig, TContext extends CommandCodeCom
|
|||||||
this.pi.registerCommand("commandcode-status", {
|
this.pi.registerCommand("commandcode-status", {
|
||||||
description: "Show redacted Command Code provider diagnostics",
|
description: "Show redacted Command Code provider diagnostics",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
ctx.ui.notify(
|
const status = this.getStatus()
|
||||||
formatCommandCodeStatus(this.status),
|
ctx.ui.notify(formatCommandCodeStatus(status), status.warning ? "warning" : "info")
|
||||||
this.status.warning ? "warning" : "info",
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isUpgradeRequired(response: Response): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ export interface StreamOptions {
|
|||||||
apiKey?: string
|
apiKey?: string
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
fetch?: typeof fetch
|
||||||
maxTokens?: number
|
maxTokens?: number
|
||||||
/** Resolved pi thinking level; forwarded only through the model's map. */
|
/** Resolved pi thinking level; forwarded only through the model's map. */
|
||||||
reasoning?: string
|
reasoning?: string
|
||||||
|
|||||||
+6
-8
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"verifiedAt": "2026-08-04",
|
"verifiedAt": "2026-08-20",
|
||||||
"source": "https://commandcode.ai/docs/resources/pricing-limits",
|
"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.",
|
"tierPolicy": "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.",
|
||||||
"tiers": {
|
"tiers": {
|
||||||
@@ -7,9 +7,7 @@
|
|||||||
"Qwen/Qwen3.7-Flash": [
|
"Qwen/Qwen3.7-Flash": [
|
||||||
[32000, 0.1, 0.4, 0.02, 0.125],
|
[32000, 0.1, 0.4, 0.02, 0.125],
|
||||||
[256000, 0.2, 0.8, 0.04, 0.25]
|
[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": {
|
"costs": {
|
||||||
"poolside/laguna-s-2.1-free": [0, 0, 0, 0],
|
"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-M3": [0.3, 1.2, 0.06, 0],
|
||||||
"MiniMaxAI/MiniMax-M2.7": [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],
|
"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-pro": [0.66, 1.98, 0.022, 0],
|
||||||
"deepseek/deepseek-v4-flash": [0.14, 0.28, 0.0028, 0],
|
"deepseek/deepseek-v4-flash": [0.22, 0.66, 0.007, 0],
|
||||||
"Qwen/Qwen3.8-Max": [2, 6, 0.25, 2.5],
|
"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-Max": [2.5, 7.5, 0.5, 3.13],
|
||||||
"Qwen/Qwen3.7-Plus": [0.4, 1.6, 0.08, 0.5],
|
"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-opus-4-7": [5, 25, 0.5, 6.25],
|
||||||
"claude-haiku-4-5-20251001": [1, 5, 0.1, 1.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-sol": [5, 30, 0.5, 6.25],
|
||||||
"gpt-5.6-terra": [1, 6, 0.1, 1.25],
|
"gpt-5.6-terra": [2, 12, 0.2, 2.5],
|
||||||
"gpt-5.6-luna": [0.1, 0.6, 0.01, 0.125],
|
"gpt-5.6-luna": [0.2, 1.2, 0.02, 0.25],
|
||||||
"gpt-5.5": [5, 30, 0.5, 0],
|
"gpt-5.5": [5, 30, 0.5, 0],
|
||||||
"gpt-5.4": [2.5, 15, 0.25, 0],
|
"gpt-5.4": [2.5, 15, 0.25, 0],
|
||||||
"gpt-5.3-codex": [2, 8, 0.5, 0],
|
"gpt-5.3-codex": [2, 8, 0.5, 0],
|
||||||
|
|||||||
@@ -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<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
+51
-25
@@ -25,6 +25,9 @@ import { fileURLToPath } from "node:url"
|
|||||||
const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
const extensionPath = join(projectDir, "index.ts")
|
const extensionPath = join(projectDir, "index.ts")
|
||||||
const testModel = process.env.COMMANDCODE_E2E_MODEL ?? "deepseek/deepseek-v4-flash"
|
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"
|
const marker = "commandcode-live-e2e-ok"
|
||||||
|
|
||||||
function findPiBinary() {
|
function findPiBinary() {
|
||||||
@@ -57,9 +60,18 @@ if (!piBin || !hasAuthMetadata()) {
|
|||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileAgentDir = testProfile
|
||||||
|
? mkdtempSync(join(tmpdir(), `pi-commandcode-live-${testProfile}-agent-`))
|
||||||
|
: undefined
|
||||||
|
|
||||||
function safeEnv(overrides = {}) {
|
function safeEnv(overrides = {}) {
|
||||||
const env = { ...process.env, PI_SKIP_VERSION_CHECK: "1", ...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
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +240,22 @@ try {
|
|||||||
|
|
||||||
return { firstThinkingDeltas, secondThinkingDeltas, stderr: getStderr() }
|
return { firstThinkingDeltas, secondThinkingDeltas, stderr: getStderr() }
|
||||||
})
|
})
|
||||||
assert.ok(multiTurn.firstThinkingDeltas > 0, "first turn should stream reasoning")
|
if (testProfile !== "provider") {
|
||||||
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)
|
assert.doesNotMatch(multiTurn.stderr, /Bearer\s+\S+/i)
|
||||||
|
|
||||||
console.log("[live-e2e] live runtime refresh/status commands")
|
console.log("[live-e2e] live runtime refresh/status commands")
|
||||||
const runtime = await runRpc(extensionPath, async ({ send, waitFor, getStderr }) => {
|
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" })
|
send({ id: "commands", type: "get_commands" })
|
||||||
const commands = await waitFor(
|
const commands = await waitFor(
|
||||||
(event) => event.type === "response" && event.id === "commands" && event.success,
|
(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-refresh"))
|
||||||
assert.ok(runtime.names.includes("commandcode-status"))
|
assert.ok(runtime.names.includes("commandcode-status"))
|
||||||
assert.match(runtime.refresh, /model catalog (?:refreshed|unchanged)/)
|
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, /source: (?:live|cache)/)
|
||||||
assert.match(runtime.status, /model count: [1-9][0-9]*/)
|
assert.match(runtime.status, /model count: [1-9][0-9]*/)
|
||||||
assert.doesNotMatch(`${runtime.refresh}\n${runtime.status}\n${runtime.stderr}`, /Bearer\s+\S+/i)
|
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.match(toolResult.stdout, new RegExp(marker))
|
||||||
assert.equal(readFileSync(targetPath, "utf-8"), marker)
|
assert.equal(readFileSync(targetPath, "utf-8"), marker)
|
||||||
|
|
||||||
console.log("[live-e2e] image rejection through real RPC host")
|
if (testProfile !== "provider") {
|
||||||
const image = await runRpc(extensionPath, async ({ send, waitFor, events }) => {
|
console.log("[live-e2e] image rejection through real RPC host")
|
||||||
send({
|
const image = await runRpc(extensionPath, async ({ send, waitFor, events }) => {
|
||||||
id: "image",
|
send({
|
||||||
type: "prompt",
|
id: "image",
|
||||||
message: "Describe this image",
|
type: "prompt",
|
||||||
images: [{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }],
|
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")
|
assert.ok(
|
||||||
await waitFor(
|
image.some(
|
||||||
(event) =>
|
(event) =>
|
||||||
event.type === "message_end" &&
|
event.type === "message_end" &&
|
||||||
event.message?.role === "assistant" &&
|
/does not support image content/i.test(event.message?.errorMessage ?? ""),
|
||||||
event.message?.stopReason === "error",
|
),
|
||||||
)
|
)
|
||||||
return events
|
}
|
||||||
})
|
|
||||||
assert.ok(
|
|
||||||
image.some(
|
|
||||||
(event) =>
|
|
||||||
event.type === "message_end" &&
|
|
||||||
/does not support image content/i.test(event.message?.errorMessage ?? ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log("[live-e2e] packed artifact with existing authentication")
|
console.log("[live-e2e] packed artifact with existing authentication")
|
||||||
const packDir = join(tempRoot, "pack")
|
const packDir = join(tempRoot, "pack")
|
||||||
@@ -361,4 +386,5 @@ try {
|
|||||||
console.log("[live-e2e] PASS")
|
console.log("[live-e2e] PASS")
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(tempRoot, { recursive: true, force: true })
|
rmSync(tempRoot, { recursive: true, force: true })
|
||||||
|
if (profileAgentDir) rmSync(profileAgentDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { join } from "node:path"
|
|||||||
import { describe, it } from "node:test"
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
apiForModelId,
|
||||||
|
baseUrlForModel,
|
||||||
commandCodeModelsFromApiResponse,
|
commandCodeModelsFromApiResponse,
|
||||||
commandCodeModelsFromCache,
|
commandCodeModelsFromCache,
|
||||||
DEFAULT_MODELS_TIMEOUT_MS,
|
DEFAULT_MODELS_TIMEOUT_MS,
|
||||||
@@ -37,6 +39,7 @@ const EXPECTED_MODELS: readonly CommandCodeModel[] = [
|
|||||||
{
|
{
|
||||||
id: "Qwen/Qwen3.7-Max",
|
id: "Qwen/Qwen3.7-Max",
|
||||||
name: "Qwen 3.7 Max (CC)",
|
name: "Qwen 3.7 Max (CC)",
|
||||||
|
api: "openai-completions",
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
contextWindow: 1_000_000,
|
contextWindow: 1_000_000,
|
||||||
maxTokens: 65_536,
|
maxTokens: 65_536,
|
||||||
@@ -84,6 +87,19 @@ describe("commandCodeModelsFromApiResponse()", () => {
|
|||||||
assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS)
|
assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("routes Claude models to Anthropic Messages and all others to Chat Completions", () => {
|
||||||
|
assert.equal(apiForModelId("claude-sonnet-4-6"), "anthropic-messages")
|
||||||
|
assert.equal(apiForModelId("gpt-5.6-sol"), "openai-completions")
|
||||||
|
assert.equal(
|
||||||
|
baseUrlForModel("https://api.commandcode.ai/provider/v1/", "openai-completions"),
|
||||||
|
"https://api.commandcode.ai/provider/v1",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
baseUrlForModel("https://api.commandcode.ai/provider/v1/", "anthropic-messages"),
|
||||||
|
"https://api.commandcode.ai/provider",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("matches command-code@1.15.1 image input capabilities", () => {
|
it("matches command-code@1.15.1 image input capabilities", () => {
|
||||||
assert.deepEqual(inputModalitiesForModel("gpt-5.6-luna"), ["text", "image"])
|
assert.deepEqual(inputModalitiesForModel("gpt-5.6-luna"), ["text", "image"])
|
||||||
assert.deepEqual(inputModalitiesForModel("meta/muse-spark-1.2"), ["text", "image"])
|
assert.deepEqual(inputModalitiesForModel("meta/muse-spark-1.2"), ["text", "image"])
|
||||||
|
|||||||
+37
-6
@@ -186,7 +186,7 @@ describe("login()", () => {
|
|||||||
authUrl = params.url
|
authUrl = params.url
|
||||||
},
|
},
|
||||||
onPrompt(_params: { message: string }): Promise<string> {
|
onPrompt(_params: { message: string }): Promise<string> {
|
||||||
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"
|
process.env.COMMANDCODE_AUTH_TIMEOUT_MS = "1"
|
||||||
|
|
||||||
let authUrl = ""
|
let authUrl = ""
|
||||||
let promptMessage = ""
|
const promptMessages: string[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await login({
|
const result = await login({
|
||||||
@@ -247,13 +247,13 @@ describe("login()", () => {
|
|||||||
authUrl = params.url
|
authUrl = params.url
|
||||||
},
|
},
|
||||||
async onPrompt(params: { message: string }): Promise<string> {
|
async onPrompt(params: { message: string }): Promise<string> {
|
||||||
promptMessage = params.message
|
promptMessages.push(params.message)
|
||||||
return "\u001b[200~ user_manualApiKey\n\u001b[201~"
|
return promptMessages.length === 1 ? "" : "\u001b[200~ user_manualApiKey\n\u001b[201~"
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.match(authUrl, /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?/)
|
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.access, "user_manualApiKey")
|
||||||
assert.equal(result.refresh, "user_manualApiKey")
|
assert.equal(result.refresh, "user_manualApiKey")
|
||||||
assert.ok(result.expires > Date.now(), "expiry should be far in the future")
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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("rejects on state token mismatch", async () => {
|
||||||
let authUrl = ""
|
let authUrl = ""
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
@@ -270,7 +301,7 @@ describe("login()", () => {
|
|||||||
authUrl = params.url
|
authUrl = params.url
|
||||||
},
|
},
|
||||||
onPrompt(_params: { message: string }): Promise<string> {
|
onPrompt(_params: { message: string }): Promise<string> {
|
||||||
throw new Error("should not prompt")
|
return Promise.resolve("")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const server = createServer((req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
if (req.method !== "POST" || req.url !== "/provider/v1/chat/completions") {
|
||||||
res.writeHead(404)
|
res.writeHead(404)
|
||||||
res.end("Not found")
|
res.end("Not found")
|
||||||
return
|
return
|
||||||
@@ -103,14 +103,19 @@ const server = createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
})
|
})
|
||||||
res.write(`${JSON.stringify({ type: "text-delta", text: "mock-omp-ok" })}\n`)
|
|
||||||
res.write(
|
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,
|
USERPROFILE: tempHome,
|
||||||
PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"),
|
PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"),
|
||||||
COMMANDCODE_API_KEY: "mock-key",
|
COMMANDCODE_API_KEY: "mock-key",
|
||||||
COMMANDCODE_API_BASE: apiBase,
|
COMMANDCODE_API_BASE: `${apiBase}/provider/v1`,
|
||||||
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
|
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
@@ -185,8 +190,8 @@ try {
|
|||||||
"Bearer mock-key",
|
"Bearer mock-key",
|
||||||
"should send the resolved env-var value, not the literal var name",
|
"should send the resolved env-var value, not the literal var name",
|
||||||
)
|
)
|
||||||
assert.equal(lastRequestBody?.params?.model, TEST_MODEL)
|
assert.equal(lastRequestBody?.model, TEST_MODEL)
|
||||||
assert.equal(typeof lastRequestBody?.params?.system, "string")
|
assert.ok(Array.isArray(lastRequestBody?.messages))
|
||||||
|
|
||||||
console.log("[omp-compat] PASS")
|
console.log("[omp-compat] PASS")
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+108
-35
@@ -15,7 +15,8 @@ import { fileURLToPath } from "node:url"
|
|||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const PROJECT_DIR = resolve(__dirname, "..")
|
const PROJECT_DIR = resolve(__dirname, "..")
|
||||||
const EXT_PATH = resolve(PROJECT_DIR, "index.ts")
|
const EXT_PATH = resolve(PROJECT_DIR, "index.ts")
|
||||||
const TEST_MODEL = "deepseek/deepseek-v4-flash"
|
const TEST_MODEL = "gpt-5.4"
|
||||||
|
const CLAUDE_TEST_MODEL = "claude-sonnet-4-6"
|
||||||
|
|
||||||
function findPiBinary() {
|
function findPiBinary() {
|
||||||
if (process.env.PI_BIN) return process.env.PI_BIN
|
if (process.env.PI_BIN) return process.env.PI_BIN
|
||||||
@@ -63,9 +64,17 @@ function modelCatalog() {
|
|||||||
object: "model",
|
object: "model",
|
||||||
created: 1779824324,
|
created: 1779824324,
|
||||||
owned_by: "command-code",
|
owned_by: "command-code",
|
||||||
name: "DeepSeek V4 Flash",
|
name: "GPT 5.4",
|
||||||
context_length: 1_000_000,
|
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",
|
id: "cc-second-model",
|
||||||
object: "model",
|
object: "model",
|
||||||
@@ -101,7 +110,9 @@ const server = createServer((req, res) => {
|
|||||||
return
|
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.writeHead(404)
|
||||||
res.end("Not found")
|
res.end("Not found")
|
||||||
return
|
return
|
||||||
@@ -129,12 +140,20 @@ const server = createServer((req, res) => {
|
|||||||
|
|
||||||
if (overflowMode && overflowRequestCount === 2) {
|
if (overflowMode && overflowRequestCount === 2) {
|
||||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" })
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
})
|
})
|
||||||
const text = overflowMode
|
const text = overflowMode
|
||||||
@@ -144,11 +163,36 @@ const server = createServer((req, res) => {
|
|||||||
? "compaction-summary"
|
? "compaction-summary"
|
||||||
: "overflow-recovered"
|
: "overflow-recovered"
|
||||||
: "mock-pi-ok"
|
: "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(
|
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,
|
USERPROFILE: tempHome,
|
||||||
PI_CODING_AGENT_DIR: agentDir,
|
PI_CODING_AGENT_DIR: agentDir,
|
||||||
PI_CODING_AGENT_SESSION_DIR: join(tempHome, "sessions"),
|
PI_CODING_AGENT_SESSION_DIR: join(tempHome, "sessions"),
|
||||||
COMMANDCODE_API_BASE: apiBase,
|
COMMANDCODE_API_BASE: `${apiBase}/provider/v1`,
|
||||||
COMMANDCODE_API_KEY: "mock-key",
|
COMMANDCODE_API_KEY: "mock-key",
|
||||||
|
COMMANDCODE_ZDR: "1",
|
||||||
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
|
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +448,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
|
|||||||
event.type === "extension_ui_request" &&
|
event.type === "extension_ui_request" &&
|
||||||
event.method === "notify" &&
|
event.method === "notify" &&
|
||||||
typeof event.message === "string" &&
|
typeof event.message === "string" &&
|
||||||
event.message.includes("model count: 2"),
|
event.message.includes("model count: 3"),
|
||||||
)
|
)
|
||||||
|
|
||||||
includeRefreshedModel = true
|
includeRefreshedModel = true
|
||||||
@@ -414,7 +459,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
|
|||||||
event.type === "extension_ui_request" &&
|
event.type === "extension_ui_request" &&
|
||||||
event.method === "notify" &&
|
event.method === "notify" &&
|
||||||
typeof event.message === "string" &&
|
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" })
|
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.type === "extension_ui_request" &&
|
||||||
event.method === "notify" &&
|
event.method === "notify" &&
|
||||||
typeof event.message === "string" &&
|
typeof event.message === "string" &&
|
||||||
event.message.includes("model count: 3"),
|
event.message.includes("model count: 4"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -565,7 +610,7 @@ try {
|
|||||||
)
|
)
|
||||||
assert.equal(recoveryList.code, 0, recoveryList.stderr)
|
assert.equal(recoveryList.code, 0, recoveryList.stderr)
|
||||||
const recoveryOutput = recoveryList.stdout || 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.match(recoveryOutput, /cc-second-model/)
|
||||||
assert.doesNotMatch(recoveryList.stderr, /no valid cached catalog/)
|
assert.doesNotMatch(recoveryList.stderr, /no valid cached catalog/)
|
||||||
assert.doesNotMatch(recoveryList.stderr, /Failed to load extension/)
|
assert.doesNotMatch(recoveryList.stderr, /Failed to load extension/)
|
||||||
@@ -578,7 +623,7 @@ try {
|
|||||||
assert.equal(list.code, 0, list.stderr)
|
assert.equal(list.code, 0, list.stderr)
|
||||||
const listOutput = list.stdout || list.stderr
|
const listOutput = list.stdout || list.stderr
|
||||||
assert.match(listOutput, /commandcode/)
|
assert.match(listOutput, /commandcode/)
|
||||||
assert.match(listOutput, /deepseek\/deepseek-v4-flash/)
|
assert.match(listOutput, /gpt-5\.4/)
|
||||||
assert.match(listOutput, /cc-second-model/)
|
assert.match(listOutput, /cc-second-model/)
|
||||||
assert.equal(modelListRequestCount, 1)
|
assert.equal(modelListRequestCount, 1)
|
||||||
assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK))
|
assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK))
|
||||||
@@ -591,7 +636,7 @@ try {
|
|||||||
)
|
)
|
||||||
assert.equal(offlineList.code, 0, offlineList.stderr)
|
assert.equal(offlineList.code, 0, offlineList.stderr)
|
||||||
const offlineListOutput = offlineList.stdout || 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(offlineListOutput, /cc-second-model/)
|
||||||
assert.match(offlineList.stderr, /Using the cached catalog/)
|
assert.match(offlineList.stderr, /Using the cached catalog/)
|
||||||
|
|
||||||
@@ -659,27 +704,55 @@ try {
|
|||||||
lastRequestHeaders.authorization.startsWith("Bearer "),
|
lastRequestHeaders.authorization.startsWith("Bearer "),
|
||||||
"should send a bearer Authorization header",
|
"should send a bearer Authorization header",
|
||||||
)
|
)
|
||||||
assert.equal(lastRequestBody?.params?.model, TEST_MODEL)
|
assert.equal(lastRequestHeaders["x-cmd-zdr"], "1")
|
||||||
assert.equal(lastRequestBody?.params?.reasoning_effort, "high")
|
assert.equal(lastRequestBody?.model, TEST_MODEL)
|
||||||
const sentTools = lastRequestBody?.params?.tools
|
assert.equal(lastRequestBody?.reasoning_effort, "high")
|
||||||
|
const sentTools = lastRequestBody?.tools
|
||||||
assert.ok(Array.isArray(sentTools) && sentTools.length > 0)
|
assert.ok(Array.isArray(sentTools) && sentTools.length > 0)
|
||||||
const editTool = sentTools.find((tool) => tool.name === "edit")
|
const editTool = sentTools.find((tool) => tool.function?.name === "edit")
|
||||||
assert.equal(editTool?.input_schema?.type, "object")
|
assert.equal(editTool?.function?.parameters?.type, "object")
|
||||||
assert.equal(editTool?.input_schema?.properties?.edits?.type, "array")
|
assert.equal(editTool?.function?.parameters?.properties?.edits?.type, "array")
|
||||||
assert.equal(editTool?.input_schema?.properties?.edits?.items?.type, "object")
|
assert.equal(editTool?.function?.parameters?.properties?.edits?.items?.type, "object")
|
||||||
assert.equal(
|
assert.equal(
|
||||||
editTool?.input_schema?.properties?.edits?.items?.properties?.oldText?.type,
|
editTool?.function?.parameters?.properties?.edits?.items?.properties?.oldText?.type,
|
||||||
"string",
|
"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")
|
console.log("[pi-local] runtime commands through real RPC extension lifecycle")
|
||||||
includeRefreshedModel = false
|
includeRefreshedModel = false
|
||||||
const runtimeCommands = await runRpcExtensionCommands()
|
const runtimeCommands = await runRpcExtensionCommands()
|
||||||
assert.ok(runtimeCommands.commandNames.includes("commandcode-refresh"))
|
assert.ok(runtimeCommands.commandNames.includes("commandcode-refresh"))
|
||||||
assert.ok(runtimeCommands.commandNames.includes("commandcode-status"))
|
assert.ok(runtimeCommands.commandNames.includes("commandcode-status"))
|
||||||
assert.match(runtimeCommands.statusBefore, /source: live/)
|
assert.match(runtimeCommands.statusBefore, /source: live/)
|
||||||
assert.match(runtimeCommands.refreshNotification, /3 models from live/)
|
assert.match(runtimeCommands.refreshNotification, /4 models from live/)
|
||||||
assert.match(runtimeCommands.statusAfter, /model count: 3/)
|
assert.match(runtimeCommands.statusAfter, /model count: 4/)
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
`${runtimeCommands.statusBefore}\n${runtimeCommands.statusAfter}\n${runtimeCommands.stderr}`,
|
`${runtimeCommands.statusBefore}\n${runtimeCommands.statusAfter}\n${runtimeCommands.stderr}`,
|
||||||
/mock-key/,
|
/mock-key/,
|
||||||
@@ -702,7 +775,7 @@ try {
|
|||||||
assert.equal(rpc.sawTextDelta, true)
|
assert.equal(rpc.sawTextDelta, true)
|
||||||
assert.equal(requestCount, 1)
|
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
|
requestCount = 0
|
||||||
const imageRpc = await runRpcQuery(10_000, "describe image", [], {
|
const imageRpc = await runRpcQuery(10_000, "describe image", [], {
|
||||||
images: [
|
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(
|
assert.ok(
|
||||||
imageRpc.events.some(
|
imageContent.some((part) => part.type === "image_url"),
|
||||||
(event) =>
|
JSON.stringify(imageContent),
|
||||||
event.type === "message_end" &&
|
|
||||||
event.message?.role === "assistant" &&
|
|
||||||
event.message?.stopReason === "error",
|
|
||||||
) || imageRpc.stderr.includes("does not support image"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log("[pi-local] verify overflow normalization and compaction recovery")
|
console.log("[pi-local] verify overflow normalization and compaction recovery")
|
||||||
@@ -729,8 +803,7 @@ try {
|
|||||||
const overflowRpc = await runRpcOverflowRecovery()
|
const overflowRpc = await runRpcOverflowRecovery()
|
||||||
assert.equal(overflowRpc.ok, true)
|
assert.equal(overflowRpc.ok, true)
|
||||||
assert.ok(overflowRpc.requests >= 4)
|
assert.ok(overflowRpc.requests >= 4)
|
||||||
assert.equal(overflowRpc.sawNormalizedOverflow, true)
|
assert.equal(overflowRpc.sawCompactionRetry, true, JSON.stringify(overflowRpc))
|
||||||
assert.equal(overflowRpc.sawCompactionRetry, true)
|
|
||||||
assert.equal(overflowRpc.stderrHasSecrets, false)
|
assert.equal(overflowRpc.stderrHasSecrets, false)
|
||||||
overflowMode = false
|
overflowMode = false
|
||||||
|
|
||||||
|
|||||||
+20
-8
@@ -108,10 +108,16 @@ describe("MODEL_COSTS pricing overlay", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("matches corrected official rates", () => {
|
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", {
|
assertCost("deepseek/deepseek-v4-flash", {
|
||||||
input: 0.14,
|
input: 0.22,
|
||||||
output: 0.28,
|
output: 0.66,
|
||||||
cacheRead: 0.0028,
|
cacheRead: 0.007,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
})
|
})
|
||||||
assertCost("Qwen/Qwen3.7-Max", {
|
assertCost("Qwen/Qwen3.7-Max", {
|
||||||
@@ -148,16 +154,22 @@ describe("MODEL_COSTS pricing overlay", () => {
|
|||||||
cacheWrite: 0.038,
|
cacheWrite: 0.038,
|
||||||
})
|
})
|
||||||
assertCost("gpt-5.6-terra", {
|
assertCost("gpt-5.6-terra", {
|
||||||
input: 1,
|
input: 2,
|
||||||
output: 6,
|
output: 12,
|
||||||
cacheRead: 0.1,
|
cacheRead: 0.2,
|
||||||
cacheWrite: 1.25,
|
cacheWrite: 2.5,
|
||||||
|
})
|
||||||
|
assertCost("gpt-5.6-luna", {
|
||||||
|
input: 0.2,
|
||||||
|
output: 1.2,
|
||||||
|
cacheRead: 0.02,
|
||||||
|
cacheWrite: 0.25,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("tracks pricing provenance", () => {
|
it("tracks pricing provenance", () => {
|
||||||
assert.equal(PRICING_SOURCE_URL, "https://commandcode.ai/docs/resources/pricing-limits")
|
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", () => {
|
it("fails once temporary pricing needs review", () => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class CommandContext implements CommandCodeCommandContext {
|
|||||||
const FIRST_MODEL: CommandCodeModel = {
|
const FIRST_MODEL: CommandCodeModel = {
|
||||||
id: "first-model",
|
id: "first-model",
|
||||||
name: "First Model",
|
name: "First Model",
|
||||||
|
api: "openai-completions",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
contextWindow: 128_000,
|
contextWindow: 128_000,
|
||||||
maxTokens: 16_384,
|
maxTokens: 16_384,
|
||||||
@@ -57,6 +58,7 @@ const FIRST_MODEL: CommandCodeModel = {
|
|||||||
const SECOND_MODEL: CommandCodeModel = {
|
const SECOND_MODEL: CommandCodeModel = {
|
||||||
id: "second-model",
|
id: "second-model",
|
||||||
name: "Second Model",
|
name: "Second Model",
|
||||||
|
api: "openai-completions",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
contextWindow: 256_000,
|
contextWindow: 256_000,
|
||||||
maxTokens: 32_768,
|
maxTokens: 32_768,
|
||||||
@@ -96,6 +98,7 @@ describe("Command Code runtime", () => {
|
|||||||
cachePath: "/tmp/commandcode-models.json",
|
cachePath: "/tmp/commandcode-models.json",
|
||||||
loadModels: () => firstLoad.promise,
|
loadModels: () => firstLoad.promise,
|
||||||
createProviderConfig: (models) => ({ models }),
|
createProviderConfig: (models) => ({ models }),
|
||||||
|
getTransport: () => "provider",
|
||||||
now: () => now,
|
now: () => now,
|
||||||
logWarning: () => {},
|
logWarning: () => {},
|
||||||
})
|
})
|
||||||
@@ -113,6 +116,7 @@ describe("Command Code runtime", () => {
|
|||||||
assert.ok(statusCommand)
|
assert.ok(statusCommand)
|
||||||
await statusCommand("", context)
|
await statusCommand("", context)
|
||||||
const statusMessage = context.notifications.at(-1)?.message ?? ""
|
const statusMessage = context.notifications.at(-1)?.message ?? ""
|
||||||
|
assert.match(statusMessage, /transport: provider/)
|
||||||
assert.match(statusMessage, /source: live/)
|
assert.match(statusMessage, /source: live/)
|
||||||
assert.match(statusMessage, /model count: 1/)
|
assert.match(statusMessage, /model count: 1/)
|
||||||
assert.match(statusMessage, /last success:/)
|
assert.match(statusMessage, /last success:/)
|
||||||
|
|||||||
@@ -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<void>((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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user