diff --git a/.gitleaks.toml b/.gitleaks.toml index e1c2d97..440ef6c 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -41,5 +41,5 @@ title = "pi-commandcode-provider secret scan" [[rules]] id = "pi-test-api-key" description = "Test API key value that looks real" - regex = '''(user_testKey|mock-key|fake-key|test-api-key)''' + regex = '''(user_testKey|mock-key|fake-key)''' tags = ["pi-extension", "test"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca1a66..f8c7b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Switch runtime requests to the official Command Code Provider API endpoints instead of the internal `/alpha/generate` stream. +- Route Claude models through Anthropic Messages and all other Provider API models through OpenAI Chat Completions. +- Support direct API-key login, Command Code CLI auth files, OMP auth files, and optional `COMMANDCODE_ZDR=1` zero-data-retention headers. +- Refresh Provider API model pricing and local pi/OMP smoke tests for the documented API shape. + ## 0.4.1 - 2026-06-16 - Use the explicit `$COMMANDCODE_API_KEY` provider registration syntax expected by newer pi versions, removing the startup deprecation warning while keeping legacy placeholder compatibility. diff --git a/README.md b/README.md index a07c288..1b13f63 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ # pi-commandcode-provider -A [pi](https://github.com/badlogic/pi-mono) custom provider that connects pi to the [Command Code](https://commandcode.ai) API. +A [pi](https://github.com/badlogic/pi-mono) custom provider that connects pi to the official [Command Code Provider API](https://commandcode.ai/docs/provider-api). -> **Disclaimer:** This is an unofficial, community-maintained package. I am not affiliated with, endorsed by, or connected to Command Code in any way. This provider simply forwards requests to the public Command Code API using your own API key. +> **Disclaimer:** This is an unofficial, community-maintained package. I am not affiliated with, endorsed by, or connected to Command Code in any way. This provider forwards requests to Command Code's documented Provider API using your own API key. -> **Note:** This package only provides a model _provider_. It does **not** include an API key. You must bring your own Command Code API key or subscription. +> **Note:** This package only provides a model _provider_. It does **not** include an API key. Command Code's Provider API requires the **Provider plan** or higher. -> 💰 **Current offers:** Command Code offers [4× usage of DeepSeek V4 Pro](https://commandcode.ai/docs/resources/pricing-limits#deepseek-v4-pro-4x-usage) and [2× usage of Qwen 3.7 Max](https://commandcode.ai/docs/resources/pricing-limits#qwen-3.7-max-2x-usage). +> 💰 **Current offers:** Command Code offers [4× usage of DeepSeek V4 Pro](https://commandcode.ai/docs/resources/pricing-limits#deepseek-v4-pro-4x-usage), [2× usage of MiniMax M3](https://commandcode.ai/docs/resources/pricing-limits#minimax-m3-2x-usage), and MiMo price cuts. ## Models -Models are fetched live from Command Code's Provider API at startup, so new models like Qwen 3.7 Max show up without a package release. +Models are fetched live from Command Code's Provider API at startup, so new Provider API models show up without a package release. + +The provider uses the documented endpoints: + +- `GET https://api.commandcode.ai/provider/v1/models` +- `POST https://api.commandcode.ai/provider/v1/chat/completions` for OpenAI-compatible and open-source models +- `POST https://api.commandcode.ai/provider/v1/messages` for Claude models You can list the current Command Code models with: @@ -134,16 +140,24 @@ On startup, the provider fetches: https://api.commandcode.ai/provider/v1/models ``` -For tests or local mocks, override it with `COMMANDCODE_MODELS_URL`. +For tests or local mocks, override it with `COMMANDCODE_MODELS_URL`. Override the request base URL with `COMMANDCODE_API_BASE`. + +## Zero data retention + +Command Code supports zero data retention on the Provider API via `x-cmd-zdr: 1`. To send that header from this provider, start pi with: + +```sh +COMMANDCODE_ZDR=1 pi +``` ## Pricing -Command Code does not yet expose model pricing through its Provider API. The provider ships a static cost table (`MODEL_COSTS` in `index.ts`) for known models so that pi can display per-model pricing. +Command Code does not yet expose model pricing through its Provider API. The provider ships a static cost table (`MODEL_COSTS` in `src/pricing.ts`) for known models so that pi can display per-model pricing. -- Models present in `MODEL_COSTS` show their real per-million-token rates (including promotional deals like the DeepSeek V4 Pro 4× discount and Qwen 3.7 Max 2× discount). +- Models present in `MODEL_COSTS` show their real per-million-token rates, including documented promotional pricing where applicable. - Models **not** in the table fall back to zero cost. When the Provider API adds a `cost` field, the static table can be removed. -To add or update a price, edit the `MODEL_COSTS` record in `index.ts` and update the corresponding test in `tests/test-pricing.ts`. +To add or update a price, edit the `MODEL_COSTS` record in `src/pricing.ts` and update `tests/test-pricing.ts`. ## Contributing diff --git a/index.ts b/index.ts index 1295816..ce53b8c 100644 --- a/index.ts +++ b/index.ts @@ -1,10 +1,11 @@ /** * Command Code provider for pi. * - * Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate). + * Uses the official Command Code Provider API: + * https://api.commandcode.ai/provider/v1 * * Authentication (pick one): - * 1. Run `/login`, then select Command Code — opens browser to commandcode.ai, auto-stores API key + * 1. Run `/login`, then choose browser login or paste a Command Code API key * 2. Set COMMANDCODE_API_KEY environment variable * 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json` * as {"apiKey": "user_..."} or {"commandcode": "user_..."} @@ -12,85 +13,44 @@ * Models are fetched from Command Code's Provider API at startup. */ -import { AssistantMessageEventStream, calculateCost } from "@earendil-works/pi-ai" import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" -import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" -import { DEFAULT_MODELS_URL, fetchCommandCodeModels } from "./src/models.ts" +import { getConfiguredApiKey } from "./src/api-key.ts" +import { + DEFAULT_MODELS_URL, + DEFAULT_PROVIDER_API_BASE, + baseUrlForModel, + fetchCommandCodeModels, +} from "./src/models.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts" +import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" -const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE +const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_PROVIDER_API_BASE const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL +const API_KEY_CONFIG = getConfiguredApiKey() ?? "$COMMANDCODE_API_KEY" +const SUPPORTED_INPUTS: ("text" | "image")[] = ["text", "image"] -type CommandCodeModelCost = { - input: number - output: number - cacheRead: number - cacheWrite: number +function commandCodeHeaders(): Record | undefined { + if (process.env.COMMANDCODE_ZDR === "1") { + return { "x-cmd-zdr": "1" } + } + return undefined } -const ZERO_MODEL_COST: CommandCodeModelCost = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -} - -// The Provider API supplies the current model list. Keep known display pricing -// here until the Provider API exposes prices directly. -const MODEL_COSTS: Record = { - "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "claude-haiku-4-5-20251001": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "google/gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.03, cacheWrite: 0 }, - // 4× usage deal: 75% off (permanent, no expiry) - "deepseek/deepseek-v4-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 }, - "deepseek/deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, - "moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - "zai-org/GLM-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "zai-org/GLM-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, - "MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "MiniMaxAI/MiniMax-M2.5": { input: 0.27, output: 0.95, cacheRead: 0.03, cacheWrite: 0 }, - "Qwen/Qwen3.6-Max-Preview": { input: 1.3, output: 7.8, cacheRead: 0.26, cacheWrite: 1.63 }, - "Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - // 2× usage deal: 50% off through June 22, 2026 - "Qwen/Qwen3.7-Max": { input: 1.25, output: 3.75, cacheRead: 0.25, cacheWrite: 1.56 }, - "stepfun/Step-3.5-Flash": { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 }, - "xiaomi/mimo-v2.5-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "xiaomi/mimo-v2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, -} - -const streamCommandCode = createStreamCommandCode({ - createStream: () => new AssistantMessageEventStream(), - calculateCost, - apiBase: API_BASE, -}) - // --------------------------------------------------------------------------- // Extension entry point // --------------------------------------------------------------------------- export default async function (pi: ExtensionAPI) { const models = await fetchCommandCodeModels({ url: MODELS_URL }) + const headers = commandCodeHeaders() pi.registerProvider("commandcode", { name: "Command Code", baseUrl: API_BASE, - apiKey: "$COMMANDCODE_API_KEY", - authHeader: true, - api: "commandcode-custom", - streamSimple: streamCommandCode, - headers: { - "x-command-code-version": COMMAND_CODE_CLI_VERSION, - "x-cli-environment": "production", - }, + apiKey: API_KEY_CONFIG, + api: "openai-completions", + headers, oauth: { name: "Command Code", login, @@ -100,11 +60,22 @@ export default async function (pi: ExtensionAPI) { models: models.map((model) => ({ id: model.id, name: model.name, + api: model.api, + baseUrl: baseUrlForModel(API_BASE, model.api), reasoning: model.reasoning, - input: ["text"] as const, + input: SUPPORTED_INPUTS, cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST, contextWindow: model.contextWindow, maxTokens: model.maxTokens, + headers, + compat: + model.api === "openai-completions" + ? { + supportsStore: false, + supportsDeveloperRole: false, + maxTokensField: "max_tokens", + } + : undefined, })), }) } diff --git a/package.json b/package.json index 631df41..9b79fc7 100644 --- a/package.json +++ b/package.json @@ -28,19 +28,17 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "npm run typecheck && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-api-key.ts && tsx tests/test-oauth.ts && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", - "test:unit": "tsx tests/test-pure-functions.ts", + "test:unit": "tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-api-key.ts", "test:models": "tsx tests/test-models.ts", "test:pricing": "tsx tests/test-pricing.ts", "test:oauth": "tsx tests/test-oauth.ts", - "test:abort": "tsx tests/test-abort.ts", - "test:stream": "tsx tests/test-stream.ts", - "test:retry": "tsx tests/test-retry.ts", "test:pi-local": "node tests/test-pi-local.mjs", - "test:smoke": "node tests/test-smoke.mjs" + "test:smoke": "node tests/test-smoke.mjs", + "test:api-key": "tsx tests/test-api-key.ts" }, "pi": { "extensions": [ diff --git a/src/api-key.ts b/src/api-key.ts new file mode 100644 index 0000000..0ab52f5 --- /dev/null +++ b/src/api-key.ts @@ -0,0 +1,68 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function defaultAuthPaths(home: string): string[] { + return [ + join(home, ".commandcode", "auth.json"), + join(home, ".pi", "agent", "auth.json"), + join(home, ".omp", "agent", "auth.json"), + ] +} + +function apiKeyFromCredential(value: unknown): string | undefined { + if (!isRecord(value)) return undefined + + if (stringValue(value.type) === "oauth") return stringValue(value.access) + if (stringValue(value.type) === "api") return stringValue(value.key) + return stringValue(value.access) ?? stringValue(value.key) +} + +export function getConfiguredApiKey( + options: { + env?: NodeJS.ProcessEnv + authPaths?: readonly string[] + homeDir?: () => string + } = {}, +): string | undefined { + const env = options.env ?? process.env + if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY + + const home = options.homeDir?.() ?? homedir() + const authPaths = options.authPaths ?? defaultAuthPaths(home) + + for (const authPath of authPaths) { + try { + if (!existsSync(authPath)) continue + const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8")) + if (!isRecord(parsed)) continue + + const apiKey = stringValue(parsed.apiKey) + if (apiKey) return apiKey + + const commandcode = stringValue(parsed.commandcode) + if (commandcode) return commandcode + + const providerKey = apiKeyFromCredential(parsed.commandcode) + if (providerKey) return providerKey + + const commandCode = stringValue(parsed["command-code"]) + if (commandCode) return commandCode + + const commandCodeKey = apiKeyFromCredential(parsed["command-code"]) + if (commandCodeKey) return commandCodeKey + } catch { + // Ignore malformed or unreadable auth files. + } + } + + return undefined +} diff --git a/src/converters.ts b/src/converters.ts deleted file mode 100644 index 38ca0d3..0000000 --- a/src/converters.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" - -import type { MessageLike, StopReason, ToolLike } from "./types.ts" - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -export function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined -} - -function booleanValue(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined -} - -export function recordArray(value: unknown): readonly Record[] { - if (!Array.isArray(value)) return [] - return value.filter(isRecord) -} - -export function recordOrEmpty(value: unknown): Record { - if (isRecord(value)) return value - if (typeof value === "string") { - try { - const parsed: unknown = JSON.parse(value) - if (isRecord(parsed)) return parsed - } catch { - // Some providers stream incomplete JSON argument fragments. - } - } - return {} -} - -export function numberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined -} - -function defaultAuthPaths(home: string): string[] { - return [ - join(home, ".commandcode", "auth.json"), - join(home, ".omp", "agent", "auth.json"), - join(home, ".pi", "agent", "auth.json"), - ] -} - -function apiKeyFromCredentialRecord(value: unknown): string | undefined { - if (!isRecord(value)) return undefined - - const type = stringValue(value.type) - if (type === "api") return stringValue(value.key) - if (type === "oauth") return stringValue(value.access) - - return stringValue(value.key) ?? stringValue(value.access) -} - -export function getApiKey( - 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 - - // Legacy: direct apiKey or commandcode field. - const apiKey = stringValue(parsed.apiKey) - if (apiKey) return apiKey - const commandcode = stringValue(parsed.commandcode) - if (commandcode) return commandcode - - // pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"..."}}. - // The official Command Code CLI stores API credentials under "command-code". - const providerKey = - apiKeyFromCredentialRecord(parsed.commandcode) ?? - apiKeyFromCredentialRecord(parsed["command-code"]) - if (providerKey) return providerKey - } catch { - // Ignore malformed or unreadable auth files. - } - } - - return undefined -} - -export function textContent(message: { content?: unknown }): string { - return recordArray(message.content) - .filter((part) => part.type === "text") - .map((part) => stringValue(part.text) ?? "") - .join("\n") -} - -export function getEnvironmentInfo(): string { - return `${process.platform}-${process.arch}, Node.js ${process.version}` -} - -export function toJsonSchema(schema: unknown): unknown { - if (!isRecord(schema)) return {} - - const kind = stringValue(schema.kind) ?? stringValue(schema.type) - const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined - if (enumValues) { - return { type: typeof enumValues[0], enum: enumValues } - } - - switch (kind) { - case "string": - case "String": - return { type: "string" } - case "number": - case "Number": - return { type: "number" } - case "boolean": - case "Boolean": - return { type: "boolean" } - case "object": - case "Object": { - const properties: Record = {} - const inferredRequired: string[] = [] - const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined - const optional = Array.isArray(schema.optional) - ? schema.optional.filter((item): item is string => typeof item === "string") - : [] - - if (sourceProperties) { - for (const [key, value] of Object.entries(sourceProperties)) { - properties[key] = toJsonSchema(value) - const valueRecord = isRecord(value) ? value : undefined - if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) { - inferredRequired.push(key) - } - } - } - - const explicitRequired = Array.isArray(schema.required) - ? schema.required.filter((item): item is string => typeof item === "string") - : undefined - const required = explicitRequired ?? inferredRequired - const out: Record = { type: "object" } - if (Object.keys(properties).length > 0) out.properties = properties - if (required.length > 0) out.required = required - return out - } - case "array": - case "Array": - return { - type: "array", - items: toJsonSchema(schema.items ?? schema.element), - } - case "union": - case "Union": { - const variants = Array.isArray(schema.variants) - ? schema.variants - : Array.isArray(schema.anyOf) - ? schema.anyOf - : [] - for (const variant of variants) { - const converted = toJsonSchema(variant) - if (isRecord(converted) && Object.keys(converted).length > 0) return converted - } - return {} - } - case "optional": - case "Optional": - return toJsonSchema(schema.wrapped ?? schema.inner) - default: - return {} - } -} - -export function toolsToJson(tools?: readonly ToolLike[]): unknown[] { - if (!tools) return [] - return tools.map((tool) => ({ - type: "function", - name: tool.name, - description: tool.description, - input_schema: tool.parameters ? toJsonSchema(tool.parameters) : {}, - })) -} - -function completeToolCallIds(messages?: readonly MessageLike[]): Set { - const callIds = new Set() - const resultIds = new Set() - - for (const message of messages ?? []) { - if (message.role === "assistant") { - for (const content of recordArray(message.content)) { - if (content.type === "toolCall") { - const id = stringValue(content.id) - if (id) callIds.add(id) - } - } - } else if (message.role === "toolResult") { - if (message.toolCallId) resultIds.add(message.toolCallId) - } - } - - return new Set([...callIds].filter((id) => resultIds.has(id))) -} - -export function messagesToCC(messages?: readonly MessageLike[]): unknown[] { - const out: unknown[] = [] - const pairedToolCallIds = completeToolCallIds(messages) - - for (const message of messages ?? []) { - if (message.role === "user") { - out.push({ - role: "user", - content: typeof message.content === "string" ? message.content : message.content, - }) - } else if (message.role === "assistant") { - const parts: unknown[] = [] - for (const content of recordArray(message.content)) { - if (content.type === "text") { - parts.push({ type: "text", text: stringValue(content.text) ?? "" }) - } else if (content.type === "thinking") { - parts.push({ - type: "reasoning", - text: stringValue(content.thinking) ?? "", - }) - } else if (content.type === "toolCall") { - const toolCallId = stringValue(content.id) ?? "" - if (!pairedToolCallIds.has(toolCallId)) continue - parts.push({ - type: "tool-call", - toolCallId, - toolName: stringValue(content.name) ?? "", - input: recordOrEmpty(content.arguments), - }) - } - } - if (parts.length > 0) out.push({ role: "assistant", content: parts }) - } else if (message.role === "toolResult") { - if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId)) continue - out.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: message.toolCallId, - toolName: message.toolName, - output: message.isError - ? { type: "error-text", value: textContent(message) } - : { type: "text", value: textContent(message) }, - }, - ], - }) - } - } - return out -} - -export function parseStreamEventLine(line: string): unknown | undefined { - let trimmed = line.trim() - if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined - if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim() - if (!trimmed || trimmed === "[DONE]") return undefined - - try { - const parsed: unknown = JSON.parse(trimmed) - return parsed - } catch { - return undefined - } -} - -export function mapFinishReason(reason: unknown): StopReason { - if (reason === "tool-calls") return "toolUse" - if ( - reason === "length" || - reason === "max_tokens" || - reason === "max-tokens" || - reason === "max_output_tokens" - ) { - return "length" - } - return "stop" -} - -function promptPartToText(value: unknown, depth = 0): string { - if (depth > 10) return "" - if (typeof value === "string") return value - if (Array.isArray(value)) - return value - .map((v) => promptPartToText(v, depth + 1)) - .filter(Boolean) - .join("\n") - if (!isRecord(value)) return "" - const text = stringValue(value.text) - if (text) return text - const content = promptPartToText(value.content, depth + 1) - if (content) return content - return "" -} - -export function systemPromptToText(value: unknown): string { - if (value === undefined || value === null) return "" - if (typeof value === "string") return value - if (Array.isArray(value)) - return value - .map((v) => promptPartToText(v, 0)) - .filter(Boolean) - .join("\n\n") - return promptPartToText(value, 0) -} diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index 301ff64..0000000 --- a/src/core.ts +++ /dev/null @@ -1,665 +0,0 @@ -/** - * Testable Command Code provider core. - * - * The runtime imports live in index.ts; this module takes injected stream/cost - * dependencies so tests can exercise the real serialization and stream parser. - */ - -import { randomUUID } from "node:crypto" - -import { - getApiKey, - getEnvironmentInfo, - isRecord, - mapFinishReason, - messagesToCC, - numberValue, - parseStreamEventLine, - recordOrEmpty, - stringValue, - toolsToJson, - systemPromptToText, -} from "./converters.ts" -import type { - AssistantMessageEventStreamLike, - AssistantMessageLike, - ContextLike, - CoreDependencies, - ErrorReason, - ModelLike, - StopReason, - StreamOptions, - TerminalReason, - TextContent, - ToolCallContent, - Usage, -} from "./types.ts" - -export * from "./converters.ts" -export * from "./types.ts" - -export const DEFAULT_API_BASE = "https://api.commandcode.ai" -export const COMMAND_CODE_CLI_VERSION = "0.29.0" - -const DEFAULT_GENERATE_MAX_TOKENS = 64_000 -const DEFAULT_MAX_RETRIES = 0 -const DEFAULT_MAX_RETRY_DELAY_MS = 60_000 -const BASE_RETRY_DELAY_MS = 500 - -function isRetryableStatus(status: number): boolean { - return status === 429 || (status >= 500 && status < 600) -} - -function parseRetryAfterSeconds(value: string | null): number | undefined { - if (!value) return undefined - const seconds = Number(value) - if (Number.isFinite(seconds) && seconds >= 0) return seconds - const date = Date.parse(value) - if (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000) - return undefined -} - -function effectiveMaxRetryDelayMs(value: number | undefined): number { - if (value === undefined) return DEFAULT_MAX_RETRY_DELAY_MS - if (value === 0) return Number.POSITIVE_INFINITY - return value -} - -function retryDelayMs( - attempt: number, - retryAfterHeader: string | null, - maxDelayMs: number, -): number { - const retryAfterMs = parseRetryAfterSeconds(retryAfterHeader) - if (retryAfterMs !== undefined) { - if (retryAfterMs * 1000 > maxDelayMs) return -1 - return retryAfterMs * 1000 - } - const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt - const jitter = exponential * 0.2 * Math.random() - return Math.min(exponential + jitter, maxDelayMs) -} - -function defaultUsage(): Usage { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - } -} - -function commandCodeUsage(event: Record): Record | undefined { - return isRecord(event.totalUsage) ? event.totalUsage : undefined -} - -function commandCodeInputTokenDetails( - usage: Record, -): Record | undefined { - return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined -} - -function headersToRecord(headers: Headers): Record { - const out: Record = {} - headers.forEach((value, key) => { - out[key] = value - }) - return out -} - -function abortError(message = "The operation was aborted"): DOMException { - return new DOMException(message, "AbortError") -} - -function timeoutError(timeoutMs: number | undefined): Error { - return new Error( - timeoutMs === undefined - ? "Command Code API request timed out" - : `Command Code API request timed out after ${timeoutMs}ms`, - ) -} - -function successStopReason(reason: TerminalReason): StopReason { - if (reason === "length" || reason === "toolUse") return reason - return "stop" -} - -function generateMaxTokens(model: ModelLike, options?: StreamOptions): number { - return Math.min( - options?.maxTokens ?? model.maxTokens, - model.maxTokens, - DEFAULT_GENERATE_MAX_TOKENS, - ) -} - -export function projectSlugFromPath(pathName: string): string { - const slug = pathName - .toLowerCase() - .replace(/^[a-z]:/i, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - return slug || "project" -} - -export function createStreamCommandCode(deps: CoreDependencies) { - const apiBase = deps.apiBase ?? DEFAULT_API_BASE - const fetchImpl = deps.fetchImpl ?? fetch - const cwd = deps.cwd ?? (() => process.cwd()) - const now = deps.now ?? (() => Date.now()) - const uuid = deps.uuid ?? (() => randomUUID()) - const delay = - deps.delay ?? - ((ms: number, signal: AbortSignal) => { - if (signal.aborted) return Promise.reject(abortError()) - return new Promise((resolve, reject) => { - const id = setTimeout(() => { - signal.removeEventListener("abort", onAbort) - resolve() - }, ms) - const onAbort = () => { - clearTimeout(id) - reject(abortError()) - } - signal.addEventListener("abort", onAbort, { once: true }) - }) - }) - - function raceAbort(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortError()) - - return new Promise((resolve, reject) => { - const onAbort = () => reject(abortError()) - signal.addEventListener("abort", onAbort, { once: true }) - promise.then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort) - reject(error) - }, - ) - }) - } - - return function streamCommandCode( - model: ModelLike, - context: ContextLike, - options?: StreamOptions, - ): AssistantMessageEventStreamLike { - const stream = deps.createStream() - - async function run() { - // OMP may pass the legacy env-var name "COMMANDCODE_API_KEY" (old pi) - // or "$COMMANDCODE_API_KEY" (new pi) as the apiKey value instead of - // resolving it. Filter out these specific strings. - const LEGACY_API_KEY_REF = "$COMMANDCODE_API_KEY" - const OLD_API_KEY_REF = "COMMANDCODE_API_KEY" - const hostKey = - options?.apiKey && - options.apiKey !== LEGACY_API_KEY_REF && - options.apiKey !== OLD_API_KEY_REF - ? options.apiKey - : undefined - - const apiKey = - hostKey ?? - getApiKey({ - env: deps.env, - authPaths: deps.authPaths, - homeDir: deps.homeDir, - }) - - if (!apiKey) { - const msg: AssistantMessageLike = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: defaultUsage(), - stopReason: "error", - errorMessage: - "No Command Code API key. Run /login and select Command Code, set the COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json", - timestamp: now(), - } - stream.push({ type: "error", reason: "error", error: msg }) - stream.end() - return - } - - const output: AssistantMessageLike = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: defaultUsage(), - stopReason: "stop", - timestamp: now(), - } - - const controller = new AbortController() - let reader: ReadableStreamDefaultReader | undefined - let textBlock: TextContent | undefined - let currentTextIdx = -1 - let thinkingIdx = -1 - let finished = false - - const abortUpstream = () => { - if (!controller.signal.aborted) controller.abort() - try { - reader?.cancel().catch(() => undefined) - } catch { - // Reader cancellation is best-effort. - } - } - - if (options?.signal?.aborted) { - abortUpstream() - } else { - options?.signal?.addEventListener("abort", abortUpstream, { - once: true, - }) - } - - const endTextBlock = () => { - if (!textBlock) return - stream.push({ - type: "text_end", - contentIndex: currentTextIdx, - content: textBlock.text, - partial: output, - }) - textBlock = undefined - currentTextIdx = -1 - } - - const endThinking = () => { - if (thinkingIdx < 0) return - const tc = output.content[thinkingIdx] - if (tc && tc.type === "thinking") { - stream.push({ - type: "thinking_end", - contentIndex: thinkingIdx, - content: (tc as { thinking: string }).thinking, - partial: output, - }) - } - thinkingIdx = -1 - } - - const handleEvent = (event: unknown) => { - if (!isRecord(event)) return - - switch (event.type) { - case "text-delta": { - endThinking() - if (!textBlock) { - textBlock = { type: "text", text: "" } - output.content.push(textBlock) - currentTextIdx = output.content.length - 1 - stream.push({ - type: "text_start", - contentIndex: currentTextIdx, - partial: output, - }) - } - const delta = stringValue(event.text) ?? "" - textBlock.text += delta - stream.push({ - type: "text_delta", - contentIndex: currentTextIdx, - delta, - partial: output, - }) - break - } - - case "reasoning-start": { - endTextBlock() - break - } - - case "reasoning-delta": { - endTextBlock() - const delta = stringValue(event.text) ?? "" - if (thinkingIdx < 0) { - output.content.push({ type: "thinking", thinking: delta }) - thinkingIdx = output.content.length - 1 - stream.push({ - type: "thinking_start", - contentIndex: thinkingIdx, - partial: output, - }) - } else { - const tc = output.content[thinkingIdx] - if (tc && tc.type === "thinking") { - ;(tc as { thinking: string }).thinking += delta - } - } - stream.push({ - type: "thinking_delta", - contentIndex: thinkingIdx, - delta, - partial: output, - }) - break - } - - case "reasoning-end": { - endThinking() - break - } - - case "tool-result": { - break - } - - case "tool-call": { - endTextBlock() - endThinking() - const toolCall: ToolCallContent = { - type: "toolCall", - id: stringValue(event.toolCallId) ?? "", - name: stringValue(event.toolName) ?? "", - arguments: recordOrEmpty(event.input ?? event.args ?? event.arguments), - } - output.content.push(toolCall) - const idx = output.content.length - 1 - stream.push({ - type: "toolcall_start", - contentIndex: idx, - partial: output, - }) - stream.push({ - type: "toolcall_end", - contentIndex: idx, - toolCall, - partial: output, - }) - break - } - - case "finish": { - const usage = commandCodeUsage(event) - if (usage) { - const details = commandCodeInputTokenDetails(usage) - output.usage.input = numberValue(usage.inputTokens) ?? 0 - output.usage.output = numberValue(usage.outputTokens) ?? 0 - output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0 - output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0 - output.usage.totalTokens = - output.usage.input + - output.usage.output + - output.usage.cacheRead + - output.usage.cacheWrite - deps.calculateCost(model, output.usage) - } - output.stopReason = mapFinishReason(event.finishReason) - finished = true - break - } - - case "error": { - const errorRecord = isRecord(event.error) ? event.error : undefined - const message = - stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error" - output.stopReason = "error" - output.errorMessage = message - throw new Error(message) - } - } - } - - try { - stream.push({ type: "start", partial: output }) - - const workingDir = cwd() - const threadId = uuid() - - let body: unknown = { - config: { - workingDir, - date: new Date(now()).toISOString().split("T")[0], - environment: getEnvironmentInfo(), - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], - }, - memory: null, - taste: null, - skills: null, - params: { - model: model.id, - messages: messagesToCC(context.messages), - tools: toolsToJson(context.tools), - system: systemPromptToText(context.systemPrompt), - max_tokens: generateMaxTokens(model, options), - temperature: 0.3, - stream: true, - }, - threadId, - } - - const nextBody = await raceAbort( - Promise.resolve(options?.onPayload?.(body, model)), - controller.signal, - ) - if (nextBody !== undefined) body = nextBody - - const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES - const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs) - const timeoutMs = options?.timeoutMs - const requestHeaders = { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "x-command-code-version": COMMAND_CODE_CLI_VERSION, - "x-cli-environment": "production", - "x-project-slug": projectSlugFromPath(workingDir), - "x-taste-learning": "true", - "x-co-flag": "false", - ...options?.headers, - } - const bodyStr = JSON.stringify(body) - - let response!: Response - retryLoop: for (let attempt = 0; ; attempt++) { - const attemptController = new AbortController() - let attemptTimedOut = false - let attemptTimeoutId: ReturnType | undefined - - const clearAttemptTimeout = () => { - if (attemptTimeoutId !== undefined) { - clearTimeout(attemptTimeoutId) - attemptTimeoutId = undefined - } - } - - if (timeoutMs !== undefined) { - attemptTimeoutId = setTimeout(() => { - attemptTimedOut = true - attemptController.abort() - }, timeoutMs) - } - const onOuterAbort = () => attemptController.abort() - controller.signal.addEventListener("abort", onOuterAbort, { once: true }) - - try { - try { - response = await fetchImpl(`${apiBase}/alpha/generate`, { - method: "POST", - headers: requestHeaders, - body: bodyStr, - signal: attemptController.signal, - }) - } catch (fetchError: unknown) { - if (controller.signal.aborted) throw abortError("Aborted") - if (attemptTimedOut) { - if (attempt < maxRetries) continue retryLoop - throw timeoutError(timeoutMs) - } - throw fetchError - } - - // --- HTTP-level retry --- - if (!response.ok && isRetryableStatus(response.status)) { - const retryAfter = response.headers.get("retry-after") - const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs) - if (waitMs < 0) { - const requestedSeconds = parseRetryAfterSeconds(retryAfter) ?? 0 - const capLabel = - maxRetryDelayMs === Number.POSITIVE_INFINITY ? "disabled" : `${maxRetryDelayMs}ms` - throw new Error(`Retry-After delay ${requestedSeconds}s exceeds max ${capLabel}`) - } - if (attempt < maxRetries) { - await response.text().catch(() => "") - if (waitMs > 0) await delay(waitMs, controller.signal) - continue retryLoop - } - } - - await raceAbort( - Promise.resolve( - options?.onResponse?.( - { - status: response.status, - headers: headersToRecord(response.headers), - }, - model, - ), - ), - controller.signal, - ) - - if (!response.ok) { - const errBody = await raceAbort( - response.text().catch(() => ""), - controller.signal, - ) - throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`) - } - - // --- Read response stream --- - reader = response.body?.getReader() - if (!reader) throw new Error("No response body") - - const decoder = new TextDecoder() - let buffer = "" - - try { - readLoop: for (;;) { - if (controller.signal.aborted) throw abortError("Aborted") - const { done, value } = await raceAbort(reader.read(), attemptController.signal) - if (done) { - if (buffer.trim()) handleEvent(parseStreamEventLine(buffer)) - break - } - if (controller.signal.aborted) throw abortError("Aborted") - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - - for (const line of lines) { - if (controller.signal.aborted) throw abortError("Aborted") - handleEvent(parseStreamEventLine(line)) - if (finished) break readLoop - } - } - } catch (streamError: unknown) { - // Stream-level error (e.g. API returned 200 OK but sent an error event) - // or per-attempt timeout during stream reading. - await reader.cancel().catch(() => {}) - try { - reader.releaseLock() - } catch {} - reader = undefined - - if (controller.signal.aborted) throw streamError - - // Never retry after visible content was emitted (including timeout mid-stream). - const canRetry = output.content.length === 0 && attempt < maxRetries - if (canRetry) { - output.content.length = 0 - textBlock = undefined - currentTextIdx = -1 - thinkingIdx = -1 - output.stopReason = "stop" - output.errorMessage = undefined - finished = false - const waitMs = attemptTimedOut ? 0 : retryDelayMs(attempt, null, maxRetryDelayMs) - if (waitMs > 0) await delay(waitMs, controller.signal) - continue retryLoop - } - if (attemptTimedOut) throw timeoutError(timeoutMs) - throw streamError - } - - // Stream completed successfully. - endTextBlock() - endThinking() - - stream.push({ - type: "done", - reason: successStopReason(output.stopReason), - message: output, - }) - stream.end() - break retryLoop - } finally { - controller.signal.removeEventListener("abort", onOuterAbort) - clearAttemptTimeout() - } - } - } catch (error: unknown) { - const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error" - output.stopReason = reason - output.errorMessage = - reason === "aborted" - ? "Request aborted" - : error instanceof Error - ? error.message - : String(error) - stream.push({ type: "error", reason, error: output }) - stream.end() - } finally { - options?.signal?.removeEventListener("abort", abortUpstream) - try { - await reader?.cancel() - } catch { - // Reader may already be closed/cancelled. - } - try { - reader?.releaseLock() - } catch { - // Reader may already be released/cancelled by the abort path. - } - } - } - - run().catch((error: unknown) => { - const msg: AssistantMessageLike = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: defaultUsage(), - stopReason: "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: now(), - } - stream.push({ type: "error", reason: "error", error: msg }) - stream.end() - }) - - return stream - } -} diff --git a/src/models.ts b/src/models.ts index c4be26f..d037bc6 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,4 +1,7 @@ -export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" +import type { Api } from "@earendil-works/pi-ai" + +export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1" +export const DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models` const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 @@ -11,6 +14,7 @@ interface ApiModel { export interface CommandCodeModel { id: string name: string + api: Api reasoning: boolean contextWindow: number maxTokens: number @@ -47,6 +51,17 @@ function parseApiModel(value: unknown): ApiModel { } } +export function apiForModelId(id: string): Api { + if (id.startsWith("claude-")) return "anthropic-messages" + return "openai-completions" +} + +export function baseUrlForModel(apiBase: string, api: Api): string { + const normalized = apiBase.replace(/\/+$/g, "") + if (api !== "anthropic-messages") return normalized + return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized +} + export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { if (!isRecord(value)) throw new Error("Expected models response to be an object") if (value.object !== "list") throw new Error("Expected models response object to be 'list'") @@ -57,6 +72,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma return data.map(parseApiModel).map((model) => ({ id: model.id, name: `${model.name} (CC)`, + api: apiForModelId(model.id), reasoning: true, contextWindow: model.contextLength, maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS), diff --git a/src/oauth.ts b/src/oauth.ts index be77391..c775138 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,13 +1,13 @@ /** * Command Code OAuth provider for pi's /login flow. * - * Implements a browser-assisted API key retrieval flow: - * 1. Starts a local HTTP server on a Command Code CLI-compatible port - * 2. Opens the Command Code Studio auth page in the browser - * 3. The user authenticates on the Command Code website - * 4. The website POSTs the API key back to the local server - * 5. If browser transfer fails, the user can paste the API key manually - * 6. The API key is stored in pi's auth.json as OAuth credentials + * Implements two API key retrieval flows: + * 1. Browser-assisted login: opens Command Code Studio and waits for the + * website to POST the API key back to a local callback server. + * 2. Direct API key login: prompts the user to paste a Command Code Studio API key. + * + * If browser transfer fails, the user can still paste the API key manually. + * The API key is stored in pi's auth.json as OAuth credentials. * * Since Command Code API keys don't expire, we store them as * OAuth credentials with a far-future expiry. @@ -101,13 +101,35 @@ async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) return credentialsFromApiKey(apiKey) } -/** - * Starts the browser-based login flow for Command Code. - * - * Returns OAuth credentials where access == refresh == the user's API key. - * The keys don't expire, so we set a far-future expiry. - */ -export async function login(callbacks: OAuthLoginCallbacks): Promise { +type LoginChoice = { type: "browser" } | { type: "prompt" } | { type: "apiKey"; apiKey: string } + +async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise { + const input = sanitizeApiKey( + await callbacks.onPrompt({ + message: + "Command Code login: press Enter for browser login, type 'key' to paste an API key, or paste the API key directly:", + }), + ) + const normalized = input.toLowerCase() + + if (!input || normalized === "1" || normalized === "b" || normalized === "browser") { + return { type: "browser" } + } + + if ( + normalized === "2" || + normalized === "k" || + normalized === "key" || + normalized === "api" || + normalized === "paste" + ) { + return { type: "prompt" } + } + + return { type: "apiKey", apiKey: input } +} + +async function browserLogin(callbacks: OAuthLoginCallbacks): Promise { let authServer try { authServer = await startAuthServer() @@ -151,6 +173,23 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise { + const choice = await chooseLoginFlow(callbacks) + + if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey) + if (choice.type === "prompt") { + return promptForApiKey(callbacks, "Paste your Command Code API key:") + } + + return browserLogin(callbacks) +} + /** * Command Code API keys don't expire, so "refresh" is a no-op. * Returns the same credentials with an updated far-future expiry. diff --git a/src/pricing.ts b/src/pricing.ts new file mode 100644 index 0000000..c4cb265 --- /dev/null +++ b/src/pricing.ts @@ -0,0 +1,71 @@ +export type CommandCodeModelCost = { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +export const ZERO_MODEL_COST: CommandCodeModelCost = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +} + +// Display pricing only. The Provider API bills server-side and does not expose +// prices in /provider/v1/models. Values are per 1M tokens from: +// https://commandcode.ai/docs/resources/pricing-limits +export const MODEL_COSTS: Record = { + "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + // Introductory pricing through August 31, 2026. + "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-haiku-4-5-20251001": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, + "gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, + "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, + "google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, + "google/gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.03, cacheWrite: 0 }, + "sakana/fugu-ultra": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + + // 4× usage deal: 75% off, permanent. + "deepseek/deepseek-v4-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 }, + "deepseek/deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + "moonshotai/Kimi-K2.7-Code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, + "moonshotai/Kimi-K2.7-Code-Highspeed": { + input: 1.9, + output: 8, + cacheRead: 0.38, + cacheWrite: 0, + }, + "moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, + "moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, + "zai-org/GLM-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + "zai-org/GLM-5.2-Fast": { input: 3, output: 10.25, cacheRead: 0.5, cacheWrite: 0 }, + "zai-org/GLM-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + "zai-org/GLM-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, + // 2× usage deal on context <=512K, permanent. + "MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + "MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + "MiniMaxAI/MiniMax-M2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 }, + "Qwen/Qwen3.6-Max-Preview": { input: 1.3, output: 7.8, cacheRead: 0.26, cacheWrite: 1.63 }, + "Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 }, + // 2× usage deal: 50% off. + "Qwen/Qwen3.7-Max": { input: 1.25, output: 3.75, cacheRead: 0.25, cacheWrite: 1.56 }, + "Qwen/Qwen3.7-Plus": { input: 0.4, output: 1.6, cacheRead: 0.08, cacheWrite: 0.5 }, + "stepfun/Step-3.7-Flash": { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 }, + "stepfun/Step-3.5-Flash": { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 }, + // Permanent MiMo price cuts. + "xiaomi/mimo-v2.5-pro": { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }, + "xiaomi/mimo-v2.5": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + "nvidia/nemotron-3-ultra-550b-a55b": { + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: 0, + }, +} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 7604e2c..0000000 --- a/src/types.ts +++ /dev/null @@ -1,175 +0,0 @@ -export type StopReason = "stop" | "length" | "toolUse" -export type ErrorReason = "error" | "aborted" -export type TerminalReason = StopReason | ErrorReason - -export interface UsageCost { - input: number - output: number - cacheRead: number - cacheWrite: number - total: number -} - -export interface Usage { - input: number - output: number - cacheRead: number - cacheWrite: number - totalTokens: number - cost: UsageCost -} - -export interface TextContent { - type: "text" - text: string -} - -export interface ThinkingContent { - type: "thinking" - thinking: string -} - -export interface ToolCallContent { - type: "toolCall" - id: string - name: string - arguments: Record -} - -export type AssistantContent = TextContent | ThinkingContent | ToolCallContent - -export interface AssistantMessageLike { - role: "assistant" - content: AssistantContent[] - api: unknown - provider: string - model: string - usage: Usage - stopReason: TerminalReason - errorMessage?: string - timestamp: number -} - -export interface ModelLike { - id: string - api: unknown - provider: string - maxTokens: number -} - -export interface MessageLike { - role: string - content?: unknown - toolCallId?: string - toolName?: string - isError?: boolean -} - -export interface ToolLike { - name: string - description?: string - parameters?: unknown -} - -export interface ContextLike { - systemPrompt?: string - messages?: readonly MessageLike[] - tools?: readonly ToolLike[] -} - -export interface ProviderResponseInfo { - status: number - headers: Record -} - -export interface StreamOptions { - apiKey?: string - signal?: AbortSignal - headers?: Record - maxTokens?: number - onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise - onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise - /** - * HTTP request timeout in milliseconds. - * Applied per-attempt; on timeout the request is retried if retries remain. - */ - timeoutMs?: number - /** - * Maximum retry attempts for transient HTTP errors (429, 5xx). - * Default: 0 (pi agent-level retry handles visible retries when unset). - */ - maxRetries?: number - /** - * Maximum delay in milliseconds to wait for a retry when the server requests - * a long wait via Retry-After. If the server's requested delay exceeds this - * value, the request fails immediately. Default: 60000 (60 seconds). - * Set to 0 to disable the cap. - */ - maxRetryDelayMs?: number -} - -export type AssistantMessageEvent = - | { type: "start"; partial: AssistantMessageLike } - | { type: "text_start"; contentIndex: number; partial: AssistantMessageLike } - | { - type: "text_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "text_end" - contentIndex: number - content: string - partial: AssistantMessageLike - } - | { - type: "thinking_start" - contentIndex: number - partial: AssistantMessageLike - } - | { - type: "thinking_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "thinking_end" - contentIndex: number - content: string - partial: AssistantMessageLike - } - | { - type: "toolcall_start" - contentIndex: number - partial: AssistantMessageLike - } - | { - type: "toolcall_end" - contentIndex: number - toolCall: ToolCallContent - partial: AssistantMessageLike - } - | { type: "done"; reason: StopReason; message: AssistantMessageLike } - | { type: "error"; reason: ErrorReason; error: AssistantMessageLike } - -export interface AssistantMessageEventStreamLike extends AsyncIterable { - push(event: AssistantMessageEvent): void - end(): void -} - -export interface CoreDependencies { - createStream: () => AssistantMessageEventStreamLike - calculateCost: (model: ModelLike, usage: Usage) => void - apiBase?: string - fetchImpl?: typeof fetch - authPaths?: readonly string[] - env?: NodeJS.ProcessEnv - cwd?: () => string - now?: () => number - uuid?: () => string - homeDir?: () => string - /** Injectable delay for retry backoff. Defaults to setTimeout. */ - delay?: (ms: number, signal: AbortSignal) => Promise -} diff --git a/tests/helpers.ts b/tests/helpers.ts deleted file mode 100644 index bba0a6c..0000000 --- a/tests/helpers.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { createServer, type IncomingHttpHeaders, type Server } from "node:http" - -import { - createStreamCommandCode, - type AssistantMessageEvent, - type AssistantMessageEventStreamLike, - type ContextLike, - type CoreDependencies, - type ModelLike, - type Usage, -} from "../src/core.ts" - -export function createTestEventStream(): AssistantMessageEventStreamLike { - const events: AssistantMessageEvent[] = [] - const waiters: Array<() => void> = [] - let ended = false - - const wake = () => { - const waiter = waiters.shift() - if (waiter) waiter() - } - - return { - push(event: AssistantMessageEvent) { - events.push(event) - wake() - }, - end() { - ended = true - while (waiters.length > 0) wake() - }, - [Symbol.asyncIterator]() { - let index = 0 - return { - async next(): Promise> { - while (index >= events.length && !ended) { - await new Promise((resolve) => waiters.push(resolve)) - } - if (index < events.length) { - const value = events[index] - index += 1 - return { done: false, value } - } - return { done: true, value: undefined } - }, - } - }, - } -} - -export async function collectEvents( - stream: AssistantMessageEventStreamLike, - timeoutMs = 2_000, -): Promise { - const events: AssistantMessageEvent[] = [] - - const collect = async () => { - for await (const event of stream) { - events.push(event) - if (event.type === "done" || event.type === "error") break - } - return events - } - - return await Promise.race([ - collect(), - new Promise((_, reject) => { - setTimeout( - () => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), - timeoutMs, - ) - }), - ]) -} - -export function makeModel(overrides: Partial = {}): ModelLike { - return { - id: "deepseek/deepseek-v4-flash", - api: "commandcode-custom", - provider: "commandcode", - maxTokens: 384_000, - ...overrides, - } -} - -export function makeContext(overrides: Partial = {}): ContextLike { - return { - systemPrompt: "You are a test assistant.", - messages: [{ role: "user", content: "hello" }], - tools: [], - ...overrides, - } -} - -export interface TestDepsResult { - streamCommandCode: ReturnType - calculatedUsages: Usage[] -} - -export function createTestDeps(overrides: Partial = {}): TestDepsResult { - const calculatedUsages: Usage[] = [] - const streamCommandCode = createStreamCommandCode({ - createStream: createTestEventStream, - calculateCost: (_model, usage) => { - calculatedUsages.push({ - ...usage, - cost: { ...usage.cost }, - }) - }, - env: {}, - authPaths: [], - now: () => new Date("2026-05-05T12:00:00Z").getTime(), - uuid: () => "00000000-0000-4000-8000-000000000000", - cwd: () => "/repo", - delay: async () => {}, - ...overrides, - }) - return { streamCommandCode, calculatedUsages } -} - -type SuccessPlan = { - type: "success" - status?: number - events?: string[] - chunks?: string[] - delays?: number[] - hangAfterLast?: boolean - /** Delay in ms before the server starts sending the response. */ - responseDelay?: number -} - -type ErrorPlan = { - type: "error" - status: number - body: string - headers?: Record -} - -export type ResponsePlan = SuccessPlan | ErrorPlan - -function headersToRecord(headers: IncomingHttpHeaders): Record { - const out: Record = {} - for (const [key, value] of Object.entries(headers)) { - if (typeof value === "string") out[key] = value - else if (Array.isArray(value)) out[key] = value.join(", ") - } - return out -} - -export interface MockCommandCodeServer { - baseUrl(): string - mockResponse(plan: ResponsePlan): void - mockResponseQueue(plans: ResponsePlan[]): void - reset(): void - close(): Promise - lastRequestBody(): unknown - lastRequestHeaders(): Record - requestCount(): number - responseClosedBeforeEnd(): boolean -} - -export async function startMockCommandCodeServer(): Promise { - let planQueue: ResponsePlan[] = [{ type: "success", events: [] }] - let lastBody: unknown - let lastHeaders: Record = {} - let requests = 0 - let closedBeforeEnd = false - let port = 0 - - const server: Server = createServer((req, res) => { - if (req.method !== "POST" || req.url !== "/alpha/generate") { - res.writeHead(404) - res.end("Not found") - return - } - - requests += 1 - lastHeaders = headersToRecord(req.headers) - let body = "" - req.on("data", (chunk: Buffer) => { - body += chunk.toString("utf-8") - }) - req.on("end", () => { - try { - const parsed: unknown = JSON.parse(body) - lastBody = parsed - } catch { - lastBody = undefined - } - - // Pop the first plan from the queue; keep the last one as fallback. - const plan = planQueue.length > 1 ? planQueue.shift()! : planQueue[0] - - if (plan.type === "error") { - const headers: Record = { "Content-Type": "text/plain", ...plan.headers } - res.writeHead(plan.status, headers) - res.end(plan.body) - return - } - - res.writeHead(plan.status ?? 200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", - }) - - let ended = false - res.on("close", () => { - if (!ended) closedBeforeEnd = true - }) - - const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`) - const delays = plan.delays ?? chunks.map(() => 0) - let index = 0 - - const sendNext = () => { - if (index >= chunks.length) { - if (!plan.hangAfterLast) { - ended = true - res.end() - } - return - } - - res.write(chunks[index]) - index += 1 - if (index < chunks.length) { - setTimeout(sendNext, delays[index] ?? 0) - } else if (!plan.hangAfterLast) { - ended = true - res.end() - } - } - - if (plan.responseDelay) { - setTimeout(sendNext, plan.responseDelay) - } else { - sendNext() - } - }) - }) - - await new Promise((resolve) => { - server.listen(0, () => { - const address = server.address() - if (typeof address === "object" && address) port = address.port - resolve() - }) - }) - - return { - baseUrl: () => `http://127.0.0.1:${port}`, - mockResponse(plan: ResponsePlan) { - planQueue = [plan] - }, - mockResponseQueue(plans: ResponsePlan[]) { - planQueue = [...plans] - }, - reset() { - planQueue = [{ type: "success", events: [] }] - lastBody = undefined - lastHeaders = {} - requests = 0 - closedBeforeEnd = false - }, - close() { - return new Promise((resolve) => server.close(() => resolve())) - }, - lastRequestBody: () => lastBody, - lastRequestHeaders: () => lastHeaders, - requestCount: () => requests, - responseClosedBeforeEnd: () => closedBeforeEnd, - } -} - -export function objectAt(value: unknown, path: readonly string[]): unknown { - let current = value - for (const key of path) { - if (Array.isArray(current)) { - const index = Number(key) - if (!Number.isInteger(index)) return undefined - current = current[index] - continue - } - if (typeof current !== "object" || current === null) return undefined - current = Object.getOwnPropertyDescriptor(current, key)?.value - } - return current -} diff --git a/tests/test-abort.ts b/tests/test-abort.ts deleted file mode 100644 index 8db0ad5..0000000 --- a/tests/test-abort.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Abort tests against the real streamCommandCode core. - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -describe("streamCommandCode — abort behavior", () => { - it("emits aborted error when signal is already aborted", async () => { - const controller = new AbortController() - controller.abort() - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - }), - ) - - assert.deepEqual( - events.map((event) => event.type), - ["start", "error"], - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - assert.equal(error.error.stopReason, "aborted") - assert.equal(server.requestCount(), 0) - }) - - it("emits aborted error and cancels the response reader mid-stream", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "text-delta", text: "first" })], - hangAfterLast: true, - }) - const controller = new AbortController() - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const stream = streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - }) - - setTimeout(() => controller.abort(), 50) - const events = await collectEvents(stream, 2_000) - - assert.ok( - events.some((event) => event.type === "text_delta"), - "stream should process data before abort", - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - assert.equal(error.error.errorMessage, "Request aborted") - await new Promise((resolve) => setTimeout(resolve, 50)) - assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response") - }) -}) diff --git a/tests/test-api-key.ts b/tests/test-api-key.ts new file mode 100644 index 0000000..7f1f5cb --- /dev/null +++ b/tests/test-api-key.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, it } from "node:test" + +import { getConfiguredApiKey } from "../src/api-key.ts" + +describe("getConfiguredApiKey()", () => { + it("uses COMMANDCODE_API_KEY from provided env", () => { + assert.equal( + getConfiguredApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), + "env-key", + ) + }) + + it("reads supported auth file shapes from explicit auth paths", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) + try { + const first = join(dir, "first.json") + const second = join(dir, "second.json") + const oauth = join(dir, "oauth.json") + const cli = join(dir, "cli.json") + writeFileSync(first, JSON.stringify({ apiKey: "file-key" })) + writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })) + writeFileSync( + oauth, + JSON.stringify({ + commandcode: { + type: "oauth", + access: "oauth-access-key", + refresh: "oauth-refresh-key", + expires: Date.now() + 3600000, + }, + }), + ) + writeFileSync( + cli, + JSON.stringify({ + "command-code": { + type: "api", + key: "cli-api-key", + }, + }), + ) + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [first, second] }), "file-key") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [second] }), "fallback-key") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [cli] }), "cli-api-key") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("ignores malformed auth files", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-")) + try { + const bad = join(dir, "bad.json") + writeFileSync(bad, "not json") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [bad] }), undefined) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("uses injected homeDir for default pi and OMP auth paths", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-home-")) + try { + const piAuthDir = join(dir, ".pi", "agent") + const ompAuthDir = join(dir, ".omp", "agent") + mkdirSync(piAuthDir, { recursive: true }) + mkdirSync(ompAuthDir, { recursive: true }) + writeFileSync(join(ompAuthDir, "auth.json"), JSON.stringify({ commandcode: "omp-key" })) + assert.equal(getConfiguredApiKey({ env: {}, homeDir: () => dir }), "omp-key") + + writeFileSync(join(piAuthDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" })) + assert.equal(getConfiguredApiKey({ env: {}, homeDir: () => dir }), "pi-key") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/test-models.ts b/tests/test-models.ts index 15ee3fd..a2f75c0 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -1,7 +1,28 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { commandCodeModelsFromApiResponse } from "../src/models.ts" +import { apiForModelId, baseUrlForModel, commandCodeModelsFromApiResponse } from "../src/models.ts" + +describe("apiForModelId()", () => { + it("routes Claude models to Anthropic Messages and other models to OpenAI Chat Completions", () => { + assert.equal(apiForModelId("claude-sonnet-4-6"), "anthropic-messages") + assert.equal(apiForModelId("Qwen/Qwen3.7-Max"), "openai-completions") + assert.equal(apiForModelId("deepseek/deepseek-v4-flash"), "openai-completions") + }) +}) + +describe("baseUrlForModel()", () => { + it("uses the Provider API /v1 base for OpenAI-compatible models and strips /v1 for Anthropic SDK models", () => { + 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", + ) + }) +}) describe("commandCodeModelsFromApiResponse()", () => { it("converts the Provider API model list to pi models", () => { @@ -23,6 +44,7 @@ describe("commandCodeModelsFromApiResponse()", () => { { id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max (CC)", + api: "openai-completions", reasoning: true, contextWindow: 1_000_000, maxTokens: 65_536, diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts index 0ff0fab..62c0123 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -185,8 +185,8 @@ describe("login()", () => { onAuth(params: { url: string }) { authUrl = params.url }, - onPrompt(_params: { message: string }): Promise { - throw new Error("onPrompt should not be called in browser flow") + async onPrompt(_params: { message: string }): Promise { + return "" }, } @@ -248,6 +248,7 @@ describe("login()", () => { }, async onPrompt(params: { message: string }): Promise { promptMessage = params.message + if (promptMessage.includes("press Enter for browser login")) return "" return "\u001b[200~ user_manualApiKey\n\u001b[201~" }, }) @@ -263,14 +264,48 @@ describe("login()", () => { } }) + it("accepts a pasted API key without starting browser login", async () => { + let authOpened = false + const result = await login({ + onAuth(_params: { url: string }) { + authOpened = true + }, + async onPrompt(_params: { message: string }): Promise { + return "\u001b[200~ user_directApiKey\n\u001b[201~" + }, + }) + + assert.equal(authOpened, false) + assert.equal(result.access, "user_directApiKey") + assert.equal(result.refresh, "user_directApiKey") + assert.ok(result.expires > Date.now(), "expiry should be far in the future") + }) + + it("can explicitly choose the API key prompt", async () => { + const prompts: string[] = [] + const result = await login({ + onAuth(_params: { url: string }) { + throw new Error("onAuth should not be called for direct API key flow") + }, + async onPrompt(params: { message: string }): Promise { + prompts.push(params.message) + return prompts.length === 1 ? "key" : "user_promptedApiKey" + }, + }) + + assert.equal(prompts.length, 2) + assert.equal(result.access, "user_promptedApiKey") + assert.equal(result.refresh, "user_promptedApiKey") + }) + it("rejects on state token mismatch", async () => { let authUrl = "" const callbacks = { onAuth(params: { url: string }) { authUrl = params.url }, - onPrompt(_params: { message: string }): Promise { - throw new Error("should not prompt") + async onPrompt(_params: { message: string }): Promise { + return "" }, } diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs index b591675..53a2515 100644 --- a/tests/test-omp-compat.mjs +++ b/tests/test-omp-compat.mjs @@ -77,7 +77,7 @@ const server = createServer((req, res) => { return } - if (req.method !== "POST" || req.url !== "/alpha/generate") { + if (req.method !== "POST" || req.url !== "/provider/v1/chat/completions") { res.writeHead(404) res.end("Not found") return @@ -103,13 +103,32 @@ const server = createServer((req, res) => { } res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", }) - res.write(`${JSON.stringify({ type: "text-delta", text: "mock-omp-ok" })}\n`) res.write( - `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`, + `data: ${JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: 0, + model: TEST_MODEL, + choices: [ + { index: 0, delta: { role: "assistant", content: "mock-omp-ok" }, finish_reason: null }, + ], + })}\n\n`, + ) + res.write( + `data: ${JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: 0, + model: TEST_MODEL, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + })}\n\n`, ) + res.write("data: [DONE]\n\n") res.end() }) }) @@ -129,7 +148,7 @@ function runOmp(args, timeoutMs = 30_000) { USERPROFILE: tempHome, PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"), COMMANDCODE_API_KEY: "mock-key", - COMMANDCODE_API_BASE: apiBase, + COMMANDCODE_API_BASE: `${apiBase}/provider/v1`, COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, }, stdio: ["ignore", "pipe", "pipe"], @@ -182,8 +201,12 @@ try { "Bearer mock-key", "should send the resolved env-var value, not the literal var name", ) - assert.equal(lastRequestBody?.params?.model, TEST_MODEL) - assert.equal(typeof lastRequestBody?.params?.system, "string") + assert.equal(lastRequestBody?.model, TEST_MODEL) + assert.equal(lastRequestBody?.stream, true) + assert.ok( + lastRequestBody?.messages?.some((message) => message.role === "system"), + "should send the system prompt as an OpenAI-compatible system message", + ) console.log("[omp-compat] PASS") } finally { diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 617025a..8f2753c 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -6,17 +6,9 @@ import assert from "node:assert/strict" import { spawn, spawnSync } from "node:child_process" -import { - accessSync, - constants, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from "node:fs" +import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { createServer } from "node:http" -import { homedir, tmpdir } from "node:os" +import { tmpdir } from "node:os" import { delimiter, dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" @@ -24,6 +16,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const PROJECT_DIR = resolve(__dirname, "..") const EXT_PATH = resolve(PROJECT_DIR, "index.ts") const TEST_MODEL = "deepseek/deepseek-v4-flash" +const TEST_CLAUDE_MODEL = "claude-sonnet-4-6" function findPiBinary() { if (process.env.PI_BIN) return process.env.PI_BIN @@ -56,6 +49,7 @@ if (piCheck.error) { } let requestCount = 0 +let anthropicRequestCount = 0 let modelListRequestCount = 0 let lastRequestBody let lastRequestHeaders = {} @@ -84,13 +78,94 @@ const server = createServer((req, res) => { name: "Qwen 3.7 Max", context_length: 1_000_000, }, + { + id: TEST_CLAUDE_MODEL, + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "Claude Sonnet 4.6", + context_length: 1_000_000, + }, ], }), ) return } - if (req.method !== "POST" || req.url !== "/alpha/generate") { + if (req.method === "POST" && req.url === "/provider/v1/messages") { + anthropicRequestCount += 1 + lastRequestHeaders = Object.fromEntries( + Object.entries(req.headers).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(", ") : (value ?? ""), + ]), + ) + + let body = "" + req.on("data", (chunk) => { + body += chunk.toString("utf-8") + }) + req.on("end", () => { + try { + lastRequestBody = JSON.parse(body) + } catch { + lastRequestBody = undefined + } + + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }) + res.write( + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg-mock", + type: "message", + role: "assistant", + content: [], + model: TEST_CLAUDE_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: "mock-claude-ok" }, + })}\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.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`) + res.end() + }) + return + } + + if (req.method !== "POST" || req.url !== "/provider/v1/chat/completions") { res.writeHead(404) res.end("Not found") return @@ -116,13 +191,32 @@ const server = createServer((req, res) => { } res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", }) - res.write(`${JSON.stringify({ type: "text-delta", text: "mock-pi-ok" })}\n`) res.write( - `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`, + `data: ${JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: 0, + model: TEST_MODEL, + choices: [ + { index: 0, delta: { role: "assistant", content: "mock-pi-ok" }, finish_reason: null }, + ], + })}\n\n`, ) + res.write( + `data: ${JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: 0, + model: TEST_MODEL, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + })}\n\n`, + ) + res.write("data: [DONE]\n\n") res.end() }) }) @@ -132,34 +226,20 @@ const address = server.address() const port = typeof address === "object" && address ? address.port : 0 const apiBase = `http://127.0.0.1:${port}` -function hasLivePiAuth() { - return ( - !!process.env.COMMANDCODE_API_KEY || - existsSync(join(homedir(), ".commandcode", "auth.json")) || - existsSync(join(homedir(), ".omp", "agent", "auth.json")) || - existsSync(join(homedir(), ".pi", "agent", "auth.json")) - ) -} +console.log("[pi-local] using isolated mock auth") +const tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-")) +mkdirSync(join(tempHome, ".commandcode"), { recursive: true }) +writeFileSync(join(tempHome, ".commandcode", "auth.json"), JSON.stringify({ apiKey: "mock-key" })) -let tempHome const env = { ...process.env, - COMMANDCODE_API_BASE: apiBase, + HOME: tempHome, + USERPROFILE: tempHome, + COMMANDCODE_API_KEY: "mock-key", + COMMANDCODE_API_BASE: `${apiBase}/provider/v1`, COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, } -if (hasLivePiAuth()) { - console.log("[pi-local] using live pi auth") -} else { - console.log("[pi-local] live pi auth not found; using mock auth fallback") - tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-")) - mkdirSync(join(tempHome, ".commandcode"), { recursive: true }) - writeFileSync(join(tempHome, ".commandcode", "auth.json"), JSON.stringify({ apiKey: "mock-key" })) - env.HOME = tempHome - env.USERPROFILE = tempHome - env.COMMANDCODE_API_KEY = "mock-key" -} - function runPi(args, timeoutMs = 30_000) { return new Promise((resolve) => { const child = spawn(PI_BIN, args, { @@ -325,7 +405,30 @@ try { lastRequestHeaders.authorization.startsWith("Bearer "), "should send a bearer Authorization header", ) - assert.equal(lastRequestBody?.params?.model, TEST_MODEL) + assert.equal(lastRequestBody?.model, TEST_MODEL) + assert.equal(lastRequestBody?.stream, true) + + console.log("[pi-local] Anthropic Messages route through real extension and mock API") + anthropicRequestCount = 0 + const claudePrint = await runPi( + [ + "--no-extensions", + "-e", + EXT_PATH, + "-p", + "say mock token", + "--provider", + "commandcode", + "--model", + TEST_CLAUDE_MODEL, + ], + 30_000, + ) + assert.equal(claudePrint.code, 0, claudePrint.stderr) + assert.match(claudePrint.stdout, /mock-claude-ok/) + assert.equal(anthropicRequestCount, 1) + assert.equal(lastRequestHeaders["x-api-key"], env.COMMANDCODE_API_KEY) + assert.equal(lastRequestBody?.model, TEST_CLAUDE_MODEL) console.log("[pi-local] RPC prompt through real extension and mock API") requestCount = 0 @@ -343,6 +446,7 @@ try { assert.equal(rpc.sawAssistantMessage, true) assert.equal(rpc.sawTextDelta, true) assert.equal(requestCount, 1) + assert.equal(lastRequestBody?.model, TEST_MODEL) console.log("[pi-local] PASS") } finally { diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts index 3d4e995..7bc3756 100644 --- a/tests/test-pricing.ts +++ b/tests/test-pricing.ts @@ -1,90 +1,85 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -// MODEL_COSTS is a module-level const in index.ts. We verify the pricing -// overlay by importing the map through a dedicated re-export so tests don't -// need to spin up the full extension. -// -// To keep the test self-contained without importing the full extension (which -// requires ExtensionAPI), we read the source and extract the constant at -// runtime. A cleaner approach would be a dedicated src/pricing.ts module, -// but for now we verify the known cost entries directly. +import { MODEL_COSTS } from "../src/pricing.ts" -import { readFileSync } from "node:fs" -import { resolve, dirname } from "node:path" -import { fileURLToPath } from "node:url" - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const indexSource = readFileSync(resolve(__dirname, "..", "index.ts"), "utf-8") - -// Extract MODEL_COSTS object from index.ts source using a simple parse. -// The map is written as a Record -// so we eval it in a sandboxed context. -const match = indexSource.match( - /const MODEL_COSTS:\s*Record\s*=\s*\{([\s\S]*?)\n\}/, -) -assert.ok(match, "MODEL_COSTS constant should exist in index.ts") - -// Parse the cost entries from the extracted block. -const costBlock = match[1] -const entries: Record = {} -for (const line of costBlock.split("\n")) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith("//")) continue - const entryMatch = trimmed.match(/^"([^"]+)":\s*\{\s*input:\s*([\d.]+),\s*output:\s*([\d.]+)/) - if (entryMatch) { - entries[entryMatch[1]] = { - input: Number(entryMatch[2]), - output: Number(entryMatch[3]), - } - } -} +const CURRENT_PROVIDER_MODELS = [ + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-haiku-4-5-20251001", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex", + "gpt-5.4-mini", + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + "moonshotai/Kimi-K2.7-Code", + "moonshotai/Kimi-K2.7-Code-Highspeed", + "moonshotai/Kimi-K2.6", + "moonshotai/Kimi-K2.5", + "zai-org/GLM-5.2", + "zai-org/GLM-5.2-Fast", + "zai-org/GLM-5.1", + "zai-org/GLM-5", + "MiniMaxAI/MiniMax-M3", + "MiniMaxAI/MiniMax-M2.7", + "MiniMaxAI/MiniMax-M2.5", + "xiaomi/mimo-v2.5-pro", + "xiaomi/mimo-v2.5", + "Qwen/Qwen3.6-Max-Preview", + "Qwen/Qwen3.6-Plus", + "Qwen/Qwen3.7-Max", + "Qwen/Qwen3.7-Plus", + "stepfun/Step-3.7-Flash", + "stepfun/Step-3.5-Flash", + "google/gemini-3.5-flash", + "google/gemini-3.1-flash-lite", + "sakana/fugu-ultra", + "nvidia/nemotron-3-ultra-550b-a55b", +] describe("MODEL_COSTS pricing overlay", () => { - it("covers known Command Code models with non-zero pricing", () => { - const knownModels = [ - "deepseek/deepseek-v4-flash", - "deepseek/deepseek-v4-pro", - "claude-sonnet-4-6", - "claude-opus-4-7", - "Qwen/Qwen3.7-Max", - "gpt-5.5", - "stepfun/Step-3.5-Flash", - ] - - for (const id of knownModels) { - const cost = entries[id] + it("covers the validated Provider API model catalog with non-zero display pricing", () => { + for (const id of CURRENT_PROVIDER_MODELS) { + const cost = MODEL_COSTS[id] assert.ok(cost, `MODEL_COSTS should include "${id}"`) assert.ok(cost.input > 0, `"${id}" input cost should be > 0`) assert.ok(cost.output > 0, `"${id}" output cost should be > 0`) } }) - it("includes promotional pricing notes in comments", () => { - // The DeepSeek V4 Pro 4× deal and Qwen 3.7 Max 2× deal should be - // documented in the source comments. - assert.ok( - costBlock.includes("4× usage deal") || costBlock.includes("75% off"), - "DeepSeek V4 Pro promotional pricing should be documented", - ) - assert.ok( - costBlock.includes("2× usage deal") || costBlock.includes("50% off"), - "Qwen 3.7 Max promotional pricing should be documented", - ) + it("includes known promotional pricing", () => { + assert.deepEqual(MODEL_COSTS["deepseek/deepseek-v4-pro"], { + input: 0.435, + output: 0.87, + cacheRead: 0.003625, + cacheWrite: 0, + }) + assert.deepEqual(MODEL_COSTS["MiniMaxAI/MiniMax-M3"], { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }) + assert.deepEqual(MODEL_COSTS["xiaomi/mimo-v2.5"], { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + cacheWrite: 0, + }) }) - it("has cache pricing for models that support it", () => { - // Claude models should have non-zero cacheRead and cacheWrite costs. - const claudeModels = ["claude-sonnet-4-6", "claude-opus-4-7"] + it("has cache pricing for Claude models", () => { + const claudeModels = ["claude-sonnet-5", "claude-sonnet-4-6", "claude-opus-4-8"] + for (const id of claudeModels) { - const fullEntryMatch = costBlock.match( - new RegExp( - `"${id.replace(/\//g, "\\\\")}":\\s*\\{[^}]+cacheRead:\\s*([\\d.]+)[^}]+cacheWrite:\\s*([\\d.]+)`, - ), - ) - assert.ok(fullEntryMatch, `"${id}" should have cacheRead and cacheWrite fields`) - assert.ok(Number(fullEntryMatch[1]) > 0, `"${id}" cacheRead should be > 0`) - assert.ok(Number(fullEntryMatch[2]) > 0, `"${id}" cacheWrite should be > 0`) + const cost = MODEL_COSTS[id] + assert.ok(cost, `MODEL_COSTS should include "${id}"`) + assert.ok(cost.cacheRead > 0, `"${id}" cacheRead should be > 0`) + assert.ok(cost.cacheWrite > 0, `"${id}" cacheWrite should be > 0`) } }) }) diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts deleted file mode 100644 index 96ac37b..0000000 --- a/tests/test-pure-functions.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Unit tests for the real pure helpers exported by src/core.ts. - * These are hermetic: no pi runtime and no network. - */ - -import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { describe, it } from "node:test" - -import { - getApiKey, - getEnvironmentInfo, - mapFinishReason, - messagesToCC, - parseStreamEventLine, - projectSlugFromPath, - textContent, - toJsonSchema, - toolsToJson, -} from "../src/core.ts" - -import { objectAt } from "./helpers.ts" - -describe("getApiKey()", () => { - it("uses COMMANDCODE_API_KEY from provided env", () => { - assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key") - }) - - it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) - try { - const first = join(dir, "first.json") - const second = join(dir, "second.json") - const oauth = join(dir, "oauth.json") - const official = join(dir, "official.json") - writeFileSync(first, JSON.stringify({ apiKey: "file-key" })) - writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })) - writeFileSync( - oauth, - JSON.stringify({ - commandcode: { - type: "oauth", - access: "oauth-access-key", - refresh: "oauth-refresh-key", - expires: Date.now() + 3600000, - }, - }), - ) - writeFileSync( - official, - JSON.stringify({ - "command-code": { - type: "api", - key: "official-cli-key", - }, - }), - ) - assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key") - assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key") - assert.equal(getApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key") - assert.equal(getApiKey({ env: {}, authPaths: [official] }), "official-cli-key") - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it("ignores malformed auth files", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-")) - try { - const bad = join(dir, "bad.json") - writeFileSync(bad, "not json") - assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it("uses injected homeDir for default auth paths", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-home-")) - try { - const authDir = join(dir, ".pi", "agent") - mkdirSync(authDir, { recursive: true }) - writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" })) - assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key") - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) - -describe("projectSlugFromPath()", () => { - it("matches the official CLI-style slug from an absolute working directory", () => { - assert.equal( - projectSlugFromPath("/Users/patwoz/dev/Personal/pi/pi-commandcode-provider"), - "users-patwoz-dev-personal-pi-pi-commandcode-provider", - ) - assert.equal(projectSlugFromPath("/repo"), "repo") - }) -}) - -describe("textContent()", () => { - it("extracts and joins text blocks", () => { - assert.equal( - textContent({ - content: [ - { type: "text", text: "hello" }, - { type: "image", data: "x" }, - { type: "text", text: "world" }, - ], - }), - "hello\nworld", - ) - }) - - it("handles empty or missing content", () => { - assert.equal(textContent({ content: [] }), "") - assert.equal(textContent({}), "") - }) -}) - -describe("getEnvironmentInfo()", () => { - it("returns platform, arch, and Node version", () => { - const info = getEnvironmentInfo() - assert.match(info, /^(darwin|linux|win32)-/) - assert.ok(info.includes("Node.js")) - }) -}) - -describe("toJsonSchema()", () => { - it("converts scalar, enum, object, optional, array, and union schema shapes", () => { - assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" }) - assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" }) - assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" }) - assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), { - type: "string", - enum: ["left", "right"], - }) - assert.deepEqual( - toJsonSchema({ - kind: "object", - properties: { - name: { kind: "string" }, - tags: { kind: "array", items: { kind: "string" }, optional: true }, - }, - }), - { - type: "object", - properties: { - name: { type: "string" }, - tags: { type: "array", items: { type: "string" } }, - }, - required: ["name"], - }, - ) - assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), { - type: "string", - }) - assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), { - type: "number", - }) - }) - - it("preserves explicit required arrays and handles unknown values", () => { - assert.deepEqual( - toJsonSchema({ - type: "object", - properties: { name: { type: "string" }, nickname: { type: "string" } }, - required: ["name"], - }), - { - type: "object", - properties: { name: { type: "string" }, nickname: { type: "string" } }, - required: ["name"], - }, - ) - assert.deepEqual(toJsonSchema(undefined), {}) - assert.deepEqual(toJsonSchema({ kind: "wat" }), {}) - }) -}) - -describe("toolsToJson()", () => { - it("converts pi tools to Command Code tool JSON", () => { - assert.deepEqual( - toolsToJson([ - { - name: "get_weather", - description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - ]), - [ - { - type: "function", - name: "get_weather", - description: "Get weather", - input_schema: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"], - }, - }, - ], - ) - }) - - it("returns an empty array for missing tools", () => { - assert.deepEqual(toolsToJson(), []) - }) -}) - -describe("messagesToCC()", () => { - it("converts user, assistant, and tool result messages", () => { - const result = messagesToCC([ - { role: "user", content: "read /tmp/test" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "I will read" }, - { type: "text", text: "Sure" }, - { - type: "toolCall", - id: "c1", - name: "read", - arguments: { path: "/tmp/test" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - isError: false, - content: [ - { type: "text", text: "hello" }, - { type: "text", text: "world" }, - ], - }, - ]) - - assert.equal(objectAt(result, ["0", "role"]), "user") - assert.equal(objectAt(result, ["1", "role"]), "assistant") - assert.equal(objectAt(result, ["1", "content", "0", "type"]), "reasoning") - assert.equal(objectAt(result, ["1", "content", "2", "type"]), "tool-call") - assert.equal(objectAt(result, ["2", "role"]), "tool") - assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld") - }) - - it("drops orphaned tool calls that have no matching tool result", () => { - const result = messagesToCC([ - { role: "user", content: "edit a file" }, - { - role: "assistant", - content: [ - { type: "text", text: "I will edit it" }, - { - type: "toolCall", - id: "missing-result", - name: "edit", - arguments: { path: "x" }, - }, - ], - }, - ]) - - assert.equal(objectAt(result, ["1", "role"]), "assistant") - assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") - assert.equal(objectAt(result, ["1", "content", "1"]), undefined) - }) - - it("handles empty conversations", () => { - assert.deepEqual(messagesToCC([]), []) - }) -}) - -describe("parseStreamEventLine()", () => { - it("parses plain JSON and SSE data lines", () => { - assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), { - type: "text-delta", - text: "x", - }) - assert.deepEqual(parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), { - type: "finish", - finishReason: "stop", - }) - }) - - it("ignores comments, event labels, done markers, and malformed JSON", () => { - assert.equal(parseStreamEventLine(":"), undefined) - assert.equal(parseStreamEventLine("event: message"), undefined) - assert.equal(parseStreamEventLine("data: [DONE]"), undefined) - assert.equal(parseStreamEventLine("not-json"), undefined) - }) -}) - -describe("mapFinishReason()", () => { - it("maps provider finish reasons to pi stop reasons", () => { - assert.equal(mapFinishReason("stop"), "stop") - assert.equal(mapFinishReason("tool-calls"), "toolUse") - assert.equal(mapFinishReason("max_tokens"), "length") - assert.equal(mapFinishReason("max_output_tokens"), "length") - }) -}) diff --git a/tests/test-retry.ts b/tests/test-retry.ts deleted file mode 100644 index e0366de..0000000 --- a/tests/test-retry.ts +++ /dev/null @@ -1,470 +0,0 @@ -/** - * Tests for retry and timeout behaviour driven by pi settings.json - * retry config (timeoutMs, maxRetries, maxRetryDelayMs). - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import type { AssistantMessageEvent } from "../src/core.ts" -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -const TEST_API_KEY = "option-key" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -function eventTypes(events: readonly AssistantMessageEvent[]): string[] { - return events.map((event) => event.type) -} - -describe("streamCommandCode — retry on transient errors", () => { - it("retries on 429 and succeeds on the second attempt", async () => { - server.mockResponseQueue([ - { type: "error", status: 429, body: "rate limited" }, - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "ok" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 2, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "stop") - }) - - it("retries on 500 and succeeds on the second attempt", async () => { - server.mockResponseQueue([ - { type: "error", status: 500, body: "internal server error" }, - { - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 2, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.equal(events.at(-1)?.type, "done") - }) - - it("does NOT retry on 400 (non-retryable client error)", async () => { - server.mockResponse({ type: "error", status: 400, body: "bad request" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY }), - ) - - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const last = events.at(-1) - if (last?.type !== "error") throw new Error("expected error") - assert.match(last.error.errorMessage ?? "", /400/) - }) - - it("exhausts maxRetries and emits an error", async () => { - server.mockResponse({ type: "error", status: 503, body: "unavailable" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 3, - }), - ) - - // initial attempt + 3 retries = 4 total - assert.equal(server.requestCount(), 4) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const last503 = events.at(-1) - if (last503?.type !== "error") throw new Error("expected error") - assert.match(last503.error.errorMessage ?? "", /503/) - }) -}) - -describe("streamCommandCode — Retry-After header", () => { - it("respects Retry-After delay in seconds", async () => { - let delayCalled = false - server.mockResponseQueue([ - { - type: "error", - status: 429, - body: "rate limited", - headers: { "retry-after": "2" }, - }, - { - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }, - ]) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - delay: async (ms: number) => { - delayCalled = true - assert.equal(ms, 2000) - }, - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 2, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.equal(events.at(-1)?.type, "done") - assert.ok(delayCalled, "delay should have been called with Retry-After value") - }) - - it("fails immediately when Retry-After exceeds maxRetryDelayMs", async () => { - server.mockResponse({ - type: "error", - status: 429, - body: "rate limited", - headers: { "retry-after": "300" }, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetryDelayMs: 10_000, - }), - ) - - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const lastMax = events.at(-1) - if (lastMax?.type !== "error") throw new Error("expected error") - assert.match(lastMax.error.errorMessage ?? "", /exceeds max/) - }) - - it("does not cap Retry-After when maxRetryDelayMs is 0", async () => { - let delayCalled = false - server.mockResponseQueue([ - { - type: "error", - status: 429, - body: "rate limited", - headers: { "retry-after": "120" }, - }, - { - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }, - ]) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - delay: async (ms: number) => { - delayCalled = true - assert.equal(ms, 120_000) - }, - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 1, - maxRetryDelayMs: 0, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.equal(events.at(-1)?.type, "done") - assert.ok(delayCalled) - }) -}) - -describe("streamCommandCode — timeout", () => { - it("retries on per-attempt timeout and succeeds", async () => { - server.mockResponseQueue([ - { - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - hangAfterLast: true, - responseDelay: 200, - }, - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "fast" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - timeoutMs: 50, - maxRetries: 2, - }), - 5_000, - ) - - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) - }) - - it("retries when the response starts but the stream hangs before finish", async () => { - server.mockResponseQueue([ - { - type: "success", - events: [], - hangAfterLast: true, - }, - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "ok" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - timeoutMs: 50, - maxRetries: 2, - }), - 5_000, - ) - - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) - }) - - it("does NOT retry on timeout after partial text-delta was emitted", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "text-delta", text: "partial" })], - hangAfterLast: true, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - timeoutMs: 50, - maxRetries: 2, - }), - 5_000, - ) - - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "error"]) - }) - - it("emits error when all retry attempts time out", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - hangAfterLast: true, - responseDelay: 200, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - timeoutMs: 50, - maxRetries: 1, - }), - 5_000, - ) - - // initial + 1 retry = 2 - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /timed out after 50ms/) - }) -}) - -describe("streamCommandCode — abort cancels retry loop", () => { - it("user abort stops retries immediately", async () => { - server.mockResponse({ type: "error", status: 500, body: "error" }) - const controller = new AbortController() - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - delay: async (_ms: number, signal: AbortSignal) => { - // Abort during the retry delay - controller.abort() - // Simulate the real delay which rejects on abort - return new Promise((_, reject) => { - if (signal.aborted) reject(new DOMException("Aborted", "AbortError")) - signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))) - }) - }, - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - signal: controller.signal, - maxRetries: 10, - }), - ) - - // Should only have made 1 request (the initial one), then aborted during delay - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - }) -}) - -describe("streamCommandCode — retry defaults", () => { - it("uses default maxRetries of 0 when not specified", async () => { - server.mockResponse({ type: "error", status: 500, body: "error" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY })) - - assert.equal(server.requestCount(), 1) - }) - - it("respects maxRetries: 0 (no retries)", async () => { - server.mockResponse({ type: "error", status: 500, body: "error" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 0, - }), - ) - - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "error"]) - }) -}) - -describe("streamCommandCode — stream-level error retry", () => { - it("retries when API returns 200 OK but stream contains an error event", async () => { - server.mockResponseQueue([ - { - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: "Service temporarily unavailable. Please try again shortly.", - }), - ], - }, - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "ok" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 2, - }), - ) - - assert.equal(server.requestCount(), 2) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) - }) - - it("exhausts retries on persistent stream-level errors", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: "Service temporarily unavailable. Please try again shortly.", - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: TEST_API_KEY, - maxRetries: 3, - }), - ) - - // initial + 3 retries = 4 - assert.equal(server.requestCount(), 4) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const last = events.at(-1) - if (last?.type !== "error") throw new Error("expected error") - assert.match(last.error.errorMessage ?? "", /temporarily unavailable/) - }) - - it("does NOT retry stream error when content was already emitted", async () => { - server.mockResponseQueue([ - { - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "partial" }), - JSON.stringify({ - type: "error", - error: "Service temporarily unavailable", - }), - ], - }, - ]) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY }), - ) - - // Only 1 request — no retry because content was already emitted. - assert.equal(server.requestCount(), 1) - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "error"]) - }) -}) diff --git a/tests/test-stream.ts b/tests/test-stream.ts deleted file mode 100644 index 225b52d..0000000 --- a/tests/test-stream.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Integration tests for the real streamCommandCode core using a local mock - * Command Code server. No real API key or pi runtime required. - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import type { AssistantMessageEvent } from "../src/core.ts" -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - objectAt, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -function eventTypes(events: readonly AssistantMessageEvent[]): string[] { - return events.map((event) => event.type) -} - -describe("streamCommandCode — auth", () => { - it("emits a missing-key error without touching the network", async () => { - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: {}, - authPaths: [], - }) - const stream = streamCommandCode(makeModel(), makeContext(), { - apiKey: "", - }) - const events = await collectEvents(stream) - - assert.deepEqual(eventTypes(events), ["error"]) - assert.equal(events[0].type, "error") - assert.equal(events[0].reason, "error") - assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/) - assert.equal(server.requestCount(), 0) - }) - - it("ignores the literal env-var name and falls back to env", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: { COMMANDCODE_API_KEY: "env-key" }, - }) - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "COMMANDCODE_API_KEY" }), - ) - - assert.equal( - server.lastRequestHeaders().authorization, - "Bearer env-key", - "should resolve from env, not send the literal var name as the token", - ) - }) - - it("uses options.apiKey in the Authorization header", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: { COMMANDCODE_API_KEY: "env-key" }, - }) - - await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "option-key" })) - - assert.equal(server.lastRequestHeaders().authorization, "Bearer option-key") - }) -}) - -describe("streamCommandCode — successful streams", () => { - it("emits start → text events → done and accumulates usage", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "Hel" }), - JSON.stringify({ type: "text-delta", text: "lo" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 5, - outputTokens: 2, - inputTokenDetails: { cacheReadTokens: 3, cacheWriteTokens: 1 }, - }, - }), - ], - }) - const { streamCommandCode, calculatedUsages } = createTestDeps({ - apiBase: server.baseUrl(), - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "text_start", - "text_delta", - "text_delta", - "text_end", - "done", - ]) - const done = events.at(-1) - assert.equal(done?.type, "done") - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "stop") - assert.equal(done.message.content[0]?.type, "text") - assert.equal( - done.message.content[0]?.type === "text" ? done.message.content[0].text : "", - "Hello", - ) - assert.equal(done.message.usage.totalTokens, 11) - assert.equal(calculatedUsages.length, 1) - }) - - it("ends on finish without waiting for an open upstream connection", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "done" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - hangAfterLast: true, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - 500, - ) - - assert.equal(events.at(-1)?.type, "done") - await new Promise((resolve) => setTimeout(resolve, 50)) - assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body") - }) - - it("emits reasoning and tool-call blocks in order", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "think" }), - JSON.stringify({ type: "reasoning-end" }), - JSON.stringify({ type: "text-delta", text: "Using tool" }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: JSON.stringify({ path: "/tmp/x" }), - }), - JSON.stringify({ type: "finish", finishReason: "tool-calls" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "text_start", - "text_delta", - "text_end", - "toolcall_start", - "toolcall_end", - "done", - ]) - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "toolUse") - assert.deepEqual( - done.message.content.map((content) => content.type), - ["thinking", "text", "toolCall"], - ) - const toolCall = done.message.content[2] - assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file") - }) - - it("flushes reasoning if finish arrives without reasoning-end", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-delta", text: "unfinished thought" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.message.content[0]?.type, "thinking") - }) - - it("closes thinking block before text when reasoning-end is missing", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "thinking" }), - JSON.stringify({ type: "text-delta", text: "answer" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "text_start", - "text_delta", - "text_end", - "done", - ]) - }) - - it("closes thinking block before tool-call when reasoning-end is missing", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "thinking" }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: JSON.stringify({ path: "/tmp/x" }), - }), - JSON.stringify({ type: "finish", finishReason: "tool-calls" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "toolcall_start", - "toolcall_end", - "done", - ]) - }) -}) - -describe("streamCommandCode — request serialization", () => { - it("sends the expected request body and default headers", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const context = makeContext({ - messages: [ - { role: "user", content: "first" }, - { - role: "assistant", - content: [{ type: "text", text: "first response" }], - }, - { role: "user", content: "second" }, - ], - tools: [ - { - name: "get_weather", - description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - ], - }) - - await collectEvents( - streamCommandCode(makeModel(), context, { - apiKey: "mock-key", - maxTokens: 500, - }), - ) - - const body = server.lastRequestBody() - assert.equal(objectAt(body, ["config", "workingDir"]), "/repo") - assert.equal(objectAt(body, ["config", "date"]), "2026-05-05") - assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash") - assert.equal(objectAt(body, ["params", "stream"]), true) - assert.equal(objectAt(body, ["params", "max_tokens"]), 500) - assert.equal(objectAt(body, ["params", "temperature"]), 0.3) - assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.") - assert.equal(objectAt(body, ["memory"]), null) - assert.equal(objectAt(body, ["taste"]), null) - assert.equal(objectAt(body, ["skills"]), null) - assert.equal(objectAt(body, ["permissionMode"]), undefined) - assert.equal(objectAt(body, ["threadId"]), "00000000-0000-4000-8000-000000000000") - assert.equal( - objectAt(body, ["params", "messages", "1", "content", "0", "text"]), - "first response", - ) - assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "get_weather") - - const headers = server.lastRequestHeaders() - assert.equal(headers.authorization, "Bearer mock-key") - assert.equal(headers["x-command-code-version"], "0.29.0") - assert.equal(headers["x-project-slug"], "repo") - assert.equal(headers["x-taste-learning"], "true") - assert.equal(headers["x-co-flag"], "false") - assert.equal(headers["x-session-id"], undefined) - }) - - it("caps maxTokens and passes custom headers", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), { - apiKey: "mock-key", - maxTokens: 500_000, - headers: { "x-custom": "value" }, - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 64_000) - assert.equal(server.lastRequestHeaders()["x-custom"], "value") - }) - - it("caps default maxTokens by the selected model", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(makeModel({ maxTokens: 8_192 }), makeContext(), { - apiKey: "mock-key", - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 8_192) - }) - - it("serializes OMP system prompt arrays as a string", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode( - makeModel(), - makeContext({ - systemPrompt: ["You are a test assistant.", "Use concise answers."] as unknown as string, - }), - { apiKey: "mock-key" }, - ), - ) - - assert.equal( - objectAt(server.lastRequestBody(), ["params", "system"]), - "You are a test assistant.\n\nUse concise answers.", - ) - }) - - it("runs onPayload and onResponse hooks", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - let responseStatus = 0 - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - onPayload: () => ({ replaced: true }), - onResponse: (response) => { - responseStatus = response.status - }, - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true) - assert.equal(responseStatus, 200) - }) -}) - -describe("streamCommandCode — upstream errors and malformed streams", () => { - it("emits error for HTTP failures", async () => { - server.mockResponse({ type: "error", status: 429, body: "rate limited" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /429/) - }) - - it("emits error for provider error events", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: { message: "provider failed" }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.error.errorMessage, "provider failed") - }) - - it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => { - const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n` - const finishEvent = JSON.stringify({ - type: "finish", - finishReason: "max_tokens", - }) - server.mockResponse({ - type: "success", - chunks: [ - "not json\n", - textEvent.slice(0, 12), - textEvent.slice(12), - "event: ignored\n", - "data: [DONE]\n", - finishEvent, - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "length") - assert.equal( - done.message.content[0]?.type === "text" ? done.message.content[0].text : "", - "split", - ) - }) -})