From 06032913963f1e28921fd8805c305757b60a7b37 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 10:55:58 +0200 Subject: [PATCH 01/13] feat(auth): support direct api key login --- src/api-key.ts | 68 +++++++++++++++++++++++++++++++++++++++++++ src/oauth.ts | 67 +++++++++++++++++++++++++++++++++--------- tests/test-api-key.ts | 59 +++++++++++++++++++++++++++++++++++++ tests/test-oauth.ts | 43 +++++++++++++++++++++++---- 4 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 src/api-key.ts create mode 100644 tests/test-api-key.ts 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/oauth.ts b/src/oauth.ts index be77391..0a68ac5 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,13 +1,13 @@ /** * Command Code OAuth provider for pi's /login flow. * - * Implements a browser-assisted API key retrieval flow: - * 1. Starts a local HTTP server on a Command Code CLI-compatible port - * 2. Opens the Command Code Studio auth page in the browser - * 3. The user authenticates on the Command Code website - * 4. The website POSTs the API key back to the local server - * 5. If browser transfer fails, the user can paste the API key manually - * 6. The API key is stored in pi's auth.json as OAuth credentials + * Implements two API key retrieval flows: + * 1. Browser-assisted login opens Command Code Studio and waits for the + * website to POST the API key back to a local callback server. + * 2. Direct API key login prompts the user to paste a Studio API key. + * + * If browser transfer fails, the user can still paste the API key manually. + * The API key is stored in pi's auth.json as OAuth credentials. * * Since Command Code API keys don't expire, we store them as * OAuth credentials with a far-future expiry. @@ -101,13 +101,35 @@ async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) return credentialsFromApiKey(apiKey) } -/** - * Starts the browser-based login flow for Command Code. - * - * Returns OAuth credentials where access == refresh == the user's API key. - * The keys don't expire, so we set a far-future expiry. - */ -export async function login(callbacks: OAuthLoginCallbacks): Promise { +type LoginChoice = { type: "browser" } | { type: "prompt" } | { type: "apiKey"; apiKey: string } + +async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise { + const input = sanitizeApiKey( + await callbacks.onPrompt({ + message: + "Command Code login: press Enter for browser login, type 'key' to paste an API key, or paste the API key directly:", + }), + ) + const normalized = input.toLowerCase() + + if (!input || normalized === "1" || normalized === "b" || normalized === "browser") { + return { type: "browser" } + } + + if ( + normalized === "2" || + normalized === "k" || + normalized === "key" || + normalized === "api" || + normalized === "paste" + ) { + return { type: "prompt" } + } + + return { type: "apiKey", apiKey: input } +} + +async function browserLogin(callbacks: OAuthLoginCallbacks): Promise { let authServer try { authServer = await startAuthServer() @@ -151,6 +173,23 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise { + const choice = await chooseLoginFlow(callbacks) + + if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey) + if (choice.type === "prompt") { + return promptForApiKey(callbacks, "Paste your Command Code API key:") + } + + return browserLogin(callbacks) +} + /** * Command Code API keys don't expire, so "refresh" is a no-op. * Returns the same credentials with an updated far-future expiry. diff --git a/tests/test-api-key.ts b/tests/test-api-key.ts new file mode 100644 index 0000000..a497978 --- /dev/null +++ b/tests/test-api-key.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, it } from "node:test" + +import { getConfiguredApiKey } from "../src/api-key.ts" + +async function withAuthFile( + value: unknown, + run: (authPath: string) => Promise, +): Promise { + const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-auth-")) + const authPath = join(directory, "auth.json") + try { + await writeFile(authPath, JSON.stringify(value), "utf-8") + await run(authPath) + } finally { + await rm(directory, { recursive: true, force: true }) + } +} + +describe("getConfiguredApiKey()", () => { + it("prefers the environment variable", () => { + assert.equal( + getConfiguredApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), + "env-key", + ) + }) + + it("reads pi OAuth and API credentials", async () => { + const cases: readonly { credential: unknown; expected: string }[] = [ + { + credential: { commandcode: { type: "oauth", access: "oauth-key" } }, + expected: "oauth-key", + }, + { credential: { commandcode: { type: "api", key: "api-key" } }, expected: "api-key" }, + { credential: { "command-code": { type: "api", key: "cli-key" } }, expected: "cli-key" }, + { credential: { apiKey: "legacy-key" }, expected: "legacy-key" }, + ] + + for (const testCase of cases) { + await withAuthFile(testCase.credential, async (authPath) => { + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [authPath] }), testCase.expected) + }) + } + }) + + it("ignores malformed files", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-auth-")) + const authPath = join(directory, "auth.json") + try { + await writeFile(authPath, "not json", "utf-8") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [authPath] }), undefined) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts index 0ff0fab..00d94a0 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -186,7 +186,7 @@ describe("login()", () => { authUrl = params.url }, onPrompt(_params: { message: string }): Promise { - throw new Error("onPrompt should not be called in browser flow") + return Promise.resolve("") }, } @@ -239,7 +239,7 @@ describe("login()", () => { process.env.COMMANDCODE_AUTH_TIMEOUT_MS = "1" let authUrl = "" - let promptMessage = "" + const promptMessages: string[] = [] try { const result = await login({ @@ -247,13 +247,13 @@ describe("login()", () => { authUrl = params.url }, async onPrompt(params: { message: string }): Promise { - promptMessage = params.message - return "\u001b[200~ user_manualApiKey\n\u001b[201~" + promptMessages.push(params.message) + return promptMessages.length === 1 ? "" : "\u001b[200~ user_manualApiKey\n\u001b[201~" }, }) assert.match(authUrl, /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?/) - assert.match(promptMessage, /Paste your Command Code API key/) + assert.match(promptMessages[1] ?? "", /Paste your Command Code API key/) assert.equal(result.access, "user_manualApiKey") assert.equal(result.refresh, "user_manualApiKey") assert.ok(result.expires > Date.now(), "expiry should be far in the future") @@ -263,6 +263,37 @@ describe("login()", () => { } }) + it("accepts a directly pasted API key", async () => { + let authOpened = false + const result = await login({ + onAuth() { + authOpened = true + }, + onPrompt(): Promise { + return Promise.resolve("user_directApiKey") + }, + }) + + assert.equal(authOpened, false) + assert.equal(result.access, "user_directApiKey") + }) + + it("offers an explicit API key prompt", async () => { + let promptCount = 0 + const result = await login({ + onAuth() { + throw new Error("browser should not open") + }, + onPrompt(): Promise { + promptCount += 1 + return Promise.resolve(promptCount === 1 ? "key" : "user_promptedApiKey") + }, + }) + + assert.equal(result.access, "user_promptedApiKey") + assert.equal(promptCount, 2) + }) + it("rejects on state token mismatch", async () => { let authUrl = "" const callbacks = { @@ -270,7 +301,7 @@ describe("login()", () => { authUrl = params.url }, onPrompt(_params: { message: string }): Promise { - throw new Error("should not prompt") + return Promise.resolve("") }, } From 84a802d91dd0e7cebc0d5458a91834a1b4e17a14 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 10:56:06 +0200 Subject: [PATCH 02/13] feat(api): use official provider endpoints --- index.ts | 97 ++--- package.json | 9 +- src/converters.ts | 296 -------------- src/core.ts | 741 --------------------------------- src/cost.ts | 31 -- src/json-schema.ts | 382 ----------------- src/models.ts | 17 +- src/overflow.ts | 120 ------ src/types.ts | 201 --------- tests/helpers.ts | 289 ------------- tests/test-abort.ts | 85 ---- tests/test-cost.ts | 183 --------- tests/test-models.ts | 16 + tests/test-omp-compat.mjs | 21 +- tests/test-overflow.ts | 196 --------- tests/test-pi-local.mjs | 139 +++++-- tests/test-pure-functions.ts | 658 ------------------------------ tests/test-retry.ts | 470 --------------------- tests/test-runtime.ts | 2 + tests/test-stream.ts | 771 ----------------------------------- 20 files changed, 199 insertions(+), 4525 deletions(-) delete mode 100644 src/converters.ts delete mode 100644 src/core.ts delete mode 100644 src/cost.ts delete mode 100644 src/json-schema.ts delete mode 100644 src/overflow.ts delete mode 100644 src/types.ts delete mode 100644 tests/helpers.ts delete mode 100644 tests/test-abort.ts delete mode 100644 tests/test-cost.ts delete mode 100644 tests/test-overflow.ts delete mode 100644 tests/test-pure-functions.ts delete mode 100644 tests/test-retry.ts delete mode 100644 tests/test-stream.ts diff --git a/index.ts b/index.ts index 24a77b1..552e3ee 100644 --- a/index.ts +++ b/index.ts @@ -1,12 +1,10 @@ /** * Command Code provider for pi. * - * Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate). - * The provider uses pi's legacy extension registration surface because the - * current pi host exposes `registerProvider(name, config)`, including OMP. + * Uses Command Code's documented Provider API: + * https://api.commandcode.ai/provider/v1 */ -import { AssistantMessageEventStream } from "@earendil-works/pi-ai" import { getAgentDir, type ExtensionAPI, @@ -15,10 +13,11 @@ import { } from "@earendil-works/pi-coding-agent" import { join } from "node:path" -import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" -import { calculateCommandCodeCost } from "./src/cost.ts" +import { getConfiguredApiKey } from "./src/api-key.ts" import { + baseUrlForModel, DEFAULT_MODELS_URL, + DEFAULT_PROVIDER_API_BASE, getModelsTimeoutMs, inputModalitiesForModel, loadCommandCodeModels, @@ -28,73 +27,67 @@ import { import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts" import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" import { createCommandCodeRuntime } from "./src/runtime.ts" -import { normalizeCommandCodeMessage } from "./src/overflow.ts" + +function commandCodeHeaders(): Record | undefined { + if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") { + return { "x-cmd-zdr": "1" } + } + return undefined +} function createProviderConfig( models: readonly CommandCodeModel[], apiBase: string, - streamCommandCode: ProviderConfig["streamSimple"], ): ProviderConfig { + const headers = commandCodeHeaders() return { name: "Command Code", baseUrl: apiBase, - // Keep environment authentication dynamic. OAuth credentials are resolved - // by pi's oauth registration, while the custom stream retains its own - // request-time legacy-file fallback for older compatible hosts. - apiKey: "$COMMANDCODE_API_KEY", - authHeader: true, - api: "commandcode-custom", - streamSimple: streamCommandCode, - headers: { - "x-command-code-version": COMMAND_CODE_CLI_VERSION, - "x-cli-environment": "production", - }, + apiKey: getConfiguredApiKey() ?? "$COMMANDCODE_API_KEY", + api: "openai-completions", + headers, oauth: { name: "Command Code", login, refreshToken, getApiKey: getOAuthApiKey, }, - models: models.map(createProviderModel), + models: models.map((model) => ({ + id: model.id, + name: model.name, + api: model.api, + baseUrl: baseUrlForModel(apiBase, model.api), + reasoning: model.reasoning, + ...(thinkingMetadataForModel(model.id) ?? {}), + input: [...inputModalitiesForModel(model.id)], + cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + headers, + compat: + model.api === "openai-completions" + ? { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + maxTokensField: "max_tokens", + } + : { + supportsEagerToolInputStreaming: false, + supportsLongCacheRetention: false, + supportsCacheControlOnTools: false, + supportsToolReferences: false, + }, + })), } } -function createProviderModel(model: { - id: string - name: string - reasoning: boolean - contextWindow: number - maxTokens: number -}) { - return { - id: model.id, - name: model.name, - reasoning: model.reasoning, - ...(thinkingMetadataForModel(model.id) ?? {}), - input: inputModalitiesForModel(model.id), - cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - } as const -} - export default async function (pi: ExtensionAPI) { - const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE + const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_PROVIDER_API_BASE const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL const modelsTimeoutMs = getModelsTimeoutMs() const modelsCachePath = process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json") - const streamCommandCode = createStreamCommandCode({ - createStream: () => new AssistantMessageEventStream(), - calculateCost: calculateCommandCodeCost, - apiBase, - }) - - pi.on("message_end", async (event, ctx) => { - if (event.message.role !== "assistant") return - const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider) - return normalized ? { message: normalized.message } : undefined - }) const runtime = createCommandCodeRuntime(pi, { endpoint: modelsUrl, @@ -105,7 +98,7 @@ export default async function (pi: ExtensionAPI) { cachePath: modelsCachePath, timeoutMs: modelsTimeoutMs, }), - createProviderConfig: (models) => createProviderConfig(models, apiBase, streamCommandCode), + createProviderConfig: (models) => createProviderConfig(models, apiBase), }) await runtime.initialize() diff --git a/package.json b/package.json index 05a6ef0..9af81b6 100644 --- a/package.json +++ b/package.json @@ -29,21 +29,18 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-oauth.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", "pi:isolated": "node scripts/pi-isolated.mjs", "pi:authenticated": "node scripts/pi-authenticated.mjs", - "test:unit": "tsx tests/test-pure-functions.ts", + "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-oauth.ts", + "test:api-key": "tsx tests/test-api-key.ts", "test:models": "tsx tests/test-models.ts", "test:runtime": "tsx tests/test-runtime.ts", "test:pricing": "tsx tests/test-pricing.ts", "test:oauth": "tsx tests/test-oauth.ts", - "test:abort": "tsx tests/test-abort.ts", - "test:overflow": "tsx tests/test-overflow.ts", - "test:stream": "tsx tests/test-stream.ts", - "test:retry": "tsx tests/test-retry.ts", "test:pi-isolated": "node tests/test-pi-isolated.mjs", "test:pi-authenticated": "node tests/test-pi-authenticated.mjs", "test:pi-local": "node tests/test-pi-local.mjs", diff --git a/src/converters.ts b/src/converters.ts deleted file mode 100644 index 4012fbb..0000000 --- a/src/converters.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" - -import type { MessageLike, StopReason, ToolLike } from "./types.ts" -import { toJsonSchema } from "./json-schema.ts" - -export { toJsonSchema } from "./json-schema.ts" - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -export function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined -} - -export function recordArray(value: unknown): readonly Record[] { - if (!Array.isArray(value)) return [] - return value.filter(isRecord) -} - -export function recordOrEmpty(value: unknown): Record { - if (isRecord(value)) return value - if (typeof value === "string") { - try { - const parsed: unknown = JSON.parse(value) - if (isRecord(parsed)) return parsed - } catch { - // Some providers stream incomplete JSON argument fragments. - } - } - return {} -} - -export function numberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined -} - -function defaultAuthPaths(home: string): string[] { - return [ - join(home, ".commandcode", "auth.json"), - join(home, ".omp", "agent", "auth.json"), - join(home, ".pi", "agent", "auth.json"), - ] -} - -function apiKeyFromCredentialRecord(value: unknown): string | undefined { - if (!isRecord(value)) return undefined - - const type = stringValue(value.type) - if (type === "api") return stringValue(value.key) - if (type === "oauth") return stringValue(value.access) - - return stringValue(value.key) ?? stringValue(value.access) -} - -function imageParts(value: unknown): readonly Record[] { - if (isRecord(value)) return value.type === "image" ? [value] : [] - return recordArray(value).filter((part) => part.type === "image") -} - -function imageContentError(role: string): Error { - return new Error(`Selected Command Code model does not support image content in ${role}`) -} - -export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void { - for (const message of messages ?? []) { - if (imageParts(message.content).length > 0) { - const role = message.role === "toolResult" ? "tool results" : `${message.role} messages` - throw imageContentError(role) - } - } -} - -function imageToCommandCode(part: Record): Record { - const data = stringValue(part.data) - const mimeType = stringValue(part.mimeType) - if (!data || !mimeType) - throw new Error("Invalid image content: expected base64 data and mimeType") - - return { - type: "image", - image: `data:${mimeType};base64,${data}`, - mimeType, - } -} - -function userContentToCommandCode(content: unknown, allowImages: boolean): unknown { - if (typeof content === "string") return content - - return recordArray(content).flatMap((part) => { - if (part.type === "text") return [{ type: "text", text: stringValue(part.text) ?? "" }] - if (part.type === "image") { - if (!allowImages) throw imageContentError("user messages") - return [imageToCommandCode(part)] - } - return [] - }) -} - -export function getApiKey( - options: { - env?: NodeJS.ProcessEnv - authPaths?: readonly string[] - homeDir?: () => string - } = {}, -): string | undefined { - const env = options.env ?? process.env - if (env.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 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[], - options: { allowImages?: boolean } = {}, -): unknown[] { - const allowImages = options.allowImages ?? false - if (!allowImages) assertTextOnlyMessages(messages) - - const out: unknown[] = [] - const pairedToolCallIds = completeToolCallIds(messages) - - for (const message of messages ?? []) { - if (message.role === "user") { - out.push({ - role: "user", - content: userContentToCommandCode(message.content, allowImages), - }) - } 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 === "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) }, - }, - ], - }) - - const images = imageParts(message.content) - if (images.length > 0) { - if (!allowImages) throw imageContentError("tool results") - out.push({ - role: "user", - content: images.map(imageToCommandCode), - }) - } - } - } - return out -} - -export function parseStreamEventLine(line: string): unknown | undefined { - let trimmed = line.trim() - if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined - if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim() - if (!trimmed || trimmed === "[DONE]") return undefined - - try { - const parsed: unknown = JSON.parse(trimmed) - return parsed - } catch { - return undefined - } -} - -export function mapFinishReason(reason: unknown): StopReason { - if (reason === "tool-calls") return "toolUse" - if ( - reason === "length" || - reason === "max_tokens" || - reason === "max-tokens" || - reason === "max_output_tokens" - ) { - return "length" - } - return "stop" -} - -function promptPartToText(value: unknown, depth = 0): string { - if (depth > 10) return "" - if (typeof value === "string") return value - if (Array.isArray(value)) - return value - .map((v) => promptPartToText(v, depth + 1)) - .filter(Boolean) - .join("\n") - if (!isRecord(value)) return "" - const text = stringValue(value.text) - if (text) return text - const content = promptPartToText(value.content, depth + 1) - if (content) return content - return "" -} - -export function systemPromptToText(value: unknown): string { - if (value === undefined || value === null) return "" - if (typeof value === "string") return value - if (Array.isArray(value)) - return value - .map((v) => promptPartToText(v, 0)) - .filter(Boolean) - .join("\n\n") - return promptPartToText(value, 0) -} diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index f1898dd..0000000 --- a/src/core.ts +++ /dev/null @@ -1,741 +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 { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts" -import { modelSupportsImageInput } from "./models.ts" -import { - getApiKey, - getEnvironmentInfo, - isRecord, - assertTextOnlyMessages, - 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 "./overflow.ts" -export * from "./types.ts" - -export const DEFAULT_API_BASE = "https://api.commandcode.ai" -export const COMMAND_CODE_CLI_VERSION = "1.15.1" - -const DEFAULT_GENERATE_MAX_TOKENS = 64_000 -const DEFAULT_MAX_RETRIES = 0 -const DEFAULT_MAX_RETRY_DELAY_MS = 60_000 -const BASE_RETRY_DELAY_MS = 500 - -function isRetryableStatus(status: number): boolean { - return status === 429 || (status >= 500 && status < 600) -} - -function parseRetryAfterSeconds(value: string | null): number | undefined { - if (!value) return undefined - const seconds = Number(value) - if (Number.isFinite(seconds) && seconds >= 0) return seconds - const date = Date.parse(value) - if (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000) - return undefined -} - -function effectiveMaxRetryDelayMs(value: number | undefined): number { - if (value === undefined) return DEFAULT_MAX_RETRY_DELAY_MS - if (value === 0) return Number.POSITIVE_INFINITY - return value -} - -function retryDelayMs( - attempt: number, - retryAfterHeader: string | null, - maxDelayMs: number, -): number { - const retryAfterMs = parseRetryAfterSeconds(retryAfterHeader) - if (retryAfterMs !== undefined) { - if (retryAfterMs * 1000 > maxDelayMs) return -1 - return retryAfterMs * 1000 - } - const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt - const jitter = exponential * 0.2 * Math.random() - return Math.min(exponential + jitter, maxDelayMs) -} - -function defaultUsage(): Usage { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - } -} - -function commandCodeUsage(event: Record): Record | undefined { - return isRecord(event.totalUsage) ? event.totalUsage : undefined -} - -function commandCodeInputTokenDetails( - usage: Record, -): Record | undefined { - return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined -} - -function headersToRecord(headers: Headers): Record { - const out: Record = {} - headers.forEach((value, key) => { - out[key] = value - }) - return out -} - -function abortError(message = "The operation was aborted"): DOMException { - return new DOMException(message, "AbortError") -} - -function timeoutError(timeoutMs: number | undefined): Error { - return new Error( - timeoutMs === undefined - ? "Command Code API request timed out" - : `Command Code API request timed out after ${timeoutMs}ms`, - ) -} - -function successStopReason(reason: TerminalReason): StopReason { - if (reason === "length" || reason === "toolUse") return reason - return "stop" -} - -function generateMaxTokens(model: ModelLike, options?: StreamOptions): number { - return Math.min( - options?.maxTokens ?? model.maxTokens, - model.maxTokens, - DEFAULT_GENERATE_MAX_TOKENS, - ) -} - -function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): string | undefined { - const level = options?.reasoning - if (!level || level === "off" || !model.reasoning) return undefined - - const effortMap = model.thinking?.effortMap ?? model.thinkingLevelMap - const mapped = effortMap?.[level] - return typeof mapped === "string" && mapped !== "off" ? mapped : undefined -} - -export function projectSlugFromPath(pathName: string): string { - const slug = pathName - .toLowerCase() - .replace(/^[a-z]:/i, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - return slug || "project" -} - -export function createStreamCommandCode(deps: CoreDependencies) { - const apiBase = deps.apiBase ?? DEFAULT_API_BASE - const fetchImpl = deps.fetchImpl ?? fetch - const cwd = deps.cwd ?? (() => process.cwd()) - const now = deps.now ?? (() => Date.now()) - const uuid = deps.uuid ?? (() => randomUUID()) - const delay = - deps.delay ?? - ((ms: number, signal: AbortSignal) => { - if (signal.aborted) return Promise.reject(abortError()) - return new Promise((resolve, reject) => { - const id = setTimeout(() => { - signal.removeEventListener("abort", onAbort) - resolve() - }, ms) - const onAbort = () => { - clearTimeout(id) - reject(abortError()) - } - signal.addEventListener("abort", onAbort, { once: true }) - }) - }) - - function raceAbort(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortError()) - - return new Promise((resolve, reject) => { - const onAbort = () => reject(abortError()) - signal.addEventListener("abort", onAbort, { once: true }) - promise.then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort) - reject(error) - }, - ) - }) - } - - function raceAbortWithTimeout( - promise: Promise, - controller: AbortController, - timeoutMs: number | undefined, - ): Promise { - if (timeoutMs === undefined) return raceAbort(promise, controller.signal) - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - controller.abort() - reject(timeoutError(timeoutMs)) - }, timeoutMs) - raceAbort(promise, controller.signal).then( - (value) => { - clearTimeout(timer) - resolve(value) - }, - (error: unknown) => { - clearTimeout(timer) - reject(error) - }, - ) - }) - } - - return function streamCommandCode( - model: ModelLike, - context: ContextLike, - options?: StreamOptions, - ): AssistantMessageEventStreamLike { - const stream = deps.createStream() - - async function run() { - // 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) - const totalInput = numberValue(usage.inputTokens) ?? 0 - const input = numberValue(details?.noCacheTokens) - const cacheRead = numberValue(details?.cacheReadTokens) ?? 0 - const cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0 - output.usage.input = input ?? Math.max(0, totalInput - cacheRead - cacheWrite) - output.usage.output = numberValue(usage.outputTokens) ?? 0 - output.usage.cacheRead = cacheRead - output.usage.cacheWrite = cacheWrite - output.usage.totalTokens = - output.usage.input + - output.usage.output + - output.usage.cacheRead + - output.usage.cacheWrite - deps.calculateCost(model, output.usage) - } - output.stopReason = mapFinishReason(event.finishReason) - finished = true - break - } - - case "error": { - const message = - commandCodeErrorMessage(event.error) ?? - commandCodeErrorMessage(event.message) ?? - "Stream error" - output.stopReason = "error" - output.errorMessage = message - throw new Error(message) - } - } - } - - try { - stream.push({ type: "start", partial: output }) - if (controller.signal.aborted) throw abortError("Aborted") - - const workingDir = cwd() - const threadId = uuid() - const reasoningEffort = mappedReasoningEffort(model, options) - const timeoutMs = options?.timeoutMs - - const allowImages = modelSupportsImageInput(model.id) - if (!allowImages) assertTextOnlyMessages(context.messages) - - let body: unknown = { - config: { - workingDir, - date: new Date(now()).toISOString().split("T")[0], - environment: getEnvironmentInfo(), - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], - }, - memory: null, - taste: null, - skills: null, - params: { - model: model.id, - messages: messagesToCC(context.messages, { allowImages }), - tools: toolsToJson(context.tools), - system: systemPromptToText(context.systemPrompt), - max_tokens: generateMaxTokens(model, options), - temperature: 0.3, - stream: true, - ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), - }, - threadId, - } - - const payloadController = new AbortController() - const onPayloadAbort = () => payloadController.abort() - controller.signal.addEventListener("abort", onPayloadAbort, { once: true }) - let nextBody: unknown - try { - nextBody = await raceAbortWithTimeout( - Promise.resolve(options?.onPayload?.(body, model)), - payloadController, - timeoutMs, - ) - } finally { - controller.signal.removeEventListener("abort", onPayloadAbort) - } - if (nextBody !== undefined) body = nextBody - - const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES - const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs) - const requestHeaders = { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "x-command-code-version": COMMAND_CODE_CLI_VERSION, - "x-cli-environment": "production", - "x-project-slug": projectSlugFromPath(workingDir), - "x-taste-learning": "true", - "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 }) - const raceAttempt = (promise: Promise): Promise => - raceAbort(promise, attemptController.signal).catch((error: unknown) => { - if (attemptTimedOut) throw timeoutError(timeoutMs) - throw error - }) - - try { - try { - response = await fetchImpl(`${apiBase}/alpha/generate`, { - method: "POST", - headers: requestHeaders, - body: bodyStr, - signal: attemptController.signal, - }) - } catch (fetchError: unknown) { - if (controller.signal.aborted) throw abortError("Aborted") - if (attemptTimedOut) { - if (attempt < maxRetries) continue retryLoop - throw timeoutError(timeoutMs) - } - throw fetchError - } - - // --- HTTP-level retry --- - if (!response.ok && isRetryableStatus(response.status)) { - const retryAfter = response.headers.get("retry-after") - const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs) - if (waitMs < 0) { - const requestedSeconds = parseRetryAfterSeconds(retryAfter) ?? 0 - const capLabel = - maxRetryDelayMs === Number.POSITIVE_INFINITY ? "disabled" : `${maxRetryDelayMs}ms` - throw new Error(`Retry-After delay ${requestedSeconds}s exceeds max ${capLabel}`) - } - if (attempt < maxRetries) { - await response.text().catch(() => "") - if (waitMs > 0) await delay(waitMs, controller.signal) - continue retryLoop - } - } - - try { - await raceAttempt( - Promise.resolve( - options?.onResponse?.( - { - status: response.status, - headers: headersToRecord(response.headers), - }, - model, - ), - ), - ) - } catch (error: unknown) { - if (attemptTimedOut && attempt < maxRetries) continue retryLoop - throw error - } - - if (!response.ok) { - const errBody = await raceAttempt(response.text().catch(() => "")) - let errorDetail: string | undefined - try { - const parsedBody: unknown = JSON.parse(errBody) - errorDetail = commandCodeErrorMessage(parsedBody) - } catch { - // Preserve useful plain-text provider errors only after secret - // redaction; upstream/proxy bodies may echo credentials. - } - const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500) - const detail = redactCommandCodeErrorText( - errorDetail ?? (safeBody || "Provider returned an error"), - ) - throw new Error(`Command Code API error ${response.status}: ${detail}`) - } - - // --- Read response stream --- - reader = response.body?.getReader() - if (!reader) throw new Error("No response body") - - const decoder = new TextDecoder() - let buffer = "" - - try { - readLoop: for (;;) { - if (controller.signal.aborted) throw abortError("Aborted") - const { done, value } = await raceAbort(reader.read(), attemptController.signal) - if (done) { - if (buffer.trim()) handleEvent(parseStreamEventLine(buffer)) - 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" - : redactCommandCodeErrorText(error instanceof Error ? error.message : String(error)) - stream.push({ type: "error", reason, error: output }) - stream.end() - } finally { - options?.signal?.removeEventListener("abort", abortUpstream) - try { - await reader?.cancel() - } catch { - // Reader may already be closed/cancelled. - } - try { - reader?.releaseLock() - } catch { - // Reader may already be released/cancelled by the abort path. - } - } - } - - run().catch((error: unknown) => { - const msg: AssistantMessageLike = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: defaultUsage(), - stopReason: "error", - errorMessage: redactCommandCodeErrorText( - error instanceof Error ? error.message : String(error), - ), - timestamp: now(), - } - stream.push({ type: "error", reason: "error", error: msg }) - stream.end() - }) - - return stream - } -} diff --git a/src/cost.ts b/src/cost.ts deleted file mode 100644 index 55c1bd0..0000000 --- a/src/cost.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Local cost calculation for Command Code usage. - * - * Mirrors pi-ai's `calculateCost` arithmetic exactly. The provider ships its - * own copy because Oh My Pi's legacy pi-ai shim does not export - * `calculateCost`, which broke extension installation there (issue #24). - * `tests/test-cost.ts` locks this implementation to the pi-ai original. - */ - -import type { ModelLike, Usage } from "./types.ts" - -export function calculateCommandCodeCost(model: ModelLike, usage: Usage): void { - const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite - let rates = model.cost - let matchedThreshold = -1 - for (const tier of model.cost.tiers ?? []) { - if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { - rates = tier - matchedThreshold = tier.inputTokensAbove - } - } - - const longWrite = usage.cacheWrite1h ?? 0 - const shortWrite = usage.cacheWrite - longWrite - usage.cost.input = (rates.input / 1_000_000) * usage.input - usage.cost.output = (rates.output / 1_000_000) * usage.output - usage.cost.cacheRead = (rates.cacheRead / 1_000_000) * usage.cacheRead - usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1_000_000 - usage.cost.total = - usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite -} diff --git a/src/json-schema.ts b/src/json-schema.ts deleted file mode 100644 index a2ffc02..0000000 --- a/src/json-schema.ts +++ /dev/null @@ -1,382 +0,0 @@ -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined -} - -function booleanValue(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined -} - -type JsonSchemaValue = boolean | Record - -const JSON_SCHEMA_TYPES = new Set([ - "array", - "boolean", - "integer", - "null", - "number", - "object", - "string", -]) - -const LEGACY_KINDS = new Set([ - "any", - "array", - "boolean", - "enum", - "integer", - "intersect", - "intersection", - "literal", - "never", - "null", - "nullable", - "number", - "object", - "optional", - "string", - "undefined", - "union", - "unknown", -]) - -const LEGACY_FIELDS = new Set([ - "element", - "kind", - "inner", - "optional", - "value", - "values", - "variants", - "wrapped", -]) - -const SCHEMA_MAP_FIELDS = new Set([ - "$defs", - "definitions", - "dependentSchemas", - "patternProperties", - "properties", -]) - -const SCHEMA_ARRAY_FIELDS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]) - -const SCHEMA_VALUE_FIELDS = new Set([ - "additionalItems", - "additionalProperties", - "contains", - "contentSchema", - "else", - "if", - "items", - "not", - "propertyNames", - "then", - "unevaluatedItems", - "unevaluatedProperties", -]) - -const SCHEMA_KEYWORDS = new Set([ - "$anchor", - "$comment", - "$defs", - "$dynamicAnchor", - "$dynamicRef", - "$id", - "$ref", - "$schema", - "$vocabulary", - "additionalItems", - "additionalProperties", - "allOf", - "anyOf", - "const", - "contains", - "contentEncoding", - "contentMediaType", - "contentSchema", - "default", - "definitions", - "dependentRequired", - "dependentSchemas", - "description", - "else", - "enum", - "examples", - "exclusiveMaximum", - "exclusiveMinimum", - "format", - "if", - "items", - "maxContains", - "maxItems", - "maxLength", - "maxProperties", - "maximum", - "minContains", - "minItems", - "minLength", - "minProperties", - "minimum", - "multipleOf", - "not", - "oneOf", - "pattern", - "patternProperties", - "prefixItems", - "properties", - "propertyNames", - "readOnly", - "required", - "title", - "type", - "unevaluatedItems", - "unevaluatedProperties", - "uniqueItems", - "writeOnly", -]) - -function stringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined - const values = value.filter((item): item is string => typeof item === "string") - return values.length === value.length ? values : undefined -} - -function validSchemaType(value: unknown): boolean { - if (typeof value === "string") return JSON_SCHEMA_TYPES.has(value) - if (!Array.isArray(value) || value.length === 0) return false - return value.every((item) => typeof item === "string" && JSON_SCHEMA_TYPES.has(item)) -} - -function legacyKind(schema: Record): string | undefined { - const explicitKind = stringValue(schema.kind)?.toLowerCase() - if (explicitKind && LEGACY_KINDS.has(explicitKind)) return explicitKind - - const type = stringValue(schema.type) - const normalized = type?.toLowerCase() - if (!normalized || !LEGACY_KINDS.has(normalized)) return undefined - if (!validSchemaType(type) || Object.keys(schema).some((key) => LEGACY_FIELDS.has(key))) { - return normalized - } - return undefined -} - -function looksLikeJsonSchema(schema: Record): boolean { - if (Object.keys(schema).length === 0) return true - if (schema.type !== undefined && !validSchemaType(schema.type)) return false - return Object.keys(schema).some((key) => SCHEMA_KEYWORDS.has(key)) -} - -function isOptionalSchema(schema: unknown): boolean { - if (!isRecord(schema)) return false - if (booleanValue(schema.optional) === true) return true - - const kind = legacyKind(schema) - if (kind === "optional") return true - if (kind !== "union") return false - - const variants = Array.isArray(schema.variants) - ? schema.variants - : Array.isArray(schema.anyOf) - ? schema.anyOf - : [] - return variants.some((variant) => legacyKind(isRecord(variant) ? variant : {}) === "undefined") -} - -function schemaValue(value: unknown, seen: WeakSet): JsonSchemaValue { - if (typeof value === "boolean") return value - if (!isRecord(value)) return {} - return convertSchema(value, seen) -} - -function setSchemaProperty(target: Record, key: string, value: unknown): void { - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - value, - writable: true, - }) -} - -function schemaMap(value: unknown, seen: WeakSet): Record { - if (!isRecord(value)) return {} - const out: Record = {} - for (const [key, item] of Object.entries(value)) { - setSchemaProperty(out, key, schemaValue(item, seen)) - } - return out -} - -function schemaArray(value: unknown, seen: WeakSet): unknown[] { - if (!Array.isArray(value)) return [] - return value.map((item) => schemaValue(item, seen)) -} - -function isSchemaValue(value: unknown): value is JsonSchemaValue { - return typeof value === "boolean" || isRecord(value) -} - -function copySchemaObject( - source: Record, - seen: WeakSet, - legacy: boolean, - forcedType?: string, -): JsonSchemaValue { - const out: Record = {} - - for (const [key, value] of Object.entries(source)) { - if (legacy && LEGACY_FIELDS.has(key)) continue - if (key === "nullable" || (forcedType !== undefined && key === "type")) continue - - if (key === "required") { - const required = stringArray(value) - if (required) out.required = required - } else if (SCHEMA_MAP_FIELDS.has(key)) { - out[key] = schemaMap(value, seen) - } else if (SCHEMA_ARRAY_FIELDS.has(key)) { - out[key] = schemaArray(value, seen) - } else if (SCHEMA_VALUE_FIELDS.has(key)) { - out[key] = - Array.isArray(value) && key === "items" - ? schemaArray(value, seen) - : schemaValue(value, seen) - } else { - out[key] = value - } - } - - if (forcedType !== undefined) out.type = forcedType - if (booleanValue(source.nullable) === true) return makeNullable(out) - return out -} - -function makeNullable(schema: Record): Record { - const type = schema.type - if (typeof type === "string") { - if (type === "null") return schema - return { ...schema, type: [type, "null"] } - } - if (Array.isArray(type) && !type.includes("null")) { - return { ...schema, type: [...type, "null"] } - } - if (Array.isArray(schema.anyOf)) { - return { ...schema, anyOf: [...schema.anyOf, { type: "null" }] } - } - return { anyOf: [schema, { type: "null" }] } -} - -function legacyVariants(schema: Record): unknown[] { - if (Array.isArray(schema.variants)) return schema.variants - if (Array.isArray(schema.anyOf)) return schema.anyOf - return [] -} - -function convertLegacySchema( - source: Record, - kind: string, - seen: WeakSet, -): JsonSchemaValue { - if (kind === "optional") return schemaValue(source.wrapped ?? source.inner, seen) - if (kind === "nullable") { - const wrapped = schemaValue(source.wrapped ?? source.inner, seen) - return typeof wrapped === "boolean" ? wrapped : makeNullable(wrapped) - } - if (kind === "undefined" || kind === "never" || kind === "any" || kind === "unknown") return {} - - if (kind === "union" || kind === "intersect" || kind === "intersection") { - const variants = legacyVariants(source) - .map((variant) => schemaValue(variant, seen)) - .filter( - (variant) => - isSchemaValue(variant) && - (typeof variant === "boolean" || Object.keys(variant).length > 0), - ) - if (variants.length === 0) return copySchemaObject(source, seen, true) - if (variants.length === 1) return variants[0] ?? {} - - const out = copySchemaObject(source, seen, true) - if (typeof out !== "boolean") out[kind === "union" ? "anyOf" : "allOf"] = variants - return out - } - - if (kind === "object") { - const converted = copySchemaObject(source, seen, true, "object") - if (typeof converted === "boolean") return converted - const out = converted - const sourceProperties = isRecord(source.properties) ? source.properties : undefined - if (!sourceProperties) return out - - const properties: Record = {} - const optional = stringArray(source.optional) ?? [] - for (const [key, value] of Object.entries(sourceProperties)) { - setSchemaProperty(properties, key, schemaValue(value, seen)) - } - out.properties = properties - - const explicitRequired = stringArray(source.required) - const required = - explicitRequired ?? - Object.entries(sourceProperties) - .filter(([key, value]) => !optional.includes(key) && !isOptionalSchema(value)) - .map(([key]) => key) - if (required.length > 0) out.required = required - else delete out.required - return out - } - - if (kind === "array") { - const converted = copySchemaObject(source, seen, true, "array") - if (typeof converted === "boolean") return converted - const out = converted - if (!("items" in source) && "element" in source) out.items = schemaValue(source.element, seen) - return out - } - - if (kind === "enum") { - const converted = copySchemaObject(source, seen, true) - if (typeof converted === "boolean") return converted - const out = converted - if (!("enum" in out) && Array.isArray(source.values)) out.enum = source.values - return out - } - - if (kind === "literal") { - const converted = copySchemaObject(source, seen, true) - if (typeof converted === "boolean") return converted - const out = converted - if (!("const" in out) && "value" in source) out.const = source.value - return out - } - - const scalarType = - kind === "string" || - kind === "number" || - kind === "boolean" || - kind === "integer" || - kind === "null" - ? kind - : undefined - return scalarType ? copySchemaObject(source, seen, true, scalarType) : {} -} - -function convertSchema(source: Record, seen: WeakSet): JsonSchemaValue { - if (seen.has(source)) return {} - seen.add(source) - try { - const kind = legacyKind(source) - if (kind) return convertLegacySchema(source, kind, seen) - if (!looksLikeJsonSchema(source)) return {} - return copySchemaObject(source, seen, false) - } finally { - seen.delete(source) - } -} - -export function toJsonSchema(schema: unknown): unknown { - if (typeof schema === "boolean") return schema - if (!isRecord(schema)) return {} - return convertSchema(schema, new WeakSet()) -} diff --git a/src/models.ts b/src/models.ts index cd470ef..b417c46 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,7 +1,9 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" import { dirname } from "node:path" +import type { Api } from "@earendil-works/pi-ai" -export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" +export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1" +export const DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models` export const DEFAULT_MODELS_TIMEOUT_MS = 10_000 const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 @@ -159,11 +161,22 @@ interface ApiModel { export interface CommandCodeModel { id: string name: string + api: Api reasoning: boolean contextWindow: number maxTokens: number } +export function apiForModelId(id: string): Api { + return id.startsWith("claude-") ? "anthropic-messages" : "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 +} + interface FetchCommandCodeModelsOptions { url?: string fetchImpl?: typeof fetch @@ -225,6 +238,7 @@ function parseCachedModel(value: unknown): CommandCodeModel { return { id, name: stringField(value, "name"), + api: apiForModelId(id), reasoning: isReasoningModel(id), contextWindow: positiveNumberField(value, "contextWindow"), maxTokens: positiveNumberField(value, "maxTokens"), @@ -329,6 +343,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma return data.map(parseApiModel).map((model) => ({ id: model.id, name: `${model.name} (CC)`, + api: apiForModelId(model.id), reasoning: isReasoningModel(model.id), contextWindow: model.contextLength, maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS), diff --git a/src/overflow.ts b/src/overflow.ts deleted file mode 100644 index 3e3e106..0000000 --- a/src/overflow.ts +++ /dev/null @@ -1,120 +0,0 @@ -const COMMAND_CODE_PROVIDER = "commandcode" -const CONTEXT_OVERFLOW_PREFIX = "context_length_exceeded:" - -const COMMAND_CODE_OVERFLOW_PATTERNS = [ - /\b(?:context[_\s-]*(?:length|window)|model[_\s-]*context[_\s-]*window)[_\s-]*(?:exceeded|overflow(?:ed)?|too[_\s-]*(?:large|long))\b/i, - /\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b[\s\S]{0,120}\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long)|(?:maximum|limit)\s+(?:reached|exceeded|hit))\b/i, - /\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long))\b[\s\S]{0,120}\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b/i, - /\b(?:prompt|input|context)\b[\s\S]{0,32}\btoo\s+(?:large|long)\b/i, - /\b(?:prompt|input)[_\s-]*too[_\s-]*(?:large|long)\b/i, - /\b(?:prompt|input)[_\s-]*tokens?[_\s-]*(?:limit|maximum|max)[_\s-]*(?:exceeded|reached)\b/i, - /\b(?:prompt|input)[_\s-]*(?:tokens?|length|size)\b[\s\S]{0,120}\b(?:limit|maximum)\b[\s\S]{0,40}\b(?:exceed(?:ed|s)?|reached|hit)\b/i, - /\b(?:maximum|limit)[_\s-]+(?:allowed[_\s-]+)?(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?)\b/i, -] - -const NON_OVERFLOW_PATTERNS = [ - /\brate[_\s-]*limit\b/i, - /\btoo\s+many\s+requests\b/i, - /\b(?:capacity|quota|throttl(?:e|ed|ing)?|concurren(?:cy|t)|overloaded)\b/i, - /\b(?:service|temporarily)\s+unavailable\b/i, - /\bstatus(?:[_\s-]*code)?\s*[:=]\s*429\b/i, -] - -const CONTEXT_OVERFLOW_PREFIX_PATTERN = /context_length_exceeded/i - -const HTTP_RATE_LIMIT_STATUS_PATTERNS = [ - /\b(?:api\s+error|http|status(?:[_\s-]*code)?|status[_\s-]*code)\s*[:(]?\s*429\b/i, - /["']?(?:status|status[_\s-]*code)["']?\s*:\s*429\b/i, -] - -const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi -const CREDENTIAL_PATTERN = - /\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*[^\s,;)]+/gi -const USER_TOKEN_PATTERN = /\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi -const QUERY_SECRET_PATTERN = - /([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|password)=)[^&#\s]+/gi -const STANDALONE_SECRET_PATTERN = - /\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b|\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g - -export function redactCommandCodeErrorText(value: string): string { - return value - .replace(BEARER_PATTERN, "Bearer [redacted]") - .replace(CREDENTIAL_PATTERN, (match) => { - const separatorIndex = match.search(/[=:]/) - return separatorIndex < 0 ? "[redacted]" : `${match.slice(0, separatorIndex + 1)}[redacted]` - }) - .replace(USER_TOKEN_PATTERN, "[redacted]") - .replace(QUERY_SECRET_PATTERN, "$1[redacted]") - .replace(STANDALONE_SECRET_PATTERN, "[redacted]") -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -export interface CommandCodeMessageLike { - role: string - provider: string - stopReason: string - errorMessage?: string -} - -export function commandCodeErrorMessage(value: unknown): string | undefined { - if (typeof value === "string") return value - if (!isRecord(value)) return undefined - - const record = value - const parts: string[] = [] - for (const key of [ - "message", - "errorMessage", - "error", - "detail", - "details", - "code", - "type", - "reason", - ]) { - const part = commandCodeErrorMessage(record[key]) - if (part && !parts.includes(part)) parts.push(part) - } - - for (const key of ["status", "statusCode", "httpStatus"]) { - const status = record[key] - if (typeof status === "string" || typeof status === "number") { - const statusPart = `status: ${status}` - if (!parts.includes(statusPart)) parts.push(statusPart) - } - } - - return parts.length > 0 ? redactCommandCodeErrorText(parts.join(": ")) : undefined -} - -export function normalizeCommandCodeErrorMessage( - errorMessage: string | undefined, -): string | undefined { - if (!errorMessage) return undefined - if (CONTEXT_OVERFLOW_PREFIX_PATTERN.test(errorMessage)) return undefined - if (NON_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) return undefined - if (HTTP_RATE_LIMIT_STATUS_PATTERNS.some((pattern) => pattern.test(errorMessage))) - return undefined - if (!COMMAND_CODE_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) - return undefined - - return `${CONTEXT_OVERFLOW_PREFIX} ${errorMessage}` -} - -export function normalizeCommandCodeMessage( - message: T, - modelProvider?: string, -): { message: T & { errorMessage: string } } | undefined { - if (message.role !== "assistant" || message.stopReason !== "error") return undefined - if (message.provider !== COMMAND_CODE_PROVIDER && modelProvider !== COMMAND_CODE_PROVIDER) { - return undefined - } - - const errorMessage = normalizeCommandCodeErrorMessage(message.errorMessage) - if (!errorMessage) return undefined - - return { message: { ...message, errorMessage } } -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index c520543..0000000 --- a/src/types.ts +++ /dev/null @@ -1,201 +0,0 @@ -export type StopReason = "stop" | "length" | "toolUse" -export type ErrorReason = "error" | "aborted" -export type TerminalReason = StopReason | ErrorReason - -export interface UsageCost { - input: number - output: number - cacheRead: number - cacheWrite: number - total: number -} - -export interface Usage { - input: number - output: number - cacheRead: number - cacheWrite: number - cacheWrite1h?: number - totalTokens: number - cost: UsageCost -} - -export interface TextContent { - type: "text" - text: string -} - -export interface ThinkingContent { - type: "thinking" - thinking: string -} - -export interface ToolCallContent { - type: "toolCall" - id: string - name: string - arguments: Record -} - -export type AssistantContent = TextContent | ThinkingContent | ToolCallContent - -export interface AssistantMessageLike { - role: "assistant" - content: AssistantContent[] - api: unknown - provider: string - model: string - usage: Usage - stopReason: TerminalReason - errorMessage?: string - timestamp: number -} - -export interface ModelCostRates { - input: number - output: number - cacheRead: number - cacheWrite: number -} - -export interface ModelCostTier extends ModelCostRates { - inputTokensAbove: number -} - -export interface ModelCost extends ModelCostRates { - tiers?: readonly ModelCostTier[] -} - -export interface ModelLike { - id: string - api: unknown - provider: string - maxTokens: number - cost: ModelCost - reasoning?: boolean - thinkingLevelMap?: Partial> - thinking?: { - mode?: "effort" - effortMap?: Partial> - efforts?: readonly string[] - } -} - -export interface MessageLike { - role: string - content?: unknown - toolCallId?: string - toolName?: string - isError?: boolean -} - -export interface ToolLike { - name: string - description?: string - parameters?: unknown -} - -export interface ContextLike { - systemPrompt?: string - messages?: readonly MessageLike[] - tools?: readonly ToolLike[] -} - -export interface ProviderResponseInfo { - status: number - headers: Record -} - -export interface StreamOptions { - apiKey?: string - signal?: AbortSignal - headers?: Record - maxTokens?: number - /** Resolved pi thinking level; forwarded only through the model's map. */ - reasoning?: string - onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise - onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise - /** - * HTTP request timeout in milliseconds. - * Applied per-attempt; on timeout the request is retried if retries remain. - */ - timeoutMs?: number - /** - * Maximum retry attempts for transient HTTP errors (429, 5xx). - * Default: 0 (pi agent-level retry handles visible retries when unset). - */ - maxRetries?: number - /** - * Maximum delay in milliseconds to wait for a retry when the server requests - * a long wait via Retry-After. If the server's requested delay exceeds this - * value, the request fails immediately. Default: 60000 (60 seconds). - * Set to 0 to disable the cap. - */ - maxRetryDelayMs?: number -} - -export type AssistantMessageEvent = - | { type: "start"; partial: AssistantMessageLike } - | { type: "text_start"; contentIndex: number; partial: AssistantMessageLike } - | { - type: "text_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "text_end" - contentIndex: number - content: string - partial: AssistantMessageLike - } - | { - type: "thinking_start" - contentIndex: number - partial: AssistantMessageLike - } - | { - type: "thinking_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "thinking_end" - contentIndex: number - content: string - partial: AssistantMessageLike - } - | { - type: "toolcall_start" - contentIndex: number - partial: AssistantMessageLike - } - | { - type: "toolcall_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 f4b3f65..0000000 --- a/tests/helpers.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { createServer, type IncomingHttpHeaders, type Server } from "node:http" - -import { - createStreamCommandCode, - type AssistantMessageEvent, - type AssistantMessageEventStreamLike, - type ContextLike, - type CoreDependencies, - type ModelLike, - type Usage, -} from "../src/core.ts" - -export function createTestEventStream(): AssistantMessageEventStreamLike { - const events: AssistantMessageEvent[] = [] - const waiters: Array<() => void> = [] - let ended = false - - const wake = () => { - const waiter = waiters.shift() - if (waiter) waiter() - } - - return { - push(event: AssistantMessageEvent) { - events.push(event) - wake() - }, - end() { - ended = true - while (waiters.length > 0) wake() - }, - [Symbol.asyncIterator]() { - let index = 0 - return { - async next(): Promise> { - while (index >= events.length && !ended) { - await new Promise((resolve) => waiters.push(resolve)) - } - if (index < events.length) { - const value = events[index] - index += 1 - return { done: false, value } - } - return { done: true, value: undefined } - }, - } - }, - } -} - -export async function collectEvents( - stream: AssistantMessageEventStreamLike, - timeoutMs = 2_000, -): Promise { - const events: AssistantMessageEvent[] = [] - - const collect = async () => { - for await (const event of stream) { - events.push(event) - if (event.type === "done" || event.type === "error") break - } - return events - } - - return await Promise.race([ - collect(), - new Promise((_, reject) => { - setTimeout( - () => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), - timeoutMs, - ) - }), - ]) -} - -export function makeModel(overrides: Partial = {}): ModelLike { - return { - id: "deepseek/deepseek-v4-flash", - api: "commandcode-custom", - provider: "commandcode", - maxTokens: 384_000, - cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - ...overrides, - } -} - -export function makeContext(overrides: Partial = {}): ContextLike { - return { - systemPrompt: "You are a test assistant.", - messages: [{ role: "user", content: "hello" }], - tools: [], - ...overrides, - } -} - -export interface TestDepsResult { - streamCommandCode: ReturnType - calculatedUsages: Usage[] -} - -export function createTestDeps(overrides: Partial = {}): TestDepsResult { - const calculatedUsages: Usage[] = [] - const streamCommandCode = createStreamCommandCode({ - createStream: createTestEventStream, - calculateCost: (_model, usage) => { - calculatedUsages.push({ - ...usage, - cost: { ...usage.cost }, - }) - }, - env: {}, - authPaths: [], - now: () => new Date("2026-05-05T12:00:00Z").getTime(), - uuid: () => "00000000-0000-4000-8000-000000000000", - cwd: () => "/repo", - delay: async () => {}, - ...overrides, - }) - return { streamCommandCode, calculatedUsages } -} - -type SuccessPlan = { - type: "success" - status?: number - events?: string[] - chunks?: string[] - delays?: number[] - hangAfterLast?: boolean - /** Delay in ms before the server starts sending the response. */ - responseDelay?: number -} - -type ErrorPlan = { - type: "error" - status: number - body: string - headers?: Record -} - -export type ResponsePlan = SuccessPlan | ErrorPlan - -function headersToRecord(headers: IncomingHttpHeaders): Record { - const out: Record = {} - for (const [key, value] of Object.entries(headers)) { - if (typeof value === "string") out[key] = value - else if (Array.isArray(value)) out[key] = value.join(", ") - } - return out -} - -export interface MockCommandCodeServer { - baseUrl(): string - mockResponse(plan: ResponsePlan): void - mockResponseQueue(plans: ResponsePlan[]): void - reset(): void - close(): Promise - lastRequestBody(): unknown - lastRequestHeaders(): Record - requestCount(): number - responseClosedBeforeEnd(): boolean -} - -export async function startMockCommandCodeServer(): Promise { - let planQueue: ResponsePlan[] = [{ type: "success", events: [] }] - let lastBody: unknown - let lastHeaders: Record = {} - let requests = 0 - let closedBeforeEnd = false - let port = 0 - - const server: Server = createServer((req, res) => { - if (req.method !== "POST" || req.url !== "/alpha/generate") { - res.writeHead(404) - res.end("Not found") - return - } - - requests += 1 - lastHeaders = headersToRecord(req.headers) - let body = "" - req.on("data", (chunk: Buffer) => { - body += chunk.toString("utf-8") - }) - req.on("end", () => { - try { - const parsed: unknown = JSON.parse(body) - lastBody = parsed - } catch { - lastBody = undefined - } - - // Pop the first plan from the queue; keep the last one as fallback. - const plan = planQueue.length > 1 ? planQueue.shift()! : planQueue[0] - - if (plan.type === "error") { - const headers: Record = { "Content-Type": "text/plain", ...plan.headers } - res.writeHead(plan.status, headers) - res.end(plan.body) - return - } - - res.writeHead(plan.status ?? 200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", - }) - - let ended = false - res.on("close", () => { - if (!ended) closedBeforeEnd = true - }) - - const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`) - const delays = plan.delays ?? chunks.map(() => 0) - let index = 0 - - const sendNext = () => { - if (index >= chunks.length) { - if (!plan.hangAfterLast) { - ended = true - res.end() - } - return - } - - res.write(chunks[index]) - index += 1 - if (index < chunks.length) { - setTimeout(sendNext, delays[index] ?? 0) - } else if (!plan.hangAfterLast) { - ended = true - res.end() - } - } - - if (plan.responseDelay) { - setTimeout(sendNext, plan.responseDelay) - } else { - sendNext() - } - }) - }) - - await new Promise((resolve) => { - server.listen(0, () => { - const address = server.address() - if (typeof address === "object" && address) port = address.port - resolve() - }) - }) - - return { - baseUrl: () => `http://127.0.0.1:${port}`, - mockResponse(plan: ResponsePlan) { - planQueue = [plan] - }, - mockResponseQueue(plans: ResponsePlan[]) { - planQueue = [...plans] - }, - reset() { - planQueue = [{ type: "success", events: [] }] - lastBody = undefined - lastHeaders = {} - requests = 0 - closedBeforeEnd = false - }, - close() { - return new Promise((resolve) => server.close(() => resolve())) - }, - lastRequestBody: () => lastBody, - lastRequestHeaders: () => lastHeaders, - requestCount: () => requests, - responseClosedBeforeEnd: () => closedBeforeEnd, - } -} - -export function objectAt(value: unknown, path: readonly string[]): unknown { - let current = value - for (const key of path) { - if (Array.isArray(current)) { - const index = Number(key) - if (!Number.isInteger(index)) return undefined - current = current[index] - continue - } - if (typeof current !== "object" || current === null) return undefined - current = Object.getOwnPropertyDescriptor(current, key)?.value - } - return current -} diff --git a/tests/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-cost.ts b/tests/test-cost.ts deleted file mode 100644 index ae7f730..0000000 --- a/tests/test-cost.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Regression test for the local cost calculation. - * - * The provider ships its own cost function because Oh My Pi's legacy pi-ai - * shim does not export `calculateCost` (see issue #24). This test locks the - * local implementation to pi-ai's documented per-million-token arithmetic - * without installing another pi-ai runtime next to the extension. - */ - -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { calculateCommandCodeCost } from "../src/cost.ts" -import type { Usage } from "../src/types.ts" - -interface CostRates { - input: number - output: number - cacheRead: number - cacheWrite: number -} - -interface CostTable extends CostRates { - tiers?: Array -} - -const COST_FIXTURES: Record = { - "zero-cost-model": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "deepseek/deepseek-v4-pro": { - input: 0.435, - output: 0.87, - cacheRead: 0.003625, - cacheWrite: 0, - }, - "Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, - "Qwen/Qwen3.7-Flash": { - input: 0.03, - output: 0.13, - cacheRead: 0.006, - cacheWrite: 0.038, - tiers: [ - { inputTokensAbove: 32_000, input: 0.1, output: 0.4, cacheRead: 0.02, cacheWrite: 0.125 }, - { inputTokensAbove: 256_000, input: 0.2, output: 0.8, cacheRead: 0.04, cacheWrite: 0.25 }, - ], - }, - "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, -} - -const USAGE_CASES = [ - { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 }, - { input: 812, output: 187, cacheRead: 52_000, cacheWrite: 3_100 }, - { input: 1_000_000, output: 65_536, cacheRead: 998_877, cacheWrite: 123_456 }, - { input: 7, output: 999_999_999, cacheRead: 0.5, cacheWrite: 42 }, -] - -function commandCodeModel(id: string, cost: CostTable) { - return { - id, - api: "commandcode-custom", - provider: "commandcode", - cost, - maxTokens: 65_536, - } -} - -function assertClose(actual: number, expected: number) { - assert.ok( - Math.abs(actual - expected) <= - Number.EPSILON * Math.max(1, Math.abs(actual), Math.abs(expected)), - `expected ${actual} to be close to ${expected}`, - ) -} - -function freshUsage(tokens: (typeof USAGE_CASES)[number]): Usage { - return { - ...tokens, - totalTokens: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - } -} - -function expectedCost(cost: CostTable, tokens: (typeof USAGE_CASES)[number]): Usage["cost"] { - const inputTokens = tokens.input + tokens.cacheRead + tokens.cacheWrite - let rates: CostRates = cost - let matchedThreshold = -1 - for (const tier of cost.tiers ?? []) { - if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { - rates = tier - matchedThreshold = tier.inputTokensAbove - } - } - - const input = (rates.input / 1_000_000) * tokens.input - const output = (rates.output / 1_000_000) * tokens.output - const cacheRead = (rates.cacheRead / 1_000_000) * tokens.cacheRead - const cacheWrite = (rates.cacheWrite * tokens.cacheWrite) / 1_000_000 - return { - input, - output, - cacheRead, - cacheWrite, - total: input + output + cacheRead + cacheWrite, - } -} - -describe("calculateCommandCodeCost()", () => { - it("applies per-million-token rates to all cost fields", () => { - for (const [id, cost] of Object.entries(COST_FIXTURES)) { - const model = commandCodeModel(id, cost) - - for (const tokens of USAGE_CASES) { - const usage = freshUsage(tokens) - calculateCommandCodeCost(model, usage) - - assert.deepEqual( - usage.cost, - expectedCost(cost, tokens), - `${id} cost for tokens=${JSON.stringify(tokens)}`, - ) - } - } - }) - - it("applies the highest request-wide input tier above its threshold", () => { - const model = commandCodeModel("Qwen/Qwen3.7-Flash", COST_FIXTURES["Qwen/Qwen3.7-Flash"]) - - const atThreshold = freshUsage({ - input: 32_000, - output: 1_000, - cacheRead: 0, - cacheWrite: 0, - }) - calculateCommandCodeCost(model, atThreshold) - assertClose(atThreshold.cost.input, (0.03 * 32_000) / 1_000_000) - - const aboveFirstTier = freshUsage({ - input: 30_000, - output: 1_000, - cacheRead: 2_001, - cacheWrite: 0, - }) - calculateCommandCodeCost(model, aboveFirstTier) - assertClose(aboveFirstTier.cost.input, (0.1 * 30_000) / 1_000_000) - assertClose(aboveFirstTier.cost.cacheRead, (0.02 * 2_001) / 1_000_000) - - const aboveHighestTier = freshUsage({ - input: 100_000, - output: 1_000, - cacheRead: 156_001, - cacheWrite: 0, - }) - calculateCommandCodeCost(model, aboveHighestTier) - assertClose(aboveHighestTier.cost.input, (0.2 * 100_000) / 1_000_000) - assertClose(aboveHighestTier.cost.output, (0.8 * 1_000) / 1_000_000) - }) - - it("prices one-hour cache writes at twice the active input rate", () => { - const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) - const usage = freshUsage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 1_000 }) - usage.cacheWrite1h = 400 - - calculateCommandCodeCost(model, usage) - - const expectedShortWrite = (3.75 * 600) / 1_000_000 - const expectedLongWrite = (3 * 2 * 400) / 1_000_000 - assertClose(usage.cost.cacheWrite, expectedShortWrite + expectedLongWrite) - }) - - it("writes the total as the sum of all cost components", () => { - const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) - const usage = freshUsage({ input: 1_000, output: 500, cacheRead: 10_000, cacheWrite: 2_000 }) - - calculateCommandCodeCost(model, usage) - - assert.equal( - usage.cost.total, - usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite, - ) - assert.ok(usage.cost.total > 0) - }) -}) diff --git a/tests/test-models.ts b/tests/test-models.ts index af96ebb..3c7e33c 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -5,6 +5,8 @@ import { join } from "node:path" import { describe, it } from "node:test" import { + apiForModelId, + baseUrlForModel, commandCodeModelsFromApiResponse, commandCodeModelsFromCache, DEFAULT_MODELS_TIMEOUT_MS, @@ -37,6 +39,7 @@ const EXPECTED_MODELS: readonly CommandCodeModel[] = [ { id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max (CC)", + api: "openai-completions", reasoning: false, contextWindow: 1_000_000, maxTokens: 65_536, @@ -84,6 +87,19 @@ describe("commandCodeModelsFromApiResponse()", () => { assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS) }) + it("routes Claude models to Anthropic Messages and all others to Chat Completions", () => { + assert.equal(apiForModelId("claude-sonnet-4-6"), "anthropic-messages") + assert.equal(apiForModelId("gpt-5.6-sol"), "openai-completions") + assert.equal( + baseUrlForModel("https://api.commandcode.ai/provider/v1/", "openai-completions"), + "https://api.commandcode.ai/provider/v1", + ) + assert.equal( + baseUrlForModel("https://api.commandcode.ai/provider/v1/", "anthropic-messages"), + "https://api.commandcode.ai/provider", + ) + }) + it("matches command-code@1.15.1 image input capabilities", () => { assert.deepEqual(inputModalitiesForModel("gpt-5.6-luna"), ["text", "image"]) assert.deepEqual(inputModalitiesForModel("meta/muse-spark-1.2"), ["text", "image"]) diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs index af7c2bd..9daae39 100644 --- a/tests/test-omp-compat.mjs +++ b/tests/test-omp-compat.mjs @@ -77,7 +77,7 @@ const server = createServer((req, res) => { return } - if (req.method !== "POST" || req.url !== "/alpha/generate") { + if (req.method !== "POST" || req.url !== "/provider/v1/chat/completions") { res.writeHead(404) res.end("Not found") return @@ -103,14 +103,19 @@ const server = createServer((req, res) => { } res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", + "Content-Type": "text/event-stream; charset=utf-8", "Transfer-Encoding": "chunked", }) - res.write(`${JSON.stringify({ type: "text-delta", text: "mock-omp-ok" })}\n`) res.write( - `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`, + `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: "mock-omp-ok" }, finish_reason: null }] })}\n\n`, ) - res.end() + res.write( + `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`, + ) + res.write( + `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`, + ) + res.end("data: [DONE]\n\n") }) }) @@ -129,7 +134,7 @@ function runOmp(args, timeoutMs = 30_000) { USERPROFILE: tempHome, PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"), COMMANDCODE_API_KEY: "mock-key", - COMMANDCODE_API_BASE: apiBase, + COMMANDCODE_API_BASE: `${apiBase}/provider/v1`, COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, }, stdio: ["ignore", "pipe", "pipe"], @@ -185,8 +190,8 @@ try { "Bearer mock-key", "should send the resolved env-var value, not the literal var name", ) - assert.equal(lastRequestBody?.params?.model, TEST_MODEL) - assert.equal(typeof lastRequestBody?.params?.system, "string") + assert.equal(lastRequestBody?.model, TEST_MODEL) + assert.ok(Array.isArray(lastRequestBody?.messages)) console.log("[omp-compat] PASS") } finally { diff --git a/tests/test-overflow.ts b/tests/test-overflow.ts deleted file mode 100644 index dc28888..0000000 --- a/tests/test-overflow.ts +++ /dev/null @@ -1,196 +0,0 @@ -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import { - commandCodeErrorMessage, - normalizeCommandCodeErrorMessage, - normalizeCommandCodeMessage, -} from "../src/overflow.ts" -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -describe("Command Code overflow normalization", () => { - it("normalizes Command Code context errors to pi's generic overflow marker", () => { - const normalized = normalizeCommandCodeErrorMessage("Prompt token limit exceeded") - - assert.equal(normalized, "context_length_exceeded: Prompt token limit exceeded") - }) - - it("is idempotent and leaves unrelated, rate-limit, and capacity errors unchanged", () => { - assert.equal( - normalizeCommandCodeErrorMessage("context_length_exceeded: Prompt token limit exceeded"), - undefined, - ) - assert.equal(normalizeCommandCodeErrorMessage("OpenAI request failed"), undefined) - assert.equal( - normalizeCommandCodeErrorMessage("Prompt token limit exceeded due to rate limit"), - undefined, - ) - assert.equal( - normalizeCommandCodeErrorMessage("Command Code API error 429: context window exceeded"), - undefined, - ) - assert.equal( - normalizeCommandCodeErrorMessage("context window exceeded: status: 429"), - undefined, - ) - assert.equal( - normalizeCommandCodeErrorMessage("The input is too long"), - "context_length_exceeded: The input is too long", - ) - assert.equal( - normalizeCommandCodeErrorMessage("Input exceeds context limit"), - "context_length_exceeded: Input exceeds context limit", - ) - assert.equal( - normalizeCommandCodeErrorMessage("Context window exceeded: provider capacity reached"), - undefined, - ) - }) - - it("scopes finalized message normalization to Command Code", () => { - const message = { - role: "assistant" as const, - provider: "commandcode", - stopReason: "error" as const, - errorMessage: "model context window exceeded", - } - - const normalized = normalizeCommandCodeMessage(message) - assert.equal( - normalized?.message.errorMessage, - "context_length_exceeded: model context window exceeded", - ) - assert.equal(normalizeCommandCodeMessage({ ...message, provider: "openai" }), undefined) - assert.equal(normalizeCommandCodeMessage({ ...message, stopReason: "stop" }), undefined) - }) - - it("extracts nested stream error messages without exposing credentials", () => { - assert.equal( - commandCodeErrorMessage({ - error: { details: { errorMessage: "context window exceeded" } }, - }), - "context window exceeded", - ) - }) - - it("redacts secrets from finalized provider errors", async () => { - server.mockResponse({ - type: "error", - status: 400, - body: "api_key=user_secret_value", - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.doesNotMatch(error.error.errorMessage ?? "", /user_secret_value/) - assert.match(error.error.errorMessage ?? "", /api_key=\[redacted\]/) - }) - - it("normalizes HTTP error bodies containing nested context errors", async () => { - server.mockResponse({ - type: "error", - status: 400, - body: JSON.stringify({ error: { message: "Prompt token limit exceeded" } }), - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - const error = events.at(-1) - - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - const normalized = normalizeCommandCodeMessage(error.error) - assert.match(normalized?.message.errorMessage ?? "", /^context_length_exceeded:/) - }) - - it("does not normalize an HTTP rate-limit response that mentions context", async () => { - server.mockResponse({ - type: "error", - status: 429, - body: JSON.stringify({ error: { message: "context window exceeded" } }), - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - const error = events.at(-1) - - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(normalizeCommandCodeMessage(error.error), undefined) - }) - - it("normalizes nested stream error events", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: { details: { message: "model context window exceeded" } }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - const error = events.at(-1) - - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - const normalized = normalizeCommandCodeMessage(error.error) - assert.equal( - normalized?.message.errorMessage, - "context_length_exceeded: model context window exceeded", - ) - - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: { message: "context window exceeded", status: 429 }, - }), - ], - }) - const retryEvents = await collectEvents( - createTestDeps({ apiBase: server.baseUrl() }).streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - }), - ) - const retryError = retryEvents.at(-1) - assert.equal(retryError?.type, "error") - if (retryError?.type !== "error") throw new Error("expected error") - assert.equal(normalizeCommandCodeMessage(retryError.error), undefined) - }) -}) diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 5099d08..760a100 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -15,7 +15,8 @@ import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const PROJECT_DIR = resolve(__dirname, "..") const EXT_PATH = resolve(PROJECT_DIR, "index.ts") -const TEST_MODEL = "deepseek/deepseek-v4-flash" +const TEST_MODEL = "gpt-5.4" +const CLAUDE_TEST_MODEL = "claude-sonnet-4-6" function findPiBinary() { if (process.env.PI_BIN) return process.env.PI_BIN @@ -63,9 +64,17 @@ function modelCatalog() { object: "model", created: 1779824324, owned_by: "command-code", - name: "DeepSeek V4 Flash", + name: "GPT 5.4", context_length: 1_000_000, }, + { + id: CLAUDE_TEST_MODEL, + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "Claude Sonnet 4.6", + context_length: 200_000, + }, { id: "cc-second-model", object: "model", @@ -101,7 +110,9 @@ const server = createServer((req, res) => { return } - if (req.method !== "POST" || req.url !== "/alpha/generate") { + const isOpenAIRequest = req.method === "POST" && req.url === "/provider/v1/chat/completions" + const isAnthropicRequest = req.method === "POST" && req.url === "/provider/v1/messages" + if (!isOpenAIRequest && !isAnthropicRequest) { res.writeHead(404) res.end("Not found") return @@ -129,12 +140,20 @@ const server = createServer((req, res) => { if (overflowMode && overflowRequestCount === 2) { res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }) - res.end(JSON.stringify({ error: { message: "Input exceeds context limit" } })) + res.end( + JSON.stringify({ + error: { + message: "Input exceeds context limit", + type: "invalid_request_error", + code: "context_length_exceeded", + }, + }), + ) return } res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", + "Content-Type": "text/event-stream; charset=utf-8", "Transfer-Encoding": "chunked", }) const text = overflowMode @@ -144,11 +163,36 @@ const server = createServer((req, res) => { ? "compaction-summary" : "overflow-recovered" : "mock-pi-ok" - res.write(`${JSON.stringify({ type: "text-delta", text })}\n`) + if (isAnthropicRequest) { + res.write( + `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "mock", type: "message", role: "assistant", content: [], model: CLAUDE_TEST_MODEL, stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } })}\n\n`, + ) + res.write( + `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`, + ) + res.write( + `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } })}\n\n`, + ) + res.write( + `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`, + ) + res.write( + `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } })}\n\n`, + ) + res.end(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`) + return + } + res.write( - `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`, + `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] })}\n\n`, ) - res.end() + res.write( + `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`, + ) + res.write( + `data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`, + ) + res.end("data: [DONE]\n\n") }) }) @@ -170,8 +214,9 @@ const env = { USERPROFILE: tempHome, PI_CODING_AGENT_DIR: agentDir, PI_CODING_AGENT_SESSION_DIR: join(tempHome, "sessions"), - COMMANDCODE_API_BASE: apiBase, + COMMANDCODE_API_BASE: `${apiBase}/provider/v1`, COMMANDCODE_API_KEY: "mock-key", + COMMANDCODE_ZDR: "1", COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, } @@ -403,7 +448,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) { event.type === "extension_ui_request" && event.method === "notify" && typeof event.message === "string" && - event.message.includes("model count: 2"), + event.message.includes("model count: 3"), ) includeRefreshedModel = true @@ -414,7 +459,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) { event.type === "extension_ui_request" && event.method === "notify" && typeof event.message === "string" && - event.message.includes("3 models from live"), + event.message.includes("4 models from live"), ) send({ id: "status-after", type: "prompt", message: "/commandcode-status" }) @@ -426,7 +471,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) { event.type === "extension_ui_request" && event.method === "notify" && typeof event.message === "string" && - event.message.includes("model count: 3"), + event.message.includes("model count: 4"), ) return { @@ -565,7 +610,7 @@ try { ) assert.equal(recoveryList.code, 0, recoveryList.stderr) const recoveryOutput = recoveryList.stdout || recoveryList.stderr - assert.match(recoveryOutput, /deepseek\/deepseek-v4-flash/) + assert.match(recoveryOutput, /gpt-5\.4/) assert.match(recoveryOutput, /cc-second-model/) assert.doesNotMatch(recoveryList.stderr, /no valid cached catalog/) assert.doesNotMatch(recoveryList.stderr, /Failed to load extension/) @@ -578,7 +623,7 @@ try { assert.equal(list.code, 0, list.stderr) const listOutput = list.stdout || list.stderr assert.match(listOutput, /commandcode/) - assert.match(listOutput, /deepseek\/deepseek-v4-flash/) + assert.match(listOutput, /gpt-5\.4/) assert.match(listOutput, /cc-second-model/) assert.equal(modelListRequestCount, 1) assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK)) @@ -591,7 +636,7 @@ try { ) assert.equal(offlineList.code, 0, offlineList.stderr) const offlineListOutput = offlineList.stdout || offlineList.stderr - assert.match(offlineListOutput, /deepseek\/deepseek-v4-flash/) + assert.match(offlineListOutput, /gpt-5\.4/) assert.match(offlineListOutput, /cc-second-model/) assert.match(offlineList.stderr, /Using the cached catalog/) @@ -659,27 +704,51 @@ try { lastRequestHeaders.authorization.startsWith("Bearer "), "should send a bearer Authorization header", ) - assert.equal(lastRequestBody?.params?.model, TEST_MODEL) - assert.equal(lastRequestBody?.params?.reasoning_effort, "high") - const sentTools = lastRequestBody?.params?.tools + assert.equal(lastRequestHeaders["x-cmd-zdr"], "1") + assert.equal(lastRequestBody?.model, TEST_MODEL) + assert.equal(lastRequestBody?.reasoning_effort, "high") + const sentTools = lastRequestBody?.tools assert.ok(Array.isArray(sentTools) && sentTools.length > 0) - const editTool = sentTools.find((tool) => tool.name === "edit") - assert.equal(editTool?.input_schema?.type, "object") - assert.equal(editTool?.input_schema?.properties?.edits?.type, "array") - assert.equal(editTool?.input_schema?.properties?.edits?.items?.type, "object") + const editTool = sentTools.find((tool) => tool.function?.name === "edit") + assert.equal(editTool?.function?.parameters?.type, "object") + assert.equal(editTool?.function?.parameters?.properties?.edits?.type, "array") + assert.equal(editTool?.function?.parameters?.properties?.edits?.items?.type, "object") assert.equal( - editTool?.input_schema?.properties?.edits?.items?.properties?.oldText?.type, + editTool?.function?.parameters?.properties?.edits?.items?.properties?.oldText?.type, "string", ) + console.log("[pi-local] Claude request through Anthropic Messages endpoint") + requestCount = 0 + const claudePrint = await runPi( + [ + "--no-extensions", + "-e", + EXT_PATH, + "-p", + "say mock token", + "--provider", + "commandcode", + "--model", + CLAUDE_TEST_MODEL, + ], + 30_000, + ) + assert.equal(claudePrint.code, 0, claudePrint.stderr) + assert.match(claudePrint.stdout, /mock-pi-ok/) + assert.equal(requestCount, 1) + assert.equal(lastRequestBody?.model, CLAUDE_TEST_MODEL) + assert.equal(lastRequestHeaders["x-api-key"], "mock-key") + assert.equal(lastRequestHeaders["x-cmd-zdr"], "1") + console.log("[pi-local] runtime commands through real RPC extension lifecycle") includeRefreshedModel = false const runtimeCommands = await runRpcExtensionCommands() assert.ok(runtimeCommands.commandNames.includes("commandcode-refresh")) assert.ok(runtimeCommands.commandNames.includes("commandcode-status")) assert.match(runtimeCommands.statusBefore, /source: live/) - assert.match(runtimeCommands.refreshNotification, /3 models from live/) - assert.match(runtimeCommands.statusAfter, /model count: 3/) + assert.match(runtimeCommands.refreshNotification, /4 models from live/) + assert.match(runtimeCommands.statusAfter, /model count: 4/) assert.doesNotMatch( `${runtimeCommands.statusBefore}\n${runtimeCommands.statusAfter}\n${runtimeCommands.stderr}`, /mock-key/, @@ -702,7 +771,7 @@ try { assert.equal(rpc.sawTextDelta, true) assert.equal(requestCount, 1) - console.log("[pi-local] reject image input through real RPC preflight/provider path") + console.log("[pi-local] forward image input through the documented provider schema") requestCount = 0 const imageRpc = await runRpcQuery(10_000, "describe image", [], { images: [ @@ -713,14 +782,15 @@ try { }, ], }) - assert.equal(requestCount, 0) + assert.equal(imageRpc.ok, true, imageRpc.stderr) + assert.equal(requestCount, 1) + const imageContent = lastRequestBody?.messages?.find( + (message) => message.role === "user", + )?.content + assert.ok(Array.isArray(imageContent), JSON.stringify(lastRequestBody?.messages)) assert.ok( - imageRpc.events.some( - (event) => - event.type === "message_end" && - event.message?.role === "assistant" && - event.message?.stopReason === "error", - ) || imageRpc.stderr.includes("does not support image"), + imageContent.some((part) => part.type === "image_url"), + JSON.stringify(imageContent), ) console.log("[pi-local] verify overflow normalization and compaction recovery") @@ -729,8 +799,7 @@ try { const overflowRpc = await runRpcOverflowRecovery() assert.equal(overflowRpc.ok, true) assert.ok(overflowRpc.requests >= 4) - assert.equal(overflowRpc.sawNormalizedOverflow, true) - assert.equal(overflowRpc.sawCompactionRetry, true) + assert.equal(overflowRpc.sawCompactionRetry, true, JSON.stringify(overflowRpc)) assert.equal(overflowRpc.stderrHasSecrets, false) overflowMode = false diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts deleted file mode 100644 index 136025f..0000000 --- a/tests/test-pure-functions.ts +++ /dev/null @@ -1,658 +0,0 @@ -/** - * Unit tests for the real pure helpers exported by src/core.ts. - * These are hermetic: no pi runtime and no network. - */ - -import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { describe, it } from "node:test" - -import { - assertTextOnlyMessages, - getApiKey, - getEnvironmentInfo, - mapFinishReason, - messagesToCC, - parseStreamEventLine, - projectSlugFromPath, - textContent, - toJsonSchema, - toolsToJson, -} from "../src/core.ts" -import { redactCommandCodeErrorText } from "../src/overflow.ts" - -import { objectAt } from "./helpers.ts" - -describe("getApiKey()", () => { - it("uses COMMANDCODE_API_KEY from provided env", () => { - assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key") - }) - - it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) - try { - const first = join(dir, "first.json") - const second = join(dir, "second.json") - const oauth = join(dir, "oauth.json") - const official = join(dir, "official.json") - writeFileSync(first, JSON.stringify({ apiKey: "file-key" })) - writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })) - writeFileSync( - oauth, - JSON.stringify({ - commandcode: { - type: "oauth", - access: "oauth-access-key", - refresh: "oauth-refresh-key", - expires: Date.now() + 3600000, - }, - }), - ) - writeFileSync( - official, - JSON.stringify({ - "command-code": { - type: "api", - key: "official-cli-key", - }, - }), - ) - assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key") - assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key") - assert.equal(getApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key") - assert.equal(getApiKey({ env: {}, authPaths: [official] }), "official-cli-key") - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it("ignores malformed auth files", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-")) - try { - const bad = join(dir, "bad.json") - writeFileSync(bad, "not json") - assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it("uses injected homeDir for default auth paths", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-home-")) - try { - const authDir = join(dir, ".pi", "agent") - mkdirSync(authDir, { recursive: true }) - writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" })) - assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key") - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) - -describe("error redaction", () => { - it("redacts bearer, credential, and query-string secrets", () => { - const redacted = redactCommandCodeErrorText( - "Bearer user_secret_value api_key=user_secret_value https://example.test/x?token=user_secret_value", - ) - assert.doesNotMatch(redacted, /user_secret_value/) - assert.match(redacted, /Bearer \[redacted\]/) - assert.doesNotMatch( - redactCommandCodeErrorText("provider returned sk-test-secret-value-1234567890"), - /sk-test-secret-value/, - ) - }) -}) - -describe("projectSlugFromPath()", () => { - it("matches the official CLI-style slug from an absolute working directory", () => { - assert.equal( - projectSlugFromPath("/Users/patwoz/dev/Personal/pi/pi-commandcode-provider"), - "users-patwoz-dev-personal-pi-pi-commandcode-provider", - ) - assert.equal(projectSlugFromPath("/repo"), "repo") - }) -}) - -describe("text-only image handling", () => { - it("rejects image content for models without image support", () => { - assert.throws( - () => - assertTextOnlyMessages([ - { - role: "user", - content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], - }, - ]), - /does not support image content/i, - ) - assert.throws( - () => - assertTextOnlyMessages([ - { - role: "toolResult", - toolCallId: "c1", - content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], - }, - ]), - /does not support image content/i, - ) - }) -}) - -describe("textContent()", () => { - it("extracts and joins text blocks", () => { - assert.equal( - textContent({ - content: [ - { type: "text", text: "hello" }, - { type: "text", text: "world" }, - ], - }), - "hello\nworld", - ) - }) - - it("extracts text while images are handled separately", () => { - assert.equal( - textContent({ - content: [ - { type: "text", text: "hello" }, - { type: "image", data: "x", mimeType: "image/png" }, - { type: "text", text: "world" }, - ], - }), - "hello\nworld", - ) - }) - - it("handles empty or missing content", () => { - assert.equal(textContent({ content: [] }), "") - assert.equal(textContent({}), "") - }) -}) - -describe("getEnvironmentInfo()", () => { - it("returns platform, arch, and Node version", () => { - const info = getEnvironmentInfo() - assert.match(info, /^(darwin|linux|win32)-/) - assert.ok(info.includes("Node.js")) - }) -}) - -describe("toJsonSchema()", () => { - it("converts scalar, enum, object, optional, array, and union schema shapes", () => { - assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" }) - assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" }) - assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" }) - assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), { - type: "string", - enum: ["left", "right"], - }) - assert.deepEqual( - toJsonSchema({ - kind: "object", - properties: { - name: { kind: "string" }, - tags: { kind: "array", items: { kind: "string" }, optional: true }, - }, - }), - { - type: "object", - properties: { - name: { type: "string" }, - tags: { type: "array", items: { type: "string" } }, - }, - required: ["name"], - }, - ) - assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), { - type: "string", - }) - assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), { - type: "number", - }) - }) - - it("preserves explicit required arrays and handles unknown values", () => { - assert.deepEqual( - toJsonSchema({ - type: "object", - properties: { name: { type: "string" }, nickname: { type: "string" } }, - required: ["name"], - }), - { - type: "object", - properties: { name: { type: "string" }, nickname: { type: "string" } }, - required: ["name"], - }, - ) - assert.deepEqual(toJsonSchema(undefined), {}) - assert.deepEqual(toJsonSchema({ kind: "wat" }), {}) - assert.deepEqual(toJsonSchema({ type: "wat", description: "not a schema" }), {}) - assert.deepEqual(toJsonSchema({}), {}) - assert.equal(toJsonSchema(true), true) - }) - - it("preserves complete JSON Schema metadata and nested schemas", () => { - assert.deepEqual( - toJsonSchema({ - type: "object", - description: "Search options", - properties: { - query: { - type: "string", - description: "Text to search for", - minLength: 2, - maxLength: 50, - pattern: "^[a-z]+$", - default: "pi", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 100, - exclusiveMinimum: 0, - multipleOf: 1, - default: 10, - }, - tags: { - type: "array", - minItems: 1, - maxItems: 3, - uniqueItems: true, - items: { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - additionalProperties: false, - }, - }, - }, - required: ["query", "limit"], - additionalProperties: false, - }), - { - type: "object", - description: "Search options", - properties: { - query: { - type: "string", - description: "Text to search for", - minLength: 2, - maxLength: 50, - pattern: "^[a-z]+$", - default: "pi", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 100, - exclusiveMinimum: 0, - multipleOf: 1, - default: 10, - }, - tags: { - type: "array", - minItems: 1, - maxItems: 3, - uniqueItems: true, - items: { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - additionalProperties: false, - }, - }, - }, - required: ["query", "limit"], - additionalProperties: false, - }, - ) - }) - - it("preserves JSON Schema composition and nullable forms", () => { - assert.deepEqual( - toJsonSchema({ - anyOf: [{ type: "string" }, { type: "number" }], - oneOf: [{ const: "a" }, { const: "b" }], - allOf: [{ minLength: 1 }, { maxLength: 10 }], - nullable: true, - }), - { - anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], - oneOf: [{ const: "a" }, { const: "b" }], - allOf: [{ minLength: 1 }, { maxLength: 10 }], - }, - ) - assert.deepEqual(toJsonSchema({ type: ["string", "null"] }), { - type: ["string", "null"], - }) - assert.deepEqual(toJsonSchema({ type: "string", nullable: true }), { - type: ["string", "null"], - }) - }) - - it("preserves dangerous schema property names", () => { - const inputProperties: Record = { - constructor: { type: "number" }, - } - Object.defineProperty(inputProperties, "__proto__", { - configurable: true, - enumerable: true, - value: { type: "string" }, - writable: true, - }) - const schema = toJsonSchema({ - type: "object", - properties: inputProperties, - required: ["__proto__", "constructor"], - }) - assert.ok(schema && typeof schema === "object" && !Array.isArray(schema)) - if (!schema || typeof schema !== "object" || Array.isArray(schema)) { - throw new Error("expected object schema") - } - const outputProperties: unknown = Object.getOwnPropertyDescriptor(schema, "properties")?.value - assert.ok(outputProperties && typeof outputProperties === "object") - if (!outputProperties || typeof outputProperties !== "object") { - throw new Error("expected object properties") - } - assert.ok(Object.prototype.hasOwnProperty.call(outputProperties, "__proto__")) - assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "__proto__")?.value, { - type: "string", - }) - assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "constructor")?.value, { - type: "number", - }) - }) - - it("converts legacy shapes without collapsing unions", () => { - assert.deepEqual( - toJsonSchema({ - kind: "Object", - description: "Legacy options", - properties: { - mode: { - kind: "union", - variants: [ - { kind: "string", enum: ["fast", "safe"] }, - { kind: "string", enum: ["debug"] }, - ], - }, - count: { kind: "Number", minimum: 1, optional: true }, - nested: { - kind: "Array", - element: { kind: "object", properties: { value: { kind: "boolean" } } }, - }, - }, - optional: ["count"], - additionalProperties: false, - }), - { - type: "object", - description: "Legacy options", - properties: { - mode: { - anyOf: [ - { type: "string", enum: ["fast", "safe"] }, - { type: "string", enum: ["debug"] }, - ], - }, - count: { type: "number", minimum: 1 }, - nested: { - type: "array", - items: { - type: "object", - properties: { value: { type: "boolean" } }, - required: ["value"], - }, - }, - }, - required: ["mode", "nested"], - additionalProperties: false, - }, - ) - assert.deepEqual( - toJsonSchema({ - kind: "intersect", - variants: [{ kind: "object", properties: { a: { kind: "string" } } }, { kind: "number" }], - }), - { - allOf: [ - { type: "object", properties: { a: { type: "string" } }, required: ["a"] }, - { type: "number" }, - ], - }, - ) - }) -}) - -describe("toolsToJson()", () => { - it("converts pi tools to Command Code tool JSON", () => { - assert.deepEqual( - toolsToJson([ - { - name: "get_weather", - description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - ]), - [ - { - type: "function", - name: "get_weather", - description: "Get weather", - input_schema: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"], - }, - }, - ], - ) - }) - - it("returns an empty array for missing tools", () => { - assert.deepEqual(toolsToJson(), []) - }) -}) - -describe("messagesToCC()", () => { - it("converts user, assistant, and tool result messages", () => { - const result = messagesToCC([ - { role: "user", content: "read /tmp/test" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "I will read" }, - { type: "text", text: "Sure" }, - { - type: "toolCall", - id: "c1", - name: "read", - arguments: { path: "/tmp/test" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - isError: false, - content: [ - { type: "text", text: "hello" }, - { type: "text", text: "world" }, - ], - }, - ]) - - assert.equal(objectAt(result, ["0", "role"]), "user") - assert.equal(objectAt(result, ["1", "role"]), "assistant") - assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") - assert.equal(objectAt(result, ["1", "content", "1", "type"]), "tool-call") - assert.equal(objectAt(result, ["1", "content", "2"]), undefined) - assert.equal(objectAt(result, ["2", "role"]), "tool") - assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld") - }) - - it("serializes image inputs in the current Command Code wire format", () => { - assert.deepEqual( - messagesToCC( - [ - { - role: "user", - content: [ - { type: "text", text: "inspect this" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, - ], - }, - ], - { allowImages: true }, - ), - [ - { - role: "user", - content: [ - { type: "text", text: "inspect this" }, - { - type: "image", - image: "data:image/png;base64,aGVsbG8=", - mimeType: "image/png", - }, - ], - }, - ], - ) - }) - - it("preserves tool-result images as a following user image message", () => { - const result = messagesToCC( - [ - { role: "user", content: "read image" }, - { - role: "assistant", - content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - content: [ - { type: "text", text: "image attached" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }, - ], - }, - ], - { allowImages: true }, - ) - - assert.equal(objectAt(result, ["2", "role"]), "tool") - assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "image attached") - assert.deepEqual(objectAt(result, ["3"]), { - role: "user", - content: [ - { - type: "image", - image: "data:image/jpeg;base64,aGVsbG8=", - mimeType: "image/jpeg", - }, - ], - }) - }) - - it("drops previous assistant reasoning while preserving text and tool calls", () => { - const result = messagesToCC([ - { role: "user", content: "first question" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "private reasoning from turn one" }, - { type: "text", text: "first answer" }, - ], - }, - { role: "user", content: "follow-up question" }, - ]) - - assert.deepEqual(result, [ - { role: "user", content: "first question" }, - { role: "assistant", content: [{ type: "text", text: "first answer" }] }, - { role: "user", content: "follow-up question" }, - ]) - }) - - it("omits assistant turns that contain only previous reasoning", () => { - const result = messagesToCC([ - { role: "user", content: "first question" }, - { - role: "assistant", - content: [{ type: "thinking", thinking: "private reasoning" }], - }, - { role: "user", content: "follow-up question" }, - ]) - - assert.deepEqual(result, [ - { role: "user", content: "first question" }, - { role: "user", content: "follow-up question" }, - ]) - }) - - it("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-runtime.ts b/tests/test-runtime.ts index c8b0af0..397c54e 100644 --- a/tests/test-runtime.ts +++ b/tests/test-runtime.ts @@ -49,6 +49,7 @@ class CommandContext implements CommandCodeCommandContext { const FIRST_MODEL: CommandCodeModel = { id: "first-model", name: "First Model", + api: "openai-completions", reasoning: true, contextWindow: 128_000, maxTokens: 16_384, @@ -57,6 +58,7 @@ const FIRST_MODEL: CommandCodeModel = { const SECOND_MODEL: CommandCodeModel = { id: "second-model", name: "Second Model", + api: "openai-completions", reasoning: true, contextWindow: 256_000, maxTokens: 32_768, diff --git a/tests/test-stream.ts b/tests/test-stream.ts deleted file mode 100644 index e30ac49..0000000 --- a/tests/test-stream.ts +++ /dev/null @@ -1,771 +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 { MODEL_EFFORTS, thinkingLevelMapForEfforts } from "../src/models.ts" -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - objectAt, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -function eventTypes(events: readonly AssistantMessageEvent[]): string[] { - return events.map((event) => event.type) -} - -describe("streamCommandCode — auth", () => { - it("emits a missing-key error without touching the network", async () => { - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: {}, - authPaths: [], - }) - const stream = streamCommandCode(makeModel(), makeContext(), { - apiKey: "", - }) - const events = await collectEvents(stream) - - assert.deepEqual(eventTypes(events), ["error"]) - assert.equal(events[0].type, "error") - assert.equal(events[0].reason, "error") - assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/) - assert.equal(server.requestCount(), 0) - }) - - it("ignores the literal env-var name and falls back to env", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: { COMMANDCODE_API_KEY: "env-key" }, - }) - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "COMMANDCODE_API_KEY" }), - ) - - assert.equal( - server.lastRequestHeaders().authorization, - "Bearer env-key", - "should resolve from env, not send the literal var name as the token", - ) - }) - - it("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: 3124, - outputTokens: 15, - inputTokenDetails: { noCacheTokens: 52, cacheReadTokens: 3072 }, - }, - }), - ], - }) - const { streamCommandCode, calculatedUsages } = createTestDeps({ - apiBase: server.baseUrl(), - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "text_start", - "text_delta", - "text_delta", - "text_end", - "done", - ]) - const done = events.at(-1) - assert.equal(done?.type, "done") - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "stop") - assert.equal(done.message.content[0]?.type, "text") - assert.equal( - done.message.content[0]?.type === "text" ? done.message.content[0].text : "", - "Hello", - ) - assert.equal(done.message.usage.input, 52) - assert.equal(done.message.usage.cacheRead, 3072) - assert.equal(done.message.usage.cacheWrite, 0) - assert.equal(done.message.usage.totalTokens, 3139) - assert.equal(calculatedUsages.length, 1) - }) - - it("sends images for vision-capable models", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode( - makeModel({ id: "gpt-5.6-luna" }), - makeContext({ - messages: [ - { - role: "user", - content: [ - { type: "text", text: "inspect" }, - { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, - ], - }, - ], - }), - { apiKey: "mock-key" }, - ), - ) - - assert.equal( - objectAt(server.lastRequestBody(), ["params", "messages", "0", "content", "1", "image"]), - "data:image/png;base64,aGVsbG8=", - ) - }) - - it("rejects images before network access for text-only models", async () => { - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode( - makeModel({ id: "deepseek/deepseek-v4-pro" }), - makeContext({ - messages: [ - { - role: "user", - content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], - }, - ], - }), - { apiKey: "mock-key" }, - ), - ) - - assert.equal(events.at(-1)?.type, "error") - assert.equal(server.requestCount(), 0) - }) - - it("derives uncached input when noCacheTokens is missing", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 100, - outputTokens: 10, - inputTokenDetails: { cacheReadTokens: 75 }, - }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - assert.equal(done?.type, "done") - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.message.usage.input, 25) - assert.equal(done.message.usage.cacheRead, 75) - assert.equal(done.message.usage.cacheWrite, 0) - assert.equal(done.message.usage.totalTokens, 110) - }) - - it("accounts for cache writes separately from uncached input", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 100, - outputTokens: 10, - inputTokenDetails: { - noCacheTokens: 20, - cacheReadTokens: 70, - cacheWriteTokens: 10, - }, - }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - assert.equal(done?.type, "done") - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.message.usage.input, 20) - assert.equal(done.message.usage.cacheRead, 70) - assert.equal(done.message.usage.cacheWrite, 10) - assert.equal(done.message.usage.totalTokens, 110) - }) - - it("ends on finish without waiting for an open upstream connection", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "done" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - hangAfterLast: true, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - 500, - ) - - assert.equal(events.at(-1)?.type, "done") - await new Promise((resolve) => setTimeout(resolve, 50)) - assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body") - }) - - it("emits reasoning and tool-call blocks in order", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "think" }), - JSON.stringify({ type: "reasoning-end" }), - JSON.stringify({ type: "text-delta", text: "Using tool" }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: JSON.stringify({ path: "/tmp/x" }), - }), - JSON.stringify({ type: "finish", finishReason: "tool-calls" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "text_start", - "text_delta", - "text_end", - "toolcall_start", - "toolcall_end", - "done", - ]) - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "toolUse") - assert.deepEqual( - done.message.content.map((content) => content.type), - ["thinking", "text", "toolCall"], - ) - const toolCall = done.message.content[2] - assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file") - }) - - it("flushes reasoning if finish arrives without reasoning-end", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-delta", text: "unfinished thought" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const done = events.at(-1) - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.message.content[0]?.type, "thinking") - }) - - it("closes thinking block before text when reasoning-end is missing", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "thinking" }), - JSON.stringify({ type: "text-delta", text: "answer" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "text_start", - "text_delta", - "text_end", - "done", - ]) - }) - - it("closes thinking block before tool-call when reasoning-end is missing", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-start" }), - JSON.stringify({ type: "reasoning-delta", text: "thinking" }), - JSON.stringify({ - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: JSON.stringify({ path: "/tmp/x" }), - }), - JSON.stringify({ type: "finish", finishReason: "tool-calls" }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "thinking_start", - "thinking_delta", - "thinking_end", - "toolcall_start", - "toolcall_end", - "done", - ]) - }) -}) - -describe("streamCommandCode — request serialization", () => { - it("rejects image input before sending a lossy request", async () => { - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const events = await collectEvents( - streamCommandCode( - makeModel(), - makeContext({ - messages: [ - { - role: "user", - content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], - }, - ], - }), - { apiKey: "mock-key" }, - ), - ) - - assert.deepEqual(eventTypes(events), ["start", "error"]) - const lastEvent = events.at(-1) - assert.equal(lastEvent?.type, "error") - if (lastEvent?.type === "error") { - assert.match(lastEvent.error.errorMessage ?? "", /does not support image content/i) - } - assert.equal(server.requestCount(), 0) - }) - it("sends the expected request body and default headers", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const context = makeContext({ - messages: [ - { role: "user", content: "first" }, - { - role: "assistant", - content: [{ type: "text", text: "first response" }], - }, - { role: "user", content: "second" }, - ], - tools: [ - { - name: "get_weather", - description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - ], - }) - - await collectEvents( - streamCommandCode(makeModel(), context, { - apiKey: "mock-key", - maxTokens: 500, - }), - ) - - const body = server.lastRequestBody() - assert.equal(objectAt(body, ["config", "workingDir"]), "/repo") - assert.equal(objectAt(body, ["config", "date"]), "2026-05-05") - assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash") - assert.equal(objectAt(body, ["params", "stream"]), true) - assert.equal(objectAt(body, ["params", "max_tokens"]), 500) - assert.equal(objectAt(body, ["params", "reasoning_effort"]), undefined) - assert.equal(objectAt(body, ["params", "temperature"]), 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"], "1.15.1") - 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("accepts the legacy OMP nested reasoning map", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const model = makeModel({ - id: "omp-compat-reasoning-model", - reasoning: true, - thinking: { effortMap: { high: "legacy-high" } }, - }) - - await collectEvents( - streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "high" }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "legacy-high") - }) - - it("forwards a supported Pi reasoning level as reasoning_effort", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const model = makeModel({ - id: "deepseek/deepseek-v4-flash", - reasoning: true, - thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), - }) - - await collectEvents( - streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "max" }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "max") - }) - - it("omits reasoning_effort for off, unsupported, and unknown reasoning levels", async () => { - const model = makeModel({ - id: "deepseek/deepseek-v4-flash", - reasoning: true, - thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), - }) - - for (const reasoning of ["off", "low"] as const) { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning }), - ) - assert.equal( - objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), - undefined, - `${reasoning} should not be sent when it has no supported Command Code field`, - ) - server.reset() - } - - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - await collectEvents( - streamCommandCode( - makeModel({ id: "new-model-without-metadata", reasoning: false }), - makeContext(), - { apiKey: "mock-key", reasoning: "high" }, - ), - ) - assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), undefined) - }) - - it("caps maxTokens and passes custom headers", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), { - apiKey: "mock-key", - maxTokens: 500_000, - headers: { "x-custom": "value" }, - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 64_000) - assert.equal(server.lastRequestHeaders()["x-custom"], "value") - }) - - it("caps default maxTokens by the selected model", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode(makeModel({ maxTokens: 8_192 }), makeContext(), { - apiKey: "mock-key", - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 8_192) - }) - - it("serializes OMP system prompt arrays as a string", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - await collectEvents( - streamCommandCode( - makeModel(), - makeContext({ - systemPrompt: ["You are a test assistant.", "Use concise answers."] as unknown as string, - }), - { apiKey: "mock-key" }, - ), - ) - - assert.equal( - objectAt(server.lastRequestBody(), ["params", "system"]), - "You are a test assistant.\n\nUse concise answers.", - ) - }) - - it("times out a hung onResponse callback", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const started = Date.now() - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - timeoutMs: 25, - onResponse: async () => new Promise(() => {}), - }), - 1_000, - ) - - assert.ok(Date.now() - started < 500) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /timed out after 25ms/) - }) - - it("times out a hung onPayload callback", async () => { - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const started = Date.now() - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - timeoutMs: 25, - onPayload: async () => new Promise(() => {}), - }), - 1_000, - ) - - assert.ok(Date.now() - started < 500) - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /timed out after 25ms/) - assert.equal(server.requestCount(), 0) - }) - - it("runs onPayload and onResponse hooks", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - let responseStatus = 0 - - await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - onPayload: () => ({ replaced: true }), - onResponse: (response) => { - responseStatus = response.status - }, - }), - ) - - assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true) - assert.equal(responseStatus, 200) - }) -}) - -describe("streamCommandCode — upstream errors and malformed streams", () => { - it("emits error for HTTP failures", async () => { - server.mockResponse({ type: "error", status: 429, body: "rate limited" }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), ["start", "error"]) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.match(error.error.errorMessage ?? "", /429/) - }) - - it("emits error for provider error events", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ - type: "error", - error: { message: "provider failed" }, - }), - ], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.error.errorMessage, "provider failed") - }) - - it("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", - ) - }) -}) From 7af77ddf58875b22620ba3eebff07b4bed1de8b1 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 10:56:11 +0200 Subject: [PATCH 03/13] fix(models): refresh expired display pricing --- src/pricing.ts | 40 ++----------------------- tests/fixtures/commandcode-pricing.json | 10 +++---- tests/test-pricing.ts | 16 ++++++---- 3 files changed, 18 insertions(+), 48 deletions(-) diff --git a/src/pricing.ts b/src/pricing.ts index aa801c0..f13a69e 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -20,7 +20,7 @@ export interface TemporaryPricing { } export const PRICING_SOURCE_URL = "https://commandcode.ai/docs/resources/pricing-limits" -export const PRICING_LAST_VERIFIED = "2026-08-04" +export const PRICING_LAST_VERIFIED = "2026-08-18" export const ZERO_MODEL_COST: CommandCodeModelCost = { input: 0, @@ -158,37 +158,8 @@ export const MODEL_COSTS: Readonly> = { // OpenAI "gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, - // Discounted rates through 2026-08-14. - "gpt-5.6-terra": { - input: 1, - output: 6, - cacheRead: 0.1, - cacheWrite: 1.25, - tiers: [ - { - inputTokensAbove: 272_000, - input: 2, - output: 9, - cacheRead: 0.2, - cacheWrite: 2.5, - }, - ], - }, - "gpt-5.6-luna": { - input: 0.1, - output: 0.6, - cacheRead: 0.01, - cacheWrite: 0.125, - tiers: [ - { - inputTokensAbove: 272_000, - input: 0.2, - output: 0.9, - cacheRead: 0.02, - cacheWrite: 0.25, - }, - ], - }, + "gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, + "gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, "gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, @@ -213,11 +184,6 @@ export const MODEL_COSTS: Readonly> = { } export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [ - { - models: ["gpt-5.6-terra", "gpt-5.6-luna"], - expiresOn: "2026-08-14", - description: "50% promotional rates", - }, { models: ["claude-sonnet-5"], expiresOn: "2026-08-31", diff --git a/tests/fixtures/commandcode-pricing.json b/tests/fixtures/commandcode-pricing.json index deaee75..786cec2 100644 --- a/tests/fixtures/commandcode-pricing.json +++ b/tests/fixtures/commandcode-pricing.json @@ -1,5 +1,5 @@ { - "verifiedAt": "2026-08-04", + "verifiedAt": "2026-08-18", "source": "https://commandcode.ai/docs/resources/pricing-limits", "tierPolicy": "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.", "tiers": { @@ -7,9 +7,7 @@ "Qwen/Qwen3.7-Flash": [ [32000, 0.1, 0.4, 0.02, 0.125], [256000, 0.2, 0.8, 0.04, 0.25] - ], - "gpt-5.6-terra": [[272000, 2, 9, 0.2, 2.5]], - "gpt-5.6-luna": [[272000, 0.2, 0.9, 0.02, 0.25]] + ] }, "costs": { "poolside/laguna-s-2.1-free": [0, 0, 0, 0], @@ -52,8 +50,8 @@ "claude-opus-4-7": [5, 25, 0.5, 6.25], "claude-haiku-4-5-20251001": [1, 5, 0.1, 1.25], "gpt-5.6-sol": [5, 30, 0.5, 6.25], - "gpt-5.6-terra": [1, 6, 0.1, 1.25], - "gpt-5.6-luna": [0.1, 0.6, 0.01, 0.125], + "gpt-5.6-terra": [2, 12, 0.2, 2.5], + "gpt-5.6-luna": [0.2, 1.2, 0.02, 0.25], "gpt-5.5": [5, 30, 0.5, 0], "gpt-5.4": [2.5, 15, 0.25, 0], "gpt-5.3-codex": [2, 8, 0.5, 0], diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts index a782a73..c463fcf 100644 --- a/tests/test-pricing.ts +++ b/tests/test-pricing.ts @@ -148,16 +148,22 @@ describe("MODEL_COSTS pricing overlay", () => { cacheWrite: 0.038, }) assertCost("gpt-5.6-terra", { - input: 1, - output: 6, - cacheRead: 0.1, - cacheWrite: 1.25, + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 2.5, + }) + assertCost("gpt-5.6-luna", { + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, }) }) it("tracks pricing provenance", () => { assert.equal(PRICING_SOURCE_URL, "https://commandcode.ai/docs/resources/pricing-limits") - assert.equal(PRICING_LAST_VERIFIED, "2026-08-04") + assert.equal(PRICING_LAST_VERIFIED, "2026-08-18") }) it("fails once temporary pricing needs review", () => { From c04f3907b5c0046dab505e7c3835bb223eafc639 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 10:56:12 +0200 Subject: [PATCH 04/13] docs(api): document provider api migration --- CHANGELOG.md | 6 ++++++ README.md | 24 ++++++++++++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cae0a0..27b09fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Replace the reverse-engineered `/alpha/generate` integration with Command Code's documented Provider API: `/provider/v1/chat/completions` for non-Claude models and `/provider/v1/messages` for Claude models. +- Use Pi's native OpenAI- and Anthropic-compatible providers for streaming, tools, reasoning, images, usage, errors, and retries while preserving dynamic model discovery, offline cache, refresh/status commands, pricing, and OAuth credentials. +- Let `/login` use browser authentication, an explicit API-key prompt, or a directly pasted API key. +- Add optional zero-data-retention headers through `CMD_ZDR=1` or `COMMANDCODE_ZDR=1`. +- Refresh GPT-5.6 Terra and Luna display prices after their temporary 50% promotion ended. + ## 0.5.1 - 2026-08-11 - Add model-specific image input capabilities from the `command-code@1.15.1` catalog and forward user and tool-result images using the current Command Code wire format. diff --git a/README.md b/README.md index 91b8085..41802fb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,15 @@ A custom provider for [pi](https://github.com/earendil-works/pi) that connects to the [Command Code](https://commandcode.ai) Provider API. -> **Disclaimer:** This is an unofficial, community-maintained integration. It is not affiliated with, endorsed by, or supported by Command Code. You need your own Command Code account and API key or subscription. Command Code's terms, availability, and pricing apply. +> **Disclaimer:** This is an unofficial, community-maintained integration. It is not affiliated with, endorsed by, or supported by Command Code. You need your own Command Code account, API key, and a plan with Provider API access. Command Code's terms, availability, and pricing apply. + +The extension uses only Command Code's documented Provider API endpoints: + +- `GET /provider/v1/models` +- `POST /provider/v1/chat/completions` for non-Claude models +- `POST /provider/v1/messages` for Claude models + +Every current Command Code plan except **Go** has Provider API access: GOAT, Pro, Max, Team, and Provider. ## Install @@ -19,7 +27,7 @@ Start or reload pi, then authenticate: /login ``` -Select **Use a subscription**, then **Command Code**. Complete the browser flow and choose a model with `/model`. +Select **Use a subscription**, then **Command Code**. Choose browser login or paste an API key, then select a model with `/model`. ## Oh My Pi @@ -33,9 +41,9 @@ Restart OMP or run `/reload`, then use `/login` and select **Use a subscription* ## Authentication -### Browser login +### Login dialog -Run `/login` in pi or OMP. Select **Use a subscription**, then **Command Code**. The browser flow stores the returned credential in the host's auth file. +Run `/login` in pi or OMP. Select **Use a subscription**, then **Command Code**. Press Enter for browser login, type `key` to open a paste prompt, or paste the API key directly. The selected credential is stored in the host's auth file. Select Command Code in pi's login dialog @@ -84,9 +92,7 @@ Open `/model` and select one of the models provided by Command Code. Model avail ### Reasoning support -Reasoning metadata is enriched only for models whose Command Code effort support is known. Those models register a model-specific `thinkingLevelMap`, so pi and OMP expose only supported levels. A selected supported level is sent as the documented `params.reasoning_effort` field; `off`, unsupported levels, and newly discovered models without metadata do not add reasoning fields to the request. No prompt instructions are injected. - -Reasoning blocks from completed assistant turns remain visible in pi's local session, but are not replayed to Command Code in later requests. Only the assistant's user-visible text and completed tool calls are sent back as history. This matches the current Command Code CLI behavior and prevents prior private reasoning traces from interfering with reasoning on follow-up turns. +Reasoning metadata is enriched only for models whose Command Code effort support is known. Those models register a model-specific `thinkingLevelMap`, so pi and OMP expose only supported levels. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level to the endpoint's documented reasoning fields. Unsupported levels and newly discovered models without metadata do not claim reasoning support. List Command Code models from the terminal: @@ -123,6 +129,8 @@ While pi is running, use these provider commands without restarting: - `/commandcode-refresh` fetches and re-registers the current model catalog. Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active. - `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning. +Set `CMD_ZDR=1` or `COMMANDCODE_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header. + The following environment variables are intended for tests, local mocks, and compatible API endpoints: - `COMMANDCODE_API_BASE` @@ -134,7 +142,7 @@ The following environment variables are intended for tests, local mocks, and com The provider advertises image input only for models marked with the `image` input modality in the official Command Code CLI model catalog. The capability snapshot currently follows `command-code@1.15.1`; unknown models default to text-only until their upstream metadata is reviewed. -For vision-capable models, image blocks from user messages and tool results are forwarded in Command Code's current data-URL wire format. Text-only models reject image content before making a network request instead of silently dropping it. +For vision-capable models, Pi's native provider adapters forward image blocks from user messages and tool results using the documented OpenAI or Anthropic message schema. Unknown and text-only models remain marked text-only in Pi. ## Pricing display From 864538e146e72179970cf09ca76dc13f1e065c1f Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 11:01:12 +0200 Subject: [PATCH 05/13] fix(ci): satisfy provider audit checks --- .gitleaks.toml | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- index.ts | 2 +- src/models.ts | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.gitleaks.toml b/.gitleaks.toml index e1c2d97..dcd3149 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -41,5 +41,5 @@ title = "pi-commandcode-provider secret scan" [[rules]] id = "pi-test-api-key" description = "Test API key value that looks real" - regex = '''(user_testKey|mock-key|fake-key|test-api-key)''' + regex = '''['"](user_testKey|mock-key|fake-key|test-api-key)['"]''' tags = ["pi-extension", "test"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b09fe..887e50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - Replace the reverse-engineered `/alpha/generate` integration with Command Code's documented Provider API: `/provider/v1/chat/completions` for non-Claude models and `/provider/v1/messages` for Claude models. - Use Pi's native OpenAI- and Anthropic-compatible providers for streaming, tools, reasoning, images, usage, errors, and retries while preserving dynamic model discovery, offline cache, refresh/status commands, pricing, and OAuth credentials. - Let `/login` use browser authentication, an explicit API-key prompt, or a directly pasted API key. -- Add optional zero-data-retention headers through `CMD_ZDR=1` or `COMMANDCODE_ZDR=1`. +- Add optional zero-data-retention headers through `COMMANDCODE_ZDR=1`. - Refresh GPT-5.6 Terra and Luna display prices after their temporary 50% promotion ended. ## 0.5.1 - 2026-08-11 diff --git a/README.md b/README.md index 41802fb..1ac02a6 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ While pi is running, use these provider commands without restarting: - `/commandcode-refresh` fetches and re-registers the current model catalog. Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active. - `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning. -Set `CMD_ZDR=1` or `COMMANDCODE_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header. +Set `COMMANDCODE_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header. The following environment variables are intended for tests, local mocks, and compatible API endpoints: diff --git a/index.ts b/index.ts index 552e3ee..36fa7bd 100644 --- a/index.ts +++ b/index.ts @@ -29,7 +29,7 @@ import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" import { createCommandCodeRuntime } from "./src/runtime.ts" function commandCodeHeaders(): Record | undefined { - if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") { + if (process.env.COMMANDCODE_ZDR === "1") { return { "x-cmd-zdr": "1" } } return undefined diff --git a/src/models.ts b/src/models.ts index b417c46..3f8c7e9 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,6 +1,5 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" import { dirname } from "node:path" -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` @@ -9,6 +8,7 @@ export const DEFAULT_MODELS_TIMEOUT_MS = 10_000 const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 const MODEL_CACHE_VERSION = 1 +export type CommandCodeApi = "openai-completions" | "anthropic-messages" export type CommandCodeInputType = "text" | "image" /** @@ -161,17 +161,17 @@ interface ApiModel { export interface CommandCodeModel { id: string name: string - api: Api + api: CommandCodeApi reasoning: boolean contextWindow: number maxTokens: number } -export function apiForModelId(id: string): Api { +export function apiForModelId(id: string): CommandCodeApi { return id.startsWith("claude-") ? "anthropic-messages" : "openai-completions" } -export function baseUrlForModel(apiBase: string, api: Api): string { +export function baseUrlForModel(apiBase: string, api: CommandCodeApi): string { const normalized = apiBase.replace(/\/+$/g, "") if (api !== "anthropic-messages") return normalized return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized From 89dc21bed23a98ed9b8986ea86a8b69e11ee3375 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 12:11:47 +0200 Subject: [PATCH 06/13] feat(api): fall back for go plan accounts --- index.ts | 30 +- package.json | 9 +- src/converters.ts | 296 ++++++++++++++ src/core.ts | 741 +++++++++++++++++++++++++++++++++ src/cost.ts | 31 ++ src/json-schema.ts | 382 +++++++++++++++++ src/overflow.ts | 120 ++++++ src/transport.ts | 139 +++++++ src/types.ts | 202 +++++++++ tests/helpers.ts | 289 +++++++++++++ tests/test-abort.ts | 85 ++++ tests/test-cost.ts | 183 +++++++++ tests/test-overflow.ts | 196 +++++++++ tests/test-pure-functions.ts | 658 ++++++++++++++++++++++++++++++ tests/test-retry.ts | 470 +++++++++++++++++++++ tests/test-stream.ts | 771 +++++++++++++++++++++++++++++++++++ tests/test-transport.ts | 183 +++++++++ 17 files changed, 4782 insertions(+), 3 deletions(-) create mode 100644 src/converters.ts create mode 100644 src/core.ts create mode 100644 src/cost.ts create mode 100644 src/json-schema.ts create mode 100644 src/overflow.ts create mode 100644 src/transport.ts create mode 100644 src/types.ts create mode 100644 tests/helpers.ts create mode 100644 tests/test-abort.ts create mode 100644 tests/test-cost.ts create mode 100644 tests/test-overflow.ts create mode 100644 tests/test-pure-functions.ts create mode 100644 tests/test-retry.ts create mode 100644 tests/test-stream.ts create mode 100644 tests/test-transport.ts diff --git a/index.ts b/index.ts index 36fa7bd..d263138 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,8 @@ * https://api.commandcode.ai/provider/v1 */ +import { AssistantMessageEventStream } from "@earendil-works/pi-ai" +import { streamSimple as streamNativeProvider } from "@earendil-works/pi-ai/compat" import { getAgentDir, type ExtensionAPI, @@ -14,6 +16,8 @@ import { import { join } from "node:path" import { getConfiguredApiKey } from "./src/api-key.ts" +import { createStreamCommandCode } from "./src/core.ts" +import { calculateCommandCodeCost } from "./src/cost.ts" import { baseUrlForModel, DEFAULT_MODELS_URL, @@ -25,8 +29,10 @@ import { type CommandCodeModel, } from "./src/models.ts" import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts" +import { normalizeCommandCodeMessage } from "./src/overflow.ts" import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" import { createCommandCodeRuntime } from "./src/runtime.ts" +import { createCommandCodeTransportRouter } from "./src/transport.ts" function commandCodeHeaders(): Record | undefined { if (process.env.COMMANDCODE_ZDR === "1") { @@ -38,6 +44,7 @@ function commandCodeHeaders(): Record | undefined { function createProviderConfig( models: readonly CommandCodeModel[], apiBase: string, + streamCommandCode: ProviderConfig["streamSimple"], ): ProviderConfig { const headers = commandCodeHeaders() return { @@ -45,6 +52,7 @@ function createProviderConfig( baseUrl: apiBase, apiKey: getConfiguredApiKey() ?? "$COMMANDCODE_API_KEY", api: "openai-completions", + streamSimple: streamCommandCode, headers, oauth: { name: "Command Code", @@ -82,12 +90,32 @@ function createProviderConfig( } } +function legacyApiBase(providerApiBase: string): string { + return providerApiBase.replace(/\/provider\/v1\/?$/, "") +} + export default async function (pi: ExtensionAPI) { const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_PROVIDER_API_BASE const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL const modelsTimeoutMs = getModelsTimeoutMs() const modelsCachePath = process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json") + const streamGenerate = createStreamCommandCode({ + createStream: () => new AssistantMessageEventStream(), + calculateCost: calculateCommandCodeCost, + apiBase: legacyApiBase(apiBase), + }) + const transport = createCommandCodeTransportRouter({ + createStream: () => new AssistantMessageEventStream(), + streamProvider: streamNativeProvider, + streamGenerate, + }) + + pi.on("message_end", async (event, ctx) => { + if (event.message.role !== "assistant") return + const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider) + return normalized ? { message: normalized.message } : undefined + }) const runtime = createCommandCodeRuntime(pi, { endpoint: modelsUrl, @@ -98,7 +126,7 @@ export default async function (pi: ExtensionAPI) { cachePath: modelsCachePath, timeoutMs: modelsTimeoutMs, }), - createProviderConfig: (models) => createProviderConfig(models, apiBase), + createProviderConfig: (models) => createProviderConfig(models, apiBase, transport.stream), }) await runtime.initialize() diff --git a/package.json b/package.json index 9af81b6..c99a0aa 100644 --- a/package.json +++ b/package.json @@ -29,18 +29,23 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-oauth.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", "pi:isolated": "node scripts/pi-isolated.mjs", "pi:authenticated": "node scripts/pi-authenticated.mjs", - "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-oauth.ts", + "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts", "test:api-key": "tsx tests/test-api-key.ts", "test:models": "tsx tests/test-models.ts", "test:runtime": "tsx tests/test-runtime.ts", "test:pricing": "tsx tests/test-pricing.ts", "test:oauth": "tsx tests/test-oauth.ts", + "test:abort": "tsx tests/test-abort.ts", + "test:overflow": "tsx tests/test-overflow.ts", + "test:stream": "tsx tests/test-stream.ts", + "test:retry": "tsx tests/test-retry.ts", + "test:transport": "tsx tests/test-transport.ts", "test:pi-isolated": "node tests/test-pi-isolated.mjs", "test:pi-authenticated": "node tests/test-pi-authenticated.mjs", "test:pi-local": "node tests/test-pi-local.mjs", diff --git a/src/converters.ts b/src/converters.ts new file mode 100644 index 0000000..4012fbb --- /dev/null +++ b/src/converters.ts @@ -0,0 +1,296 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +import type { MessageLike, StopReason, ToolLike } from "./types.ts" +import { toJsonSchema } from "./json-schema.ts" + +export { toJsonSchema } from "./json-schema.ts" + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +export function recordArray(value: unknown): readonly Record[] { + if (!Array.isArray(value)) return [] + return value.filter(isRecord) +} + +export function recordOrEmpty(value: unknown): Record { + if (isRecord(value)) return value + if (typeof value === "string") { + try { + const parsed: unknown = JSON.parse(value) + if (isRecord(parsed)) return parsed + } catch { + // Some providers stream incomplete JSON argument fragments. + } + } + return {} +} + +export function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function defaultAuthPaths(home: string): string[] { + return [ + join(home, ".commandcode", "auth.json"), + join(home, ".omp", "agent", "auth.json"), + join(home, ".pi", "agent", "auth.json"), + ] +} + +function apiKeyFromCredentialRecord(value: unknown): string | undefined { + if (!isRecord(value)) return undefined + + const type = stringValue(value.type) + if (type === "api") return stringValue(value.key) + if (type === "oauth") return stringValue(value.access) + + return stringValue(value.key) ?? stringValue(value.access) +} + +function imageParts(value: unknown): readonly Record[] { + if (isRecord(value)) return value.type === "image" ? [value] : [] + return recordArray(value).filter((part) => part.type === "image") +} + +function imageContentError(role: string): Error { + return new Error(`Selected Command Code model does not support image content in ${role}`) +} + +export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void { + for (const message of messages ?? []) { + if (imageParts(message.content).length > 0) { + const role = message.role === "toolResult" ? "tool results" : `${message.role} messages` + throw imageContentError(role) + } + } +} + +function imageToCommandCode(part: Record): Record { + const data = stringValue(part.data) + const mimeType = stringValue(part.mimeType) + if (!data || !mimeType) + throw new Error("Invalid image content: expected base64 data and mimeType") + + return { + type: "image", + image: `data:${mimeType};base64,${data}`, + mimeType, + } +} + +function userContentToCommandCode(content: unknown, allowImages: boolean): unknown { + if (typeof content === "string") return content + + return recordArray(content).flatMap((part) => { + if (part.type === "text") return [{ type: "text", text: stringValue(part.text) ?? "" }] + if (part.type === "image") { + if (!allowImages) throw imageContentError("user messages") + return [imageToCommandCode(part)] + } + return [] + }) +} + +export function getApiKey( + options: { + env?: NodeJS.ProcessEnv + authPaths?: readonly string[] + homeDir?: () => string + } = {}, +): string | undefined { + const env = options.env ?? process.env + if (env.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 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[], + options: { allowImages?: boolean } = {}, +): unknown[] { + const allowImages = options.allowImages ?? false + if (!allowImages) assertTextOnlyMessages(messages) + + const out: unknown[] = [] + const pairedToolCallIds = completeToolCallIds(messages) + + for (const message of messages ?? []) { + if (message.role === "user") { + out.push({ + role: "user", + content: userContentToCommandCode(message.content, allowImages), + }) + } 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 === "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) }, + }, + ], + }) + + const images = imageParts(message.content) + if (images.length > 0) { + if (!allowImages) throw imageContentError("tool results") + out.push({ + role: "user", + content: images.map(imageToCommandCode), + }) + } + } + } + return out +} + +export function parseStreamEventLine(line: string): unknown | undefined { + let trimmed = line.trim() + if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined + if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim() + if (!trimmed || trimmed === "[DONE]") return undefined + + try { + const parsed: unknown = JSON.parse(trimmed) + return parsed + } catch { + return undefined + } +} + +export function mapFinishReason(reason: unknown): StopReason { + if (reason === "tool-calls") return "toolUse" + if ( + reason === "length" || + reason === "max_tokens" || + reason === "max-tokens" || + reason === "max_output_tokens" + ) { + return "length" + } + return "stop" +} + +function promptPartToText(value: unknown, depth = 0): string { + if (depth > 10) return "" + if (typeof value === "string") return value + if (Array.isArray(value)) + return value + .map((v) => promptPartToText(v, depth + 1)) + .filter(Boolean) + .join("\n") + if (!isRecord(value)) return "" + const text = stringValue(value.text) + if (text) return text + const content = promptPartToText(value.content, depth + 1) + if (content) return content + return "" +} + +export function systemPromptToText(value: unknown): string { + if (value === undefined || value === null) return "" + if (typeof value === "string") return value + if (Array.isArray(value)) + return value + .map((v) => promptPartToText(v, 0)) + .filter(Boolean) + .join("\n\n") + return promptPartToText(value, 0) +} diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..f1898dd --- /dev/null +++ b/src/core.ts @@ -0,0 +1,741 @@ +/** + * 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 { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts" +import { modelSupportsImageInput } from "./models.ts" +import { + getApiKey, + getEnvironmentInfo, + isRecord, + assertTextOnlyMessages, + 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 "./overflow.ts" +export * from "./types.ts" + +export const DEFAULT_API_BASE = "https://api.commandcode.ai" +export const COMMAND_CODE_CLI_VERSION = "1.15.1" + +const DEFAULT_GENERATE_MAX_TOKENS = 64_000 +const DEFAULT_MAX_RETRIES = 0 +const DEFAULT_MAX_RETRY_DELAY_MS = 60_000 +const BASE_RETRY_DELAY_MS = 500 + +function isRetryableStatus(status: number): boolean { + return status === 429 || (status >= 500 && status < 600) +} + +function parseRetryAfterSeconds(value: string | null): number | undefined { + if (!value) return undefined + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds >= 0) return seconds + const date = Date.parse(value) + if (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000) + return undefined +} + +function effectiveMaxRetryDelayMs(value: number | undefined): number { + if (value === undefined) return DEFAULT_MAX_RETRY_DELAY_MS + if (value === 0) return Number.POSITIVE_INFINITY + return value +} + +function retryDelayMs( + attempt: number, + retryAfterHeader: string | null, + maxDelayMs: number, +): number { + const retryAfterMs = parseRetryAfterSeconds(retryAfterHeader) + if (retryAfterMs !== undefined) { + if (retryAfterMs * 1000 > maxDelayMs) return -1 + return retryAfterMs * 1000 + } + const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt + const jitter = exponential * 0.2 * Math.random() + return Math.min(exponential + jitter, maxDelayMs) +} + +function defaultUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + } +} + +function commandCodeUsage(event: Record): Record | undefined { + return isRecord(event.totalUsage) ? event.totalUsage : undefined +} + +function commandCodeInputTokenDetails( + usage: Record, +): Record | undefined { + return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {} + headers.forEach((value, key) => { + out[key] = value + }) + return out +} + +function abortError(message = "The operation was aborted"): DOMException { + return new DOMException(message, "AbortError") +} + +function timeoutError(timeoutMs: number | undefined): Error { + return new Error( + timeoutMs === undefined + ? "Command Code API request timed out" + : `Command Code API request timed out after ${timeoutMs}ms`, + ) +} + +function successStopReason(reason: TerminalReason): StopReason { + if (reason === "length" || reason === "toolUse") return reason + return "stop" +} + +function generateMaxTokens(model: ModelLike, options?: StreamOptions): number { + return Math.min( + options?.maxTokens ?? model.maxTokens, + model.maxTokens, + DEFAULT_GENERATE_MAX_TOKENS, + ) +} + +function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): string | undefined { + const level = options?.reasoning + if (!level || level === "off" || !model.reasoning) return undefined + + const effortMap = model.thinking?.effortMap ?? model.thinkingLevelMap + const mapped = effortMap?.[level] + return typeof mapped === "string" && mapped !== "off" ? mapped : undefined +} + +export function projectSlugFromPath(pathName: string): string { + const slug = pathName + .toLowerCase() + .replace(/^[a-z]:/i, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + return slug || "project" +} + +export function createStreamCommandCode(deps: CoreDependencies) { + const apiBase = deps.apiBase ?? DEFAULT_API_BASE + const fetchImpl = deps.fetchImpl ?? fetch + const cwd = deps.cwd ?? (() => process.cwd()) + const now = deps.now ?? (() => Date.now()) + const uuid = deps.uuid ?? (() => randomUUID()) + const delay = + deps.delay ?? + ((ms: number, signal: AbortSignal) => { + if (signal.aborted) return Promise.reject(abortError()) + return new Promise((resolve, reject) => { + const id = setTimeout(() => { + signal.removeEventListener("abort", onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(id) + reject(abortError()) + } + signal.addEventListener("abort", onAbort, { once: true }) + }) + }) + + function raceAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortError()) + + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()) + signal.addEventListener("abort", onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort) + reject(error) + }, + ) + }) + } + + function raceAbortWithTimeout( + promise: Promise, + controller: AbortController, + timeoutMs: number | undefined, + ): Promise { + if (timeoutMs === undefined) return raceAbort(promise, controller.signal) + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + controller.abort() + reject(timeoutError(timeoutMs)) + }, timeoutMs) + raceAbort(promise, controller.signal).then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error: unknown) => { + clearTimeout(timer) + reject(error) + }, + ) + }) + } + + return function streamCommandCode( + model: ModelLike, + context: ContextLike, + options?: StreamOptions, + ): AssistantMessageEventStreamLike { + const stream = deps.createStream() + + async function run() { + // 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) + const totalInput = numberValue(usage.inputTokens) ?? 0 + const input = numberValue(details?.noCacheTokens) + const cacheRead = numberValue(details?.cacheReadTokens) ?? 0 + const cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0 + output.usage.input = input ?? Math.max(0, totalInput - cacheRead - cacheWrite) + output.usage.output = numberValue(usage.outputTokens) ?? 0 + output.usage.cacheRead = cacheRead + output.usage.cacheWrite = cacheWrite + output.usage.totalTokens = + output.usage.input + + output.usage.output + + output.usage.cacheRead + + output.usage.cacheWrite + deps.calculateCost(model, output.usage) + } + output.stopReason = mapFinishReason(event.finishReason) + finished = true + break + } + + case "error": { + const message = + commandCodeErrorMessage(event.error) ?? + commandCodeErrorMessage(event.message) ?? + "Stream error" + output.stopReason = "error" + output.errorMessage = message + throw new Error(message) + } + } + } + + try { + stream.push({ type: "start", partial: output }) + if (controller.signal.aborted) throw abortError("Aborted") + + const workingDir = cwd() + const threadId = uuid() + const reasoningEffort = mappedReasoningEffort(model, options) + const timeoutMs = options?.timeoutMs + + const allowImages = modelSupportsImageInput(model.id) + if (!allowImages) assertTextOnlyMessages(context.messages) + + let body: unknown = { + config: { + workingDir, + date: new Date(now()).toISOString().split("T")[0], + environment: getEnvironmentInfo(), + structure: [], + isGitRepo: false, + currentBranch: "", + mainBranch: "", + gitStatus: "", + recentCommits: [], + }, + memory: null, + taste: null, + skills: null, + params: { + model: model.id, + messages: messagesToCC(context.messages, { allowImages }), + tools: toolsToJson(context.tools), + system: systemPromptToText(context.systemPrompt), + max_tokens: generateMaxTokens(model, options), + temperature: 0.3, + stream: true, + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), + }, + threadId, + } + + const payloadController = new AbortController() + const onPayloadAbort = () => payloadController.abort() + controller.signal.addEventListener("abort", onPayloadAbort, { once: true }) + let nextBody: unknown + try { + nextBody = await raceAbortWithTimeout( + Promise.resolve(options?.onPayload?.(body, model)), + payloadController, + timeoutMs, + ) + } finally { + controller.signal.removeEventListener("abort", onPayloadAbort) + } + if (nextBody !== undefined) body = nextBody + + const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES + const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs) + const requestHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "x-command-code-version": COMMAND_CODE_CLI_VERSION, + "x-cli-environment": "production", + "x-project-slug": projectSlugFromPath(workingDir), + "x-taste-learning": "true", + "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 }) + const raceAttempt = (promise: Promise): Promise => + raceAbort(promise, attemptController.signal).catch((error: unknown) => { + if (attemptTimedOut) throw timeoutError(timeoutMs) + throw error + }) + + try { + try { + response = await fetchImpl(`${apiBase}/alpha/generate`, { + method: "POST", + headers: requestHeaders, + body: bodyStr, + signal: attemptController.signal, + }) + } catch (fetchError: unknown) { + if (controller.signal.aborted) throw abortError("Aborted") + if (attemptTimedOut) { + if (attempt < maxRetries) continue retryLoop + throw timeoutError(timeoutMs) + } + throw fetchError + } + + // --- HTTP-level retry --- + if (!response.ok && isRetryableStatus(response.status)) { + const retryAfter = response.headers.get("retry-after") + const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs) + if (waitMs < 0) { + const requestedSeconds = parseRetryAfterSeconds(retryAfter) ?? 0 + const capLabel = + maxRetryDelayMs === Number.POSITIVE_INFINITY ? "disabled" : `${maxRetryDelayMs}ms` + throw new Error(`Retry-After delay ${requestedSeconds}s exceeds max ${capLabel}`) + } + if (attempt < maxRetries) { + await response.text().catch(() => "") + if (waitMs > 0) await delay(waitMs, controller.signal) + continue retryLoop + } + } + + try { + await raceAttempt( + Promise.resolve( + options?.onResponse?.( + { + status: response.status, + headers: headersToRecord(response.headers), + }, + model, + ), + ), + ) + } catch (error: unknown) { + if (attemptTimedOut && attempt < maxRetries) continue retryLoop + throw error + } + + if (!response.ok) { + const errBody = await raceAttempt(response.text().catch(() => "")) + let errorDetail: string | undefined + try { + const parsedBody: unknown = JSON.parse(errBody) + errorDetail = commandCodeErrorMessage(parsedBody) + } catch { + // Preserve useful plain-text provider errors only after secret + // redaction; upstream/proxy bodies may echo credentials. + } + const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500) + const detail = redactCommandCodeErrorText( + errorDetail ?? (safeBody || "Provider returned an error"), + ) + throw new Error(`Command Code API error ${response.status}: ${detail}`) + } + + // --- Read response stream --- + reader = response.body?.getReader() + if (!reader) throw new Error("No response body") + + const decoder = new TextDecoder() + let buffer = "" + + try { + readLoop: for (;;) { + if (controller.signal.aborted) throw abortError("Aborted") + const { done, value } = await raceAbort(reader.read(), attemptController.signal) + if (done) { + if (buffer.trim()) handleEvent(parseStreamEventLine(buffer)) + 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" + : redactCommandCodeErrorText(error instanceof Error ? error.message : String(error)) + stream.push({ type: "error", reason, error: output }) + stream.end() + } finally { + options?.signal?.removeEventListener("abort", abortUpstream) + try { + await reader?.cancel() + } catch { + // Reader may already be closed/cancelled. + } + try { + reader?.releaseLock() + } catch { + // Reader may already be released/cancelled by the abort path. + } + } + } + + run().catch((error: unknown) => { + const msg: AssistantMessageLike = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: defaultUsage(), + stopReason: "error", + errorMessage: redactCommandCodeErrorText( + error instanceof Error ? error.message : String(error), + ), + timestamp: now(), + } + stream.push({ type: "error", reason: "error", error: msg }) + stream.end() + }) + + return stream + } +} diff --git a/src/cost.ts b/src/cost.ts new file mode 100644 index 0000000..55c1bd0 --- /dev/null +++ b/src/cost.ts @@ -0,0 +1,31 @@ +/** + * Local cost calculation for Command Code usage. + * + * Mirrors pi-ai's `calculateCost` arithmetic exactly. The provider ships its + * own copy because Oh My Pi's legacy pi-ai shim does not export + * `calculateCost`, which broke extension installation there (issue #24). + * `tests/test-cost.ts` locks this implementation to the pi-ai original. + */ + +import type { ModelLike, Usage } from "./types.ts" + +export function calculateCommandCodeCost(model: ModelLike, usage: Usage): void { + const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite + let rates = model.cost + let matchedThreshold = -1 + for (const tier of model.cost.tiers ?? []) { + if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { + rates = tier + matchedThreshold = tier.inputTokensAbove + } + } + + const longWrite = usage.cacheWrite1h ?? 0 + const shortWrite = usage.cacheWrite - longWrite + usage.cost.input = (rates.input / 1_000_000) * usage.input + usage.cost.output = (rates.output / 1_000_000) * usage.output + usage.cost.cacheRead = (rates.cacheRead / 1_000_000) * usage.cacheRead + usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1_000_000 + usage.cost.total = + usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite +} diff --git a/src/json-schema.ts b/src/json-schema.ts new file mode 100644 index 0000000..a2ffc02 --- /dev/null +++ b/src/json-schema.ts @@ -0,0 +1,382 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + +type JsonSchemaValue = boolean | Record + +const JSON_SCHEMA_TYPES = new Set([ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string", +]) + +const LEGACY_KINDS = new Set([ + "any", + "array", + "boolean", + "enum", + "integer", + "intersect", + "intersection", + "literal", + "never", + "null", + "nullable", + "number", + "object", + "optional", + "string", + "undefined", + "union", + "unknown", +]) + +const LEGACY_FIELDS = new Set([ + "element", + "kind", + "inner", + "optional", + "value", + "values", + "variants", + "wrapped", +]) + +const SCHEMA_MAP_FIELDS = new Set([ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", +]) + +const SCHEMA_ARRAY_FIELDS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]) + +const SCHEMA_VALUE_FIELDS = new Set([ + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", +]) + +const SCHEMA_KEYWORDS = new Set([ + "$anchor", + "$comment", + "$defs", + "$dynamicAnchor", + "$dynamicRef", + "$id", + "$ref", + "$schema", + "$vocabulary", + "additionalItems", + "additionalProperties", + "allOf", + "anyOf", + "const", + "contains", + "contentEncoding", + "contentMediaType", + "contentSchema", + "default", + "definitions", + "dependentRequired", + "dependentSchemas", + "description", + "else", + "enum", + "examples", + "exclusiveMaximum", + "exclusiveMinimum", + "format", + "if", + "items", + "maxContains", + "maxItems", + "maxLength", + "maxProperties", + "maximum", + "minContains", + "minItems", + "minLength", + "minProperties", + "minimum", + "multipleOf", + "not", + "oneOf", + "pattern", + "patternProperties", + "prefixItems", + "properties", + "propertyNames", + "readOnly", + "required", + "title", + "type", + "unevaluatedItems", + "unevaluatedProperties", + "uniqueItems", + "writeOnly", +]) + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const values = value.filter((item): item is string => typeof item === "string") + return values.length === value.length ? values : undefined +} + +function validSchemaType(value: unknown): boolean { + if (typeof value === "string") return JSON_SCHEMA_TYPES.has(value) + if (!Array.isArray(value) || value.length === 0) return false + return value.every((item) => typeof item === "string" && JSON_SCHEMA_TYPES.has(item)) +} + +function legacyKind(schema: Record): string | undefined { + const explicitKind = stringValue(schema.kind)?.toLowerCase() + if (explicitKind && LEGACY_KINDS.has(explicitKind)) return explicitKind + + const type = stringValue(schema.type) + const normalized = type?.toLowerCase() + if (!normalized || !LEGACY_KINDS.has(normalized)) return undefined + if (!validSchemaType(type) || Object.keys(schema).some((key) => LEGACY_FIELDS.has(key))) { + return normalized + } + return undefined +} + +function looksLikeJsonSchema(schema: Record): boolean { + if (Object.keys(schema).length === 0) return true + if (schema.type !== undefined && !validSchemaType(schema.type)) return false + return Object.keys(schema).some((key) => SCHEMA_KEYWORDS.has(key)) +} + +function isOptionalSchema(schema: unknown): boolean { + if (!isRecord(schema)) return false + if (booleanValue(schema.optional) === true) return true + + const kind = legacyKind(schema) + if (kind === "optional") return true + if (kind !== "union") return false + + const variants = Array.isArray(schema.variants) + ? schema.variants + : Array.isArray(schema.anyOf) + ? schema.anyOf + : [] + return variants.some((variant) => legacyKind(isRecord(variant) ? variant : {}) === "undefined") +} + +function schemaValue(value: unknown, seen: WeakSet): JsonSchemaValue { + if (typeof value === "boolean") return value + if (!isRecord(value)) return {} + return convertSchema(value, seen) +} + +function setSchemaProperty(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) +} + +function schemaMap(value: unknown, seen: WeakSet): Record { + if (!isRecord(value)) return {} + const out: Record = {} + for (const [key, item] of Object.entries(value)) { + setSchemaProperty(out, key, schemaValue(item, seen)) + } + return out +} + +function schemaArray(value: unknown, seen: WeakSet): unknown[] { + if (!Array.isArray(value)) return [] + return value.map((item) => schemaValue(item, seen)) +} + +function isSchemaValue(value: unknown): value is JsonSchemaValue { + return typeof value === "boolean" || isRecord(value) +} + +function copySchemaObject( + source: Record, + seen: WeakSet, + legacy: boolean, + forcedType?: string, +): JsonSchemaValue { + const out: Record = {} + + for (const [key, value] of Object.entries(source)) { + if (legacy && LEGACY_FIELDS.has(key)) continue + if (key === "nullable" || (forcedType !== undefined && key === "type")) continue + + if (key === "required") { + const required = stringArray(value) + if (required) out.required = required + } else if (SCHEMA_MAP_FIELDS.has(key)) { + out[key] = schemaMap(value, seen) + } else if (SCHEMA_ARRAY_FIELDS.has(key)) { + out[key] = schemaArray(value, seen) + } else if (SCHEMA_VALUE_FIELDS.has(key)) { + out[key] = + Array.isArray(value) && key === "items" + ? schemaArray(value, seen) + : schemaValue(value, seen) + } else { + out[key] = value + } + } + + if (forcedType !== undefined) out.type = forcedType + if (booleanValue(source.nullable) === true) return makeNullable(out) + return out +} + +function makeNullable(schema: Record): Record { + const type = schema.type + if (typeof type === "string") { + if (type === "null") return schema + return { ...schema, type: [type, "null"] } + } + if (Array.isArray(type) && !type.includes("null")) { + return { ...schema, type: [...type, "null"] } + } + if (Array.isArray(schema.anyOf)) { + return { ...schema, anyOf: [...schema.anyOf, { type: "null" }] } + } + return { anyOf: [schema, { type: "null" }] } +} + +function legacyVariants(schema: Record): unknown[] { + if (Array.isArray(schema.variants)) return schema.variants + if (Array.isArray(schema.anyOf)) return schema.anyOf + return [] +} + +function convertLegacySchema( + source: Record, + kind: string, + seen: WeakSet, +): JsonSchemaValue { + if (kind === "optional") return schemaValue(source.wrapped ?? source.inner, seen) + if (kind === "nullable") { + const wrapped = schemaValue(source.wrapped ?? source.inner, seen) + return typeof wrapped === "boolean" ? wrapped : makeNullable(wrapped) + } + if (kind === "undefined" || kind === "never" || kind === "any" || kind === "unknown") return {} + + if (kind === "union" || kind === "intersect" || kind === "intersection") { + const variants = legacyVariants(source) + .map((variant) => schemaValue(variant, seen)) + .filter( + (variant) => + isSchemaValue(variant) && + (typeof variant === "boolean" || Object.keys(variant).length > 0), + ) + if (variants.length === 0) return copySchemaObject(source, seen, true) + if (variants.length === 1) return variants[0] ?? {} + + const out = copySchemaObject(source, seen, true) + if (typeof out !== "boolean") out[kind === "union" ? "anyOf" : "allOf"] = variants + return out + } + + if (kind === "object") { + const converted = copySchemaObject(source, seen, true, "object") + if (typeof converted === "boolean") return converted + const out = converted + const sourceProperties = isRecord(source.properties) ? source.properties : undefined + if (!sourceProperties) return out + + const properties: Record = {} + const optional = stringArray(source.optional) ?? [] + for (const [key, value] of Object.entries(sourceProperties)) { + setSchemaProperty(properties, key, schemaValue(value, seen)) + } + out.properties = properties + + const explicitRequired = stringArray(source.required) + const required = + explicitRequired ?? + Object.entries(sourceProperties) + .filter(([key, value]) => !optional.includes(key) && !isOptionalSchema(value)) + .map(([key]) => key) + if (required.length > 0) out.required = required + else delete out.required + return out + } + + if (kind === "array") { + const converted = copySchemaObject(source, seen, true, "array") + if (typeof converted === "boolean") return converted + const out = converted + if (!("items" in source) && "element" in source) out.items = schemaValue(source.element, seen) + return out + } + + if (kind === "enum") { + const converted = copySchemaObject(source, seen, true) + if (typeof converted === "boolean") return converted + const out = converted + if (!("enum" in out) && Array.isArray(source.values)) out.enum = source.values + return out + } + + if (kind === "literal") { + const converted = copySchemaObject(source, seen, true) + if (typeof converted === "boolean") return converted + const out = converted + if (!("const" in out) && "value" in source) out.const = source.value + return out + } + + const scalarType = + kind === "string" || + kind === "number" || + kind === "boolean" || + kind === "integer" || + kind === "null" + ? kind + : undefined + return scalarType ? copySchemaObject(source, seen, true, scalarType) : {} +} + +function convertSchema(source: Record, seen: WeakSet): JsonSchemaValue { + if (seen.has(source)) return {} + seen.add(source) + try { + const kind = legacyKind(source) + if (kind) return convertLegacySchema(source, kind, seen) + if (!looksLikeJsonSchema(source)) return {} + return copySchemaObject(source, seen, false) + } finally { + seen.delete(source) + } +} + +export function toJsonSchema(schema: unknown): unknown { + if (typeof schema === "boolean") return schema + if (!isRecord(schema)) return {} + return convertSchema(schema, new WeakSet()) +} diff --git a/src/overflow.ts b/src/overflow.ts new file mode 100644 index 0000000..3e3e106 --- /dev/null +++ b/src/overflow.ts @@ -0,0 +1,120 @@ +const COMMAND_CODE_PROVIDER = "commandcode" +const CONTEXT_OVERFLOW_PREFIX = "context_length_exceeded:" + +const COMMAND_CODE_OVERFLOW_PATTERNS = [ + /\b(?:context[_\s-]*(?:length|window)|model[_\s-]*context[_\s-]*window)[_\s-]*(?:exceeded|overflow(?:ed)?|too[_\s-]*(?:large|long))\b/i, + /\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b[\s\S]{0,120}\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long)|(?:maximum|limit)\s+(?:reached|exceeded|hit))\b/i, + /\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long))\b[\s\S]{0,120}\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b/i, + /\b(?:prompt|input|context)\b[\s\S]{0,32}\btoo\s+(?:large|long)\b/i, + /\b(?:prompt|input)[_\s-]*too[_\s-]*(?:large|long)\b/i, + /\b(?:prompt|input)[_\s-]*tokens?[_\s-]*(?:limit|maximum|max)[_\s-]*(?:exceeded|reached)\b/i, + /\b(?:prompt|input)[_\s-]*(?:tokens?|length|size)\b[\s\S]{0,120}\b(?:limit|maximum)\b[\s\S]{0,40}\b(?:exceed(?:ed|s)?|reached|hit)\b/i, + /\b(?:maximum|limit)[_\s-]+(?:allowed[_\s-]+)?(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?)\b/i, +] + +const NON_OVERFLOW_PATTERNS = [ + /\brate[_\s-]*limit\b/i, + /\btoo\s+many\s+requests\b/i, + /\b(?:capacity|quota|throttl(?:e|ed|ing)?|concurren(?:cy|t)|overloaded)\b/i, + /\b(?:service|temporarily)\s+unavailable\b/i, + /\bstatus(?:[_\s-]*code)?\s*[:=]\s*429\b/i, +] + +const CONTEXT_OVERFLOW_PREFIX_PATTERN = /context_length_exceeded/i + +const HTTP_RATE_LIMIT_STATUS_PATTERNS = [ + /\b(?:api\s+error|http|status(?:[_\s-]*code)?|status[_\s-]*code)\s*[:(]?\s*429\b/i, + /["']?(?:status|status[_\s-]*code)["']?\s*:\s*429\b/i, +] + +const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi +const CREDENTIAL_PATTERN = + /\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*[^\s,;)]+/gi +const USER_TOKEN_PATTERN = /\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi +const QUERY_SECRET_PATTERN = + /([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|password)=)[^&#\s]+/gi +const STANDALONE_SECRET_PATTERN = + /\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b|\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g + +export function redactCommandCodeErrorText(value: string): string { + return value + .replace(BEARER_PATTERN, "Bearer [redacted]") + .replace(CREDENTIAL_PATTERN, (match) => { + const separatorIndex = match.search(/[=:]/) + return separatorIndex < 0 ? "[redacted]" : `${match.slice(0, separatorIndex + 1)}[redacted]` + }) + .replace(USER_TOKEN_PATTERN, "[redacted]") + .replace(QUERY_SECRET_PATTERN, "$1[redacted]") + .replace(STANDALONE_SECRET_PATTERN, "[redacted]") +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +export interface CommandCodeMessageLike { + role: string + provider: string + stopReason: string + errorMessage?: string +} + +export function commandCodeErrorMessage(value: unknown): string | undefined { + if (typeof value === "string") return value + if (!isRecord(value)) return undefined + + const record = value + const parts: string[] = [] + for (const key of [ + "message", + "errorMessage", + "error", + "detail", + "details", + "code", + "type", + "reason", + ]) { + const part = commandCodeErrorMessage(record[key]) + if (part && !parts.includes(part)) parts.push(part) + } + + for (const key of ["status", "statusCode", "httpStatus"]) { + const status = record[key] + if (typeof status === "string" || typeof status === "number") { + const statusPart = `status: ${status}` + if (!parts.includes(statusPart)) parts.push(statusPart) + } + } + + return parts.length > 0 ? redactCommandCodeErrorText(parts.join(": ")) : undefined +} + +export function normalizeCommandCodeErrorMessage( + errorMessage: string | undefined, +): string | undefined { + if (!errorMessage) return undefined + if (CONTEXT_OVERFLOW_PREFIX_PATTERN.test(errorMessage)) return undefined + if (NON_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) return undefined + if (HTTP_RATE_LIMIT_STATUS_PATTERNS.some((pattern) => pattern.test(errorMessage))) + return undefined + if (!COMMAND_CODE_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) + return undefined + + return `${CONTEXT_OVERFLOW_PREFIX} ${errorMessage}` +} + +export function normalizeCommandCodeMessage( + message: T, + modelProvider?: string, +): { message: T & { errorMessage: string } } | undefined { + if (message.role !== "assistant" || message.stopReason !== "error") return undefined + if (message.provider !== COMMAND_CODE_PROVIDER && modelProvider !== COMMAND_CODE_PROVIDER) { + return undefined + } + + const errorMessage = normalizeCommandCodeErrorMessage(message.errorMessage) + if (!errorMessage) return undefined + + return { message: { ...message, errorMessage } } +} diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..9d83d5d --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,139 @@ +import type { + AssistantMessageEvent, + AssistantMessageEventStreamLike, + ContextLike, + ModelLike, + StreamOptions, +} from "./types.ts" + +export type CommandCodeTransport = "unknown" | "provider" | "generate" + +interface TransportDependencies { + createStream: () => AssistantMessageEventStreamLike + streamProvider: ( + model: ModelLike, + context: ContextLike, + options?: StreamOptions, + ) => AssistantMessageEventStreamLike + streamGenerate: ( + model: ModelLike, + context: ContextLike, + options?: StreamOptions, + ) => AssistantMessageEventStreamLike +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +async function isUpgradeRequired(response: Response): Promise { + if (response.status !== 403) return false + + try { + const body: unknown = await response.clone().json() + if (!isRecord(body)) return false + const error = isRecord(body.error) ? body.error : body + return error.code === "upgrade_required" + } catch { + return false + } +} + +export function createCommandCodeTransportRouter(deps: TransportDependencies) { + let transport: CommandCodeTransport = "unknown" + let apiKey: string | undefined + + function pipe( + source: AssistantMessageEventStreamLike, + target: AssistantMessageEventStreamLike, + ): Promise { + return (async () => { + for await (const event of source) target.push(event) + })() + } + + return { + getTransport(): CommandCodeTransport { + return transport + }, + + reset(): void { + transport = "unknown" + apiKey = undefined + }, + + stream( + model: ModelLike, + context: ContextLike, + options?: StreamOptions, + ): AssistantMessageEventStreamLike { + if (options?.apiKey !== apiKey) { + apiKey = options?.apiKey + transport = "unknown" + } + if (transport === "generate") return deps.streamGenerate(model, context, options) + + const output = deps.createStream() + let upgradeRequired = false + const fetchImpl = options?.fetch ?? fetch + const providerOptions: StreamOptions = { + ...options, + fetch: async (input, init) => { + const response = await fetchImpl(input, init) + if (await isUpgradeRequired(response)) upgradeRequired = true + return response + }, + onResponse: async (response, responseModel) => { + if (upgradeRequired) return + await options?.onResponse?.(response, responseModel) + }, + } + + const run = async () => { + const providerStream = deps.streamProvider(model, context, providerOptions) + + for await (const event of providerStream) { + if (!upgradeRequired) { + transport = "provider" + output.push(event) + } + } + + if (upgradeRequired) { + transport = "generate" + await pipe(deps.streamGenerate(model, context, options), output) + } + output.end() + } + + run().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + output.push({ + type: "error", + reason: "error", + error: { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: message, + timestamp: Date.now(), + }, + }) + output.end() + }) + + return output + }, + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..dc680ea --- /dev/null +++ b/src/types.ts @@ -0,0 +1,202 @@ +export type StopReason = "stop" | "length" | "toolUse" +export type ErrorReason = "error" | "aborted" +export type TerminalReason = StopReason | ErrorReason + +export interface UsageCost { + input: number + output: number + cacheRead: number + cacheWrite: number + total: number +} + +export interface Usage { + input: number + output: number + cacheRead: number + cacheWrite: number + cacheWrite1h?: number + totalTokens: number + cost: UsageCost +} + +export interface TextContent { + type: "text" + text: string +} + +export interface ThinkingContent { + type: "thinking" + thinking: string +} + +export interface ToolCallContent { + type: "toolCall" + id: string + name: string + arguments: Record +} + +export type AssistantContent = TextContent | ThinkingContent | ToolCallContent + +export interface AssistantMessageLike { + role: "assistant" + content: AssistantContent[] + api: unknown + provider: string + model: string + usage: Usage + stopReason: TerminalReason + errorMessage?: string + timestamp: number +} + +export interface ModelCostRates { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +export interface ModelCostTier extends ModelCostRates { + inputTokensAbove: number +} + +export interface ModelCost extends ModelCostRates { + tiers?: readonly ModelCostTier[] +} + +export interface ModelLike { + id: string + api: unknown + provider: string + maxTokens: number + cost: ModelCost + reasoning?: boolean + thinkingLevelMap?: Partial> + thinking?: { + mode?: "effort" + effortMap?: Partial> + efforts?: readonly string[] + } +} + +export interface MessageLike { + role: string + content?: unknown + toolCallId?: string + toolName?: string + isError?: boolean +} + +export interface ToolLike { + name: string + description?: string + parameters?: unknown +} + +export interface ContextLike { + systemPrompt?: string + messages?: readonly MessageLike[] + tools?: readonly ToolLike[] +} + +export interface ProviderResponseInfo { + status: number + headers: Record +} + +export interface StreamOptions { + apiKey?: string + signal?: AbortSignal + headers?: Record + fetch?: typeof fetch + maxTokens?: number + /** Resolved pi thinking level; forwarded only through the model's map. */ + reasoning?: string + onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise + onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise + /** + * HTTP request timeout in milliseconds. + * Applied per-attempt; on timeout the request is retried if retries remain. + */ + timeoutMs?: number + /** + * Maximum retry attempts for transient HTTP errors (429, 5xx). + * Default: 0 (pi agent-level retry handles visible retries when unset). + */ + maxRetries?: number + /** + * Maximum delay in milliseconds to wait for a retry when the server requests + * a long wait via Retry-After. If the server's requested delay exceeds this + * value, the request fails immediately. Default: 60000 (60 seconds). + * Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number +} + +export type AssistantMessageEvent = + | { type: "start"; partial: AssistantMessageLike } + | { type: "text_start"; contentIndex: number; partial: AssistantMessageLike } + | { + type: "text_delta" + contentIndex: number + delta: string + partial: AssistantMessageLike + } + | { + type: "text_end" + contentIndex: number + content: string + partial: AssistantMessageLike + } + | { + type: "thinking_start" + contentIndex: number + partial: AssistantMessageLike + } + | { + type: "thinking_delta" + contentIndex: number + delta: string + partial: AssistantMessageLike + } + | { + type: "thinking_end" + contentIndex: number + content: string + partial: AssistantMessageLike + } + | { + type: "toolcall_start" + contentIndex: number + partial: AssistantMessageLike + } + | { + type: "toolcall_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 new file mode 100644 index 0000000..f4b3f65 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,289 @@ +import { createServer, type IncomingHttpHeaders, type Server } from "node:http" + +import { + createStreamCommandCode, + type AssistantMessageEvent, + type AssistantMessageEventStreamLike, + type ContextLike, + type CoreDependencies, + type ModelLike, + type Usage, +} from "../src/core.ts" + +export function createTestEventStream(): AssistantMessageEventStreamLike { + const events: AssistantMessageEvent[] = [] + const waiters: Array<() => void> = [] + let ended = false + + const wake = () => { + const waiter = waiters.shift() + if (waiter) waiter() + } + + return { + push(event: AssistantMessageEvent) { + events.push(event) + wake() + }, + end() { + ended = true + while (waiters.length > 0) wake() + }, + [Symbol.asyncIterator]() { + let index = 0 + return { + async next(): Promise> { + while (index >= events.length && !ended) { + await new Promise((resolve) => waiters.push(resolve)) + } + if (index < events.length) { + const value = events[index] + index += 1 + return { done: false, value } + } + return { done: true, value: undefined } + }, + } + }, + } +} + +export async function collectEvents( + stream: AssistantMessageEventStreamLike, + timeoutMs = 2_000, +): Promise { + const events: AssistantMessageEvent[] = [] + + const collect = async () => { + for await (const event of stream) { + events.push(event) + if (event.type === "done" || event.type === "error") break + } + return events + } + + return await Promise.race([ + collect(), + new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), + timeoutMs, + ) + }), + ]) +} + +export function makeModel(overrides: Partial = {}): ModelLike { + return { + id: "deepseek/deepseek-v4-flash", + api: "commandcode-custom", + provider: "commandcode", + maxTokens: 384_000, + cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + ...overrides, + } +} + +export function makeContext(overrides: Partial = {}): ContextLike { + return { + systemPrompt: "You are a test assistant.", + messages: [{ role: "user", content: "hello" }], + tools: [], + ...overrides, + } +} + +export interface TestDepsResult { + streamCommandCode: ReturnType + calculatedUsages: Usage[] +} + +export function createTestDeps(overrides: Partial = {}): TestDepsResult { + const calculatedUsages: Usage[] = [] + const streamCommandCode = createStreamCommandCode({ + createStream: createTestEventStream, + calculateCost: (_model, usage) => { + calculatedUsages.push({ + ...usage, + cost: { ...usage.cost }, + }) + }, + env: {}, + authPaths: [], + now: () => new Date("2026-05-05T12:00:00Z").getTime(), + uuid: () => "00000000-0000-4000-8000-000000000000", + cwd: () => "/repo", + delay: async () => {}, + ...overrides, + }) + return { streamCommandCode, calculatedUsages } +} + +type SuccessPlan = { + type: "success" + status?: number + events?: string[] + chunks?: string[] + delays?: number[] + hangAfterLast?: boolean + /** Delay in ms before the server starts sending the response. */ + responseDelay?: number +} + +type ErrorPlan = { + type: "error" + status: number + body: string + headers?: Record +} + +export type ResponsePlan = SuccessPlan | ErrorPlan + +function headersToRecord(headers: IncomingHttpHeaders): Record { + const out: Record = {} + for (const [key, value] of Object.entries(headers)) { + if (typeof value === "string") out[key] = value + else if (Array.isArray(value)) out[key] = value.join(", ") + } + return out +} + +export interface MockCommandCodeServer { + baseUrl(): string + mockResponse(plan: ResponsePlan): void + mockResponseQueue(plans: ResponsePlan[]): void + reset(): void + close(): Promise + lastRequestBody(): unknown + lastRequestHeaders(): Record + requestCount(): number + responseClosedBeforeEnd(): boolean +} + +export async function startMockCommandCodeServer(): Promise { + let planQueue: ResponsePlan[] = [{ type: "success", events: [] }] + let lastBody: unknown + let lastHeaders: Record = {} + let requests = 0 + let closedBeforeEnd = false + let port = 0 + + const server: Server = createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/alpha/generate") { + res.writeHead(404) + res.end("Not found") + return + } + + requests += 1 + lastHeaders = headersToRecord(req.headers) + let body = "" + req.on("data", (chunk: Buffer) => { + body += chunk.toString("utf-8") + }) + req.on("end", () => { + try { + const parsed: unknown = JSON.parse(body) + lastBody = parsed + } catch { + lastBody = undefined + } + + // Pop the first plan from the queue; keep the last one as fallback. + const plan = planQueue.length > 1 ? planQueue.shift()! : planQueue[0] + + if (plan.type === "error") { + const headers: Record = { "Content-Type": "text/plain", ...plan.headers } + res.writeHead(plan.status, headers) + res.end(plan.body) + return + } + + res.writeHead(plan.status ?? 200, { + "Content-Type": "text/plain; charset=utf-8", + "Transfer-Encoding": "chunked", + }) + + let ended = false + res.on("close", () => { + if (!ended) closedBeforeEnd = true + }) + + const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`) + const delays = plan.delays ?? chunks.map(() => 0) + let index = 0 + + const sendNext = () => { + if (index >= chunks.length) { + if (!plan.hangAfterLast) { + ended = true + res.end() + } + return + } + + res.write(chunks[index]) + index += 1 + if (index < chunks.length) { + setTimeout(sendNext, delays[index] ?? 0) + } else if (!plan.hangAfterLast) { + ended = true + res.end() + } + } + + if (plan.responseDelay) { + setTimeout(sendNext, plan.responseDelay) + } else { + sendNext() + } + }) + }) + + await new Promise((resolve) => { + server.listen(0, () => { + const address = server.address() + if (typeof address === "object" && address) port = address.port + resolve() + }) + }) + + return { + baseUrl: () => `http://127.0.0.1:${port}`, + mockResponse(plan: ResponsePlan) { + planQueue = [plan] + }, + mockResponseQueue(plans: ResponsePlan[]) { + planQueue = [...plans] + }, + reset() { + planQueue = [{ type: "success", events: [] }] + lastBody = undefined + lastHeaders = {} + requests = 0 + closedBeforeEnd = false + }, + close() { + return new Promise((resolve) => server.close(() => resolve())) + }, + lastRequestBody: () => lastBody, + lastRequestHeaders: () => lastHeaders, + requestCount: () => requests, + responseClosedBeforeEnd: () => closedBeforeEnd, + } +} + +export function objectAt(value: unknown, path: readonly string[]): unknown { + let current = value + for (const key of path) { + if (Array.isArray(current)) { + const index = Number(key) + if (!Number.isInteger(index)) return undefined + current = current[index] + continue + } + if (typeof current !== "object" || current === null) return undefined + current = Object.getOwnPropertyDescriptor(current, key)?.value + } + return current +} diff --git a/tests/test-abort.ts b/tests/test-abort.ts new file mode 100644 index 0000000..8db0ad5 --- /dev/null +++ b/tests/test-abort.ts @@ -0,0 +1,85 @@ +/** + * 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-cost.ts b/tests/test-cost.ts new file mode 100644 index 0000000..ae7f730 --- /dev/null +++ b/tests/test-cost.ts @@ -0,0 +1,183 @@ +/** + * Regression test for the local cost calculation. + * + * The provider ships its own cost function because Oh My Pi's legacy pi-ai + * shim does not export `calculateCost` (see issue #24). This test locks the + * local implementation to pi-ai's documented per-million-token arithmetic + * without installing another pi-ai runtime next to the extension. + */ + +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { calculateCommandCodeCost } from "../src/cost.ts" +import type { Usage } from "../src/types.ts" + +interface CostRates { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +interface CostTable extends CostRates { + tiers?: Array +} + +const COST_FIXTURES: Record = { + "zero-cost-model": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "deepseek/deepseek-v4-pro": { + input: 0.435, + output: 0.87, + cacheRead: 0.003625, + cacheWrite: 0, + }, + "Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, + "Qwen/Qwen3.7-Flash": { + input: 0.03, + output: 0.13, + cacheRead: 0.006, + cacheWrite: 0.038, + tiers: [ + { inputTokensAbove: 32_000, input: 0.1, output: 0.4, cacheRead: 0.02, cacheWrite: 0.125 }, + { inputTokensAbove: 256_000, input: 0.2, output: 0.8, cacheRead: 0.04, cacheWrite: 0.25 }, + ], + }, + "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, +} + +const USAGE_CASES = [ + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 }, + { input: 812, output: 187, cacheRead: 52_000, cacheWrite: 3_100 }, + { input: 1_000_000, output: 65_536, cacheRead: 998_877, cacheWrite: 123_456 }, + { input: 7, output: 999_999_999, cacheRead: 0.5, cacheWrite: 42 }, +] + +function commandCodeModel(id: string, cost: CostTable) { + return { + id, + api: "commandcode-custom", + provider: "commandcode", + cost, + maxTokens: 65_536, + } +} + +function assertClose(actual: number, expected: number) { + assert.ok( + Math.abs(actual - expected) <= + Number.EPSILON * Math.max(1, Math.abs(actual), Math.abs(expected)), + `expected ${actual} to be close to ${expected}`, + ) +} + +function freshUsage(tokens: (typeof USAGE_CASES)[number]): Usage { + return { + ...tokens, + totalTokens: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + } +} + +function expectedCost(cost: CostTable, tokens: (typeof USAGE_CASES)[number]): Usage["cost"] { + const inputTokens = tokens.input + tokens.cacheRead + tokens.cacheWrite + let rates: CostRates = cost + let matchedThreshold = -1 + for (const tier of cost.tiers ?? []) { + if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { + rates = tier + matchedThreshold = tier.inputTokensAbove + } + } + + const input = (rates.input / 1_000_000) * tokens.input + const output = (rates.output / 1_000_000) * tokens.output + const cacheRead = (rates.cacheRead / 1_000_000) * tokens.cacheRead + const cacheWrite = (rates.cacheWrite * tokens.cacheWrite) / 1_000_000 + return { + input, + output, + cacheRead, + cacheWrite, + total: input + output + cacheRead + cacheWrite, + } +} + +describe("calculateCommandCodeCost()", () => { + it("applies per-million-token rates to all cost fields", () => { + for (const [id, cost] of Object.entries(COST_FIXTURES)) { + const model = commandCodeModel(id, cost) + + for (const tokens of USAGE_CASES) { + const usage = freshUsage(tokens) + calculateCommandCodeCost(model, usage) + + assert.deepEqual( + usage.cost, + expectedCost(cost, tokens), + `${id} cost for tokens=${JSON.stringify(tokens)}`, + ) + } + } + }) + + it("applies the highest request-wide input tier above its threshold", () => { + const model = commandCodeModel("Qwen/Qwen3.7-Flash", COST_FIXTURES["Qwen/Qwen3.7-Flash"]) + + const atThreshold = freshUsage({ + input: 32_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + }) + calculateCommandCodeCost(model, atThreshold) + assertClose(atThreshold.cost.input, (0.03 * 32_000) / 1_000_000) + + const aboveFirstTier = freshUsage({ + input: 30_000, + output: 1_000, + cacheRead: 2_001, + cacheWrite: 0, + }) + calculateCommandCodeCost(model, aboveFirstTier) + assertClose(aboveFirstTier.cost.input, (0.1 * 30_000) / 1_000_000) + assertClose(aboveFirstTier.cost.cacheRead, (0.02 * 2_001) / 1_000_000) + + const aboveHighestTier = freshUsage({ + input: 100_000, + output: 1_000, + cacheRead: 156_001, + cacheWrite: 0, + }) + calculateCommandCodeCost(model, aboveHighestTier) + assertClose(aboveHighestTier.cost.input, (0.2 * 100_000) / 1_000_000) + assertClose(aboveHighestTier.cost.output, (0.8 * 1_000) / 1_000_000) + }) + + it("prices one-hour cache writes at twice the active input rate", () => { + const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) + const usage = freshUsage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 1_000 }) + usage.cacheWrite1h = 400 + + calculateCommandCodeCost(model, usage) + + const expectedShortWrite = (3.75 * 600) / 1_000_000 + const expectedLongWrite = (3 * 2 * 400) / 1_000_000 + assertClose(usage.cost.cacheWrite, expectedShortWrite + expectedLongWrite) + }) + + it("writes the total as the sum of all cost components", () => { + const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) + const usage = freshUsage({ input: 1_000, output: 500, cacheRead: 10_000, cacheWrite: 2_000 }) + + calculateCommandCodeCost(model, usage) + + assert.equal( + usage.cost.total, + usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite, + ) + assert.ok(usage.cost.total > 0) + }) +}) diff --git a/tests/test-overflow.ts b/tests/test-overflow.ts new file mode 100644 index 0000000..dc28888 --- /dev/null +++ b/tests/test-overflow.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict" +import { after, before, beforeEach, describe, it } from "node:test" + +import { + commandCodeErrorMessage, + normalizeCommandCodeErrorMessage, + normalizeCommandCodeMessage, +} from "../src/overflow.ts" +import { + collectEvents, + createTestDeps, + makeContext, + makeModel, + startMockCommandCodeServer, + type MockCommandCodeServer, +} from "./helpers.ts" + +let server: MockCommandCodeServer + +before(async () => { + server = await startMockCommandCodeServer() +}) + +after(async () => { + await server.close() +}) + +beforeEach(() => { + server.reset() +}) + +describe("Command Code overflow normalization", () => { + it("normalizes Command Code context errors to pi's generic overflow marker", () => { + const normalized = normalizeCommandCodeErrorMessage("Prompt token limit exceeded") + + assert.equal(normalized, "context_length_exceeded: Prompt token limit exceeded") + }) + + it("is idempotent and leaves unrelated, rate-limit, and capacity errors unchanged", () => { + assert.equal( + normalizeCommandCodeErrorMessage("context_length_exceeded: Prompt token limit exceeded"), + undefined, + ) + assert.equal(normalizeCommandCodeErrorMessage("OpenAI request failed"), undefined) + assert.equal( + normalizeCommandCodeErrorMessage("Prompt token limit exceeded due to rate limit"), + undefined, + ) + assert.equal( + normalizeCommandCodeErrorMessage("Command Code API error 429: context window exceeded"), + undefined, + ) + assert.equal( + normalizeCommandCodeErrorMessage("context window exceeded: status: 429"), + undefined, + ) + assert.equal( + normalizeCommandCodeErrorMessage("The input is too long"), + "context_length_exceeded: The input is too long", + ) + assert.equal( + normalizeCommandCodeErrorMessage("Input exceeds context limit"), + "context_length_exceeded: Input exceeds context limit", + ) + assert.equal( + normalizeCommandCodeErrorMessage("Context window exceeded: provider capacity reached"), + undefined, + ) + }) + + it("scopes finalized message normalization to Command Code", () => { + const message = { + role: "assistant" as const, + provider: "commandcode", + stopReason: "error" as const, + errorMessage: "model context window exceeded", + } + + const normalized = normalizeCommandCodeMessage(message) + assert.equal( + normalized?.message.errorMessage, + "context_length_exceeded: model context window exceeded", + ) + assert.equal(normalizeCommandCodeMessage({ ...message, provider: "openai" }), undefined) + assert.equal(normalizeCommandCodeMessage({ ...message, stopReason: "stop" }), undefined) + }) + + it("extracts nested stream error messages without exposing credentials", () => { + assert.equal( + commandCodeErrorMessage({ + error: { details: { errorMessage: "context window exceeded" } }, + }), + "context window exceeded", + ) + }) + + it("redacts secrets from finalized provider errors", async () => { + server.mockResponse({ + type: "error", + status: 400, + body: "api_key=user_secret_value", + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.doesNotMatch(error.error.errorMessage ?? "", /user_secret_value/) + assert.match(error.error.errorMessage ?? "", /api_key=\[redacted\]/) + }) + + it("normalizes HTTP error bodies containing nested context errors", async () => { + server.mockResponse({ + type: "error", + status: 400, + body: JSON.stringify({ error: { message: "Prompt token limit exceeded" } }), + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + const error = events.at(-1) + + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + const normalized = normalizeCommandCodeMessage(error.error) + assert.match(normalized?.message.errorMessage ?? "", /^context_length_exceeded:/) + }) + + it("does not normalize an HTTP rate-limit response that mentions context", async () => { + server.mockResponse({ + type: "error", + status: 429, + body: JSON.stringify({ error: { message: "context window exceeded" } }), + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + const error = events.at(-1) + + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.equal(normalizeCommandCodeMessage(error.error), undefined) + }) + + it("normalizes nested stream error events", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "error", + error: { details: { message: "model context window exceeded" } }, + }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + const error = events.at(-1) + + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + const normalized = normalizeCommandCodeMessage(error.error) + assert.equal( + normalized?.message.errorMessage, + "context_length_exceeded: model context window exceeded", + ) + + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "error", + error: { message: "context window exceeded", status: 429 }, + }), + ], + }) + const retryEvents = await collectEvents( + createTestDeps({ apiBase: server.baseUrl() }).streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + }), + ) + const retryError = retryEvents.at(-1) + assert.equal(retryError?.type, "error") + if (retryError?.type !== "error") throw new Error("expected error") + assert.equal(normalizeCommandCodeMessage(retryError.error), undefined) + }) +}) diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts new file mode 100644 index 0000000..136025f --- /dev/null +++ b/tests/test-pure-functions.ts @@ -0,0 +1,658 @@ +/** + * Unit tests for the real pure helpers exported by src/core.ts. + * These are hermetic: no pi runtime and no network. + */ + +import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, it } from "node:test" + +import { + assertTextOnlyMessages, + getApiKey, + getEnvironmentInfo, + mapFinishReason, + messagesToCC, + parseStreamEventLine, + projectSlugFromPath, + textContent, + toJsonSchema, + toolsToJson, +} from "../src/core.ts" +import { redactCommandCodeErrorText } from "../src/overflow.ts" + +import { objectAt } from "./helpers.ts" + +describe("getApiKey()", () => { + it("uses COMMANDCODE_API_KEY from provided env", () => { + assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key") + }) + + it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) + try { + const first = join(dir, "first.json") + const second = join(dir, "second.json") + const oauth = join(dir, "oauth.json") + const official = join(dir, "official.json") + writeFileSync(first, JSON.stringify({ apiKey: "file-key" })) + writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })) + writeFileSync( + oauth, + JSON.stringify({ + commandcode: { + type: "oauth", + access: "oauth-access-key", + refresh: "oauth-refresh-key", + expires: Date.now() + 3600000, + }, + }), + ) + writeFileSync( + official, + JSON.stringify({ + "command-code": { + type: "api", + key: "official-cli-key", + }, + }), + ) + assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key") + assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key") + assert.equal(getApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key") + assert.equal(getApiKey({ env: {}, authPaths: [official] }), "official-cli-key") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("ignores malformed auth files", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-")) + try { + const bad = join(dir, "bad.json") + writeFileSync(bad, "not json") + assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("uses injected homeDir for default auth paths", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-home-")) + try { + const authDir = join(dir, ".pi", "agent") + mkdirSync(authDir, { recursive: true }) + writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" })) + assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe("error redaction", () => { + it("redacts bearer, credential, and query-string secrets", () => { + const redacted = redactCommandCodeErrorText( + "Bearer user_secret_value api_key=user_secret_value https://example.test/x?token=user_secret_value", + ) + assert.doesNotMatch(redacted, /user_secret_value/) + assert.match(redacted, /Bearer \[redacted\]/) + assert.doesNotMatch( + redactCommandCodeErrorText("provider returned sk-test-secret-value-1234567890"), + /sk-test-secret-value/, + ) + }) +}) + +describe("projectSlugFromPath()", () => { + it("matches the official CLI-style slug from an absolute working directory", () => { + assert.equal( + projectSlugFromPath("/Users/patwoz/dev/Personal/pi/pi-commandcode-provider"), + "users-patwoz-dev-personal-pi-pi-commandcode-provider", + ) + assert.equal(projectSlugFromPath("/repo"), "repo") + }) +}) + +describe("text-only image handling", () => { + it("rejects image content for models without image support", () => { + assert.throws( + () => + assertTextOnlyMessages([ + { + role: "user", + content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], + }, + ]), + /does not support image content/i, + ) + assert.throws( + () => + assertTextOnlyMessages([ + { + role: "toolResult", + toolCallId: "c1", + content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], + }, + ]), + /does not support image content/i, + ) + }) +}) + +describe("textContent()", () => { + it("extracts and joins text blocks", () => { + assert.equal( + textContent({ + content: [ + { type: "text", text: "hello" }, + { type: "text", text: "world" }, + ], + }), + "hello\nworld", + ) + }) + + it("extracts text while images are handled separately", () => { + assert.equal( + textContent({ + content: [ + { type: "text", text: "hello" }, + { type: "image", data: "x", mimeType: "image/png" }, + { type: "text", text: "world" }, + ], + }), + "hello\nworld", + ) + }) + + it("handles empty or missing content", () => { + assert.equal(textContent({ content: [] }), "") + assert.equal(textContent({}), "") + }) +}) + +describe("getEnvironmentInfo()", () => { + it("returns platform, arch, and Node version", () => { + const info = getEnvironmentInfo() + assert.match(info, /^(darwin|linux|win32)-/) + assert.ok(info.includes("Node.js")) + }) +}) + +describe("toJsonSchema()", () => { + it("converts scalar, enum, object, optional, array, and union schema shapes", () => { + assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" }) + assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" }) + assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" }) + assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), { + type: "string", + enum: ["left", "right"], + }) + assert.deepEqual( + toJsonSchema({ + kind: "object", + properties: { + name: { kind: "string" }, + tags: { kind: "array", items: { kind: "string" }, optional: true }, + }, + }), + { + type: "object", + properties: { + name: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["name"], + }, + ) + assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), { + type: "string", + }) + assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), { + type: "number", + }) + }) + + it("preserves explicit required arrays and handles unknown values", () => { + assert.deepEqual( + toJsonSchema({ + type: "object", + properties: { name: { type: "string" }, nickname: { type: "string" } }, + required: ["name"], + }), + { + type: "object", + properties: { name: { type: "string" }, nickname: { type: "string" } }, + required: ["name"], + }, + ) + assert.deepEqual(toJsonSchema(undefined), {}) + assert.deepEqual(toJsonSchema({ kind: "wat" }), {}) + assert.deepEqual(toJsonSchema({ type: "wat", description: "not a schema" }), {}) + assert.deepEqual(toJsonSchema({}), {}) + assert.equal(toJsonSchema(true), true) + }) + + it("preserves complete JSON Schema metadata and nested schemas", () => { + assert.deepEqual( + toJsonSchema({ + type: "object", + description: "Search options", + properties: { + query: { + type: "string", + description: "Text to search for", + minLength: 2, + maxLength: 50, + pattern: "^[a-z]+$", + default: "pi", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + exclusiveMinimum: 0, + multipleOf: 1, + default: 10, + }, + tags: { + type: "array", + minItems: 1, + maxItems: 3, + uniqueItems: true, + items: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + }, + }, + required: ["query", "limit"], + additionalProperties: false, + }), + { + type: "object", + description: "Search options", + properties: { + query: { + type: "string", + description: "Text to search for", + minLength: 2, + maxLength: 50, + pattern: "^[a-z]+$", + default: "pi", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + exclusiveMinimum: 0, + multipleOf: 1, + default: 10, + }, + tags: { + type: "array", + minItems: 1, + maxItems: 3, + uniqueItems: true, + items: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + }, + }, + required: ["query", "limit"], + additionalProperties: false, + }, + ) + }) + + it("preserves JSON Schema composition and nullable forms", () => { + assert.deepEqual( + toJsonSchema({ + anyOf: [{ type: "string" }, { type: "number" }], + oneOf: [{ const: "a" }, { const: "b" }], + allOf: [{ minLength: 1 }, { maxLength: 10 }], + nullable: true, + }), + { + anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + oneOf: [{ const: "a" }, { const: "b" }], + allOf: [{ minLength: 1 }, { maxLength: 10 }], + }, + ) + assert.deepEqual(toJsonSchema({ type: ["string", "null"] }), { + type: ["string", "null"], + }) + assert.deepEqual(toJsonSchema({ type: "string", nullable: true }), { + type: ["string", "null"], + }) + }) + + it("preserves dangerous schema property names", () => { + const inputProperties: Record = { + constructor: { type: "number" }, + } + Object.defineProperty(inputProperties, "__proto__", { + configurable: true, + enumerable: true, + value: { type: "string" }, + writable: true, + }) + const schema = toJsonSchema({ + type: "object", + properties: inputProperties, + required: ["__proto__", "constructor"], + }) + assert.ok(schema && typeof schema === "object" && !Array.isArray(schema)) + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error("expected object schema") + } + const outputProperties: unknown = Object.getOwnPropertyDescriptor(schema, "properties")?.value + assert.ok(outputProperties && typeof outputProperties === "object") + if (!outputProperties || typeof outputProperties !== "object") { + throw new Error("expected object properties") + } + assert.ok(Object.prototype.hasOwnProperty.call(outputProperties, "__proto__")) + assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "__proto__")?.value, { + type: "string", + }) + assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "constructor")?.value, { + type: "number", + }) + }) + + it("converts legacy shapes without collapsing unions", () => { + assert.deepEqual( + toJsonSchema({ + kind: "Object", + description: "Legacy options", + properties: { + mode: { + kind: "union", + variants: [ + { kind: "string", enum: ["fast", "safe"] }, + { kind: "string", enum: ["debug"] }, + ], + }, + count: { kind: "Number", minimum: 1, optional: true }, + nested: { + kind: "Array", + element: { kind: "object", properties: { value: { kind: "boolean" } } }, + }, + }, + optional: ["count"], + additionalProperties: false, + }), + { + type: "object", + description: "Legacy options", + properties: { + mode: { + anyOf: [ + { type: "string", enum: ["fast", "safe"] }, + { type: "string", enum: ["debug"] }, + ], + }, + count: { type: "number", minimum: 1 }, + nested: { + type: "array", + items: { + type: "object", + properties: { value: { type: "boolean" } }, + required: ["value"], + }, + }, + }, + required: ["mode", "nested"], + additionalProperties: false, + }, + ) + assert.deepEqual( + toJsonSchema({ + kind: "intersect", + variants: [{ kind: "object", properties: { a: { kind: "string" } } }, { kind: "number" }], + }), + { + allOf: [ + { type: "object", properties: { a: { type: "string" } }, required: ["a"] }, + { type: "number" }, + ], + }, + ) + }) +}) + +describe("toolsToJson()", () => { + it("converts pi tools to Command Code tool JSON", () => { + assert.deepEqual( + toolsToJson([ + { + name: "get_weather", + description: "Get weather", + parameters: { + kind: "object", + properties: { city: { kind: "string" } }, + }, + }, + ]), + [ + { + type: "function", + name: "get_weather", + description: "Get weather", + input_schema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + ], + ) + }) + + it("returns an empty array for missing tools", () => { + assert.deepEqual(toolsToJson(), []) + }) +}) + +describe("messagesToCC()", () => { + it("converts user, assistant, and tool result messages", () => { + const result = messagesToCC([ + { role: "user", content: "read /tmp/test" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I will read" }, + { type: "text", text: "Sure" }, + { + type: "toolCall", + id: "c1", + name: "read", + arguments: { path: "/tmp/test" }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "read", + isError: false, + content: [ + { type: "text", text: "hello" }, + { type: "text", text: "world" }, + ], + }, + ]) + + assert.equal(objectAt(result, ["0", "role"]), "user") + assert.equal(objectAt(result, ["1", "role"]), "assistant") + assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") + assert.equal(objectAt(result, ["1", "content", "1", "type"]), "tool-call") + assert.equal(objectAt(result, ["1", "content", "2"]), undefined) + assert.equal(objectAt(result, ["2", "role"]), "tool") + assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld") + }) + + it("serializes image inputs in the current Command Code wire format", () => { + assert.deepEqual( + messagesToCC( + [ + { + role: "user", + content: [ + { type: "text", text: "inspect this" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + }, + ], + { allowImages: true }, + ), + [ + { + role: "user", + content: [ + { type: "text", text: "inspect this" }, + { + type: "image", + image: "data:image/png;base64,aGVsbG8=", + mimeType: "image/png", + }, + ], + }, + ], + ) + }) + + it("preserves tool-result images as a following user image message", () => { + const result = messagesToCC( + [ + { role: "user", content: "read image" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "read", + content: [ + { type: "text", text: "image attached" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }, + ], + }, + ], + { allowImages: true }, + ) + + assert.equal(objectAt(result, ["2", "role"]), "tool") + assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "image attached") + assert.deepEqual(objectAt(result, ["3"]), { + role: "user", + content: [ + { + type: "image", + image: "data:image/jpeg;base64,aGVsbG8=", + mimeType: "image/jpeg", + }, + ], + }) + }) + + it("drops previous assistant reasoning while preserving text and tool calls", () => { + const result = messagesToCC([ + { role: "user", content: "first question" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning from turn one" }, + { type: "text", text: "first answer" }, + ], + }, + { role: "user", content: "follow-up question" }, + ]) + + assert.deepEqual(result, [ + { role: "user", content: "first question" }, + { role: "assistant", content: [{ type: "text", text: "first answer" }] }, + { role: "user", content: "follow-up question" }, + ]) + }) + + it("omits assistant turns that contain only previous reasoning", () => { + const result = messagesToCC([ + { role: "user", content: "first question" }, + { + role: "assistant", + content: [{ type: "thinking", thinking: "private reasoning" }], + }, + { role: "user", content: "follow-up question" }, + ]) + + assert.deepEqual(result, [ + { role: "user", content: "first question" }, + { role: "user", content: "follow-up question" }, + ]) + }) + + it("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 new file mode 100644 index 0000000..e0366de --- /dev/null +++ b/tests/test-retry.ts @@ -0,0 +1,470 @@ +/** + * 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 new file mode 100644 index 0000000..e30ac49 --- /dev/null +++ b/tests/test-stream.ts @@ -0,0 +1,771 @@ +/** + * 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 { MODEL_EFFORTS, thinkingLevelMapForEfforts } from "../src/models.ts" +import { + collectEvents, + createTestDeps, + makeContext, + makeModel, + objectAt, + startMockCommandCodeServer, + type MockCommandCodeServer, +} from "./helpers.ts" + +let server: MockCommandCodeServer + +before(async () => { + server = await startMockCommandCodeServer() +}) + +after(async () => { + await server.close() +}) + +beforeEach(() => { + server.reset() +}) + +function eventTypes(events: readonly AssistantMessageEvent[]): string[] { + return events.map((event) => event.type) +} + +describe("streamCommandCode — auth", () => { + it("emits a missing-key error without touching the network", async () => { + const { streamCommandCode } = createTestDeps({ + apiBase: server.baseUrl(), + env: {}, + authPaths: [], + }) + const stream = streamCommandCode(makeModel(), makeContext(), { + apiKey: "", + }) + const events = await collectEvents(stream) + + assert.deepEqual(eventTypes(events), ["error"]) + assert.equal(events[0].type, "error") + assert.equal(events[0].reason, "error") + assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/) + assert.equal(server.requestCount(), 0) + }) + + it("ignores the literal env-var name and falls back to env", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ + apiBase: server.baseUrl(), + env: { COMMANDCODE_API_KEY: "env-key" }, + }) + + await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "COMMANDCODE_API_KEY" }), + ) + + assert.equal( + server.lastRequestHeaders().authorization, + "Bearer env-key", + "should resolve from env, not send the literal var name as the token", + ) + }) + + it("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: 3124, + outputTokens: 15, + inputTokenDetails: { noCacheTokens: 52, cacheReadTokens: 3072 }, + }, + }), + ], + }) + const { streamCommandCode, calculatedUsages } = createTestDeps({ + apiBase: server.baseUrl(), + }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + assert.deepEqual(eventTypes(events), [ + "start", + "text_start", + "text_delta", + "text_delta", + "text_end", + "done", + ]) + const done = events.at(-1) + assert.equal(done?.type, "done") + if (done?.type !== "done") throw new Error("expected done") + assert.equal(done.reason, "stop") + assert.equal(done.message.content[0]?.type, "text") + assert.equal( + done.message.content[0]?.type === "text" ? done.message.content[0].text : "", + "Hello", + ) + assert.equal(done.message.usage.input, 52) + assert.equal(done.message.usage.cacheRead, 3072) + assert.equal(done.message.usage.cacheWrite, 0) + assert.equal(done.message.usage.totalTokens, 3139) + assert.equal(calculatedUsages.length, 1) + }) + + it("sends images for vision-capable models", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode( + makeModel({ id: "gpt-5.6-luna" }), + makeContext({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "inspect" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + }, + ], + }), + { apiKey: "mock-key" }, + ), + ) + + assert.equal( + objectAt(server.lastRequestBody(), ["params", "messages", "0", "content", "1", "image"]), + "data:image/png;base64,aGVsbG8=", + ) + }) + + it("rejects images before network access for text-only models", async () => { + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode( + makeModel({ id: "deepseek/deepseek-v4-pro" }), + makeContext({ + messages: [ + { + role: "user", + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + }, + ], + }), + { apiKey: "mock-key" }, + ), + ) + + assert.equal(events.at(-1)?.type, "error") + assert.equal(server.requestCount(), 0) + }) + + it("derives uncached input when noCacheTokens is missing", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "finish", + finishReason: "stop", + totalUsage: { + inputTokens: 100, + outputTokens: 10, + inputTokenDetails: { cacheReadTokens: 75 }, + }, + }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const done = events.at(-1) + assert.equal(done?.type, "done") + if (done?.type !== "done") throw new Error("expected done") + assert.equal(done.message.usage.input, 25) + assert.equal(done.message.usage.cacheRead, 75) + assert.equal(done.message.usage.cacheWrite, 0) + assert.equal(done.message.usage.totalTokens, 110) + }) + + it("accounts for cache writes separately from uncached input", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "finish", + finishReason: "stop", + totalUsage: { + inputTokens: 100, + outputTokens: 10, + inputTokenDetails: { + noCacheTokens: 20, + cacheReadTokens: 70, + cacheWriteTokens: 10, + }, + }, + }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const done = events.at(-1) + assert.equal(done?.type, "done") + if (done?.type !== "done") throw new Error("expected done") + assert.equal(done.message.usage.input, 20) + assert.equal(done.message.usage.cacheRead, 70) + assert.equal(done.message.usage.cacheWrite, 10) + assert.equal(done.message.usage.totalTokens, 110) + }) + + it("ends on finish without waiting for an open upstream connection", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ type: "text-delta", text: "done" }), + JSON.stringify({ type: "finish", finishReason: "stop" }), + ], + hangAfterLast: true, + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + 500, + ) + + assert.equal(events.at(-1)?.type, "done") + await new Promise((resolve) => setTimeout(resolve, 50)) + assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body") + }) + + it("emits reasoning and tool-call blocks in order", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ type: "reasoning-start" }), + JSON.stringify({ type: "reasoning-delta", text: "think" }), + JSON.stringify({ type: "reasoning-end" }), + JSON.stringify({ type: "text-delta", text: "Using tool" }), + JSON.stringify({ + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: JSON.stringify({ path: "/tmp/x" }), + }), + JSON.stringify({ type: "finish", finishReason: "tool-calls" }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + assert.deepEqual(eventTypes(events), [ + "start", + "thinking_start", + "thinking_delta", + "thinking_end", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_end", + "done", + ]) + const done = events.at(-1) + if (done?.type !== "done") throw new Error("expected done") + assert.equal(done.reason, "toolUse") + assert.deepEqual( + done.message.content.map((content) => content.type), + ["thinking", "text", "toolCall"], + ) + const toolCall = done.message.content[2] + assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file") + }) + + it("flushes reasoning if finish arrives without reasoning-end", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ type: "reasoning-delta", text: "unfinished thought" }), + JSON.stringify({ type: "finish", finishReason: "stop" }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const done = events.at(-1) + if (done?.type !== "done") throw new Error("expected done") + assert.equal(done.message.content[0]?.type, "thinking") + }) + + it("closes thinking block before text when reasoning-end is missing", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ type: "reasoning-start" }), + JSON.stringify({ type: "reasoning-delta", text: "thinking" }), + JSON.stringify({ type: "text-delta", text: "answer" }), + JSON.stringify({ type: "finish", finishReason: "stop" }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + assert.deepEqual(eventTypes(events), [ + "start", + "thinking_start", + "thinking_delta", + "thinking_end", + "text_start", + "text_delta", + "text_end", + "done", + ]) + }) + + it("closes thinking block before tool-call when reasoning-end is missing", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ type: "reasoning-start" }), + JSON.stringify({ type: "reasoning-delta", text: "thinking" }), + JSON.stringify({ + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: JSON.stringify({ path: "/tmp/x" }), + }), + JSON.stringify({ type: "finish", finishReason: "tool-calls" }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + assert.deepEqual(eventTypes(events), [ + "start", + "thinking_start", + "thinking_delta", + "thinking_end", + "toolcall_start", + "toolcall_end", + "done", + ]) + }) +}) + +describe("streamCommandCode — request serialization", () => { + it("rejects image input before sending a lossy request", async () => { + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const events = await collectEvents( + streamCommandCode( + makeModel(), + makeContext({ + messages: [ + { + role: "user", + content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], + }, + ], + }), + { apiKey: "mock-key" }, + ), + ) + + assert.deepEqual(eventTypes(events), ["start", "error"]) + const lastEvent = events.at(-1) + assert.equal(lastEvent?.type, "error") + if (lastEvent?.type === "error") { + assert.match(lastEvent.error.errorMessage ?? "", /does not support image content/i) + } + assert.equal(server.requestCount(), 0) + }) + it("sends the expected request body and default headers", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const context = makeContext({ + messages: [ + { role: "user", content: "first" }, + { + role: "assistant", + content: [{ type: "text", text: "first response" }], + }, + { role: "user", content: "second" }, + ], + tools: [ + { + name: "get_weather", + description: "Get weather", + parameters: { + kind: "object", + properties: { city: { kind: "string" } }, + }, + }, + ], + }) + + await collectEvents( + streamCommandCode(makeModel(), context, { + apiKey: "mock-key", + maxTokens: 500, + }), + ) + + const body = server.lastRequestBody() + assert.equal(objectAt(body, ["config", "workingDir"]), "/repo") + assert.equal(objectAt(body, ["config", "date"]), "2026-05-05") + assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash") + assert.equal(objectAt(body, ["params", "stream"]), true) + assert.equal(objectAt(body, ["params", "max_tokens"]), 500) + assert.equal(objectAt(body, ["params", "reasoning_effort"]), undefined) + assert.equal(objectAt(body, ["params", "temperature"]), 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"], "1.15.1") + 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("accepts the legacy OMP nested reasoning map", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const model = makeModel({ + id: "omp-compat-reasoning-model", + reasoning: true, + thinking: { effortMap: { high: "legacy-high" } }, + }) + + await collectEvents( + streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "high" }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "legacy-high") + }) + + it("forwards a supported Pi reasoning level as reasoning_effort", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const model = makeModel({ + id: "deepseek/deepseek-v4-flash", + reasoning: true, + thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), + }) + + await collectEvents( + streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "max" }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "max") + }) + + it("omits reasoning_effort for off, unsupported, and unknown reasoning levels", async () => { + const model = makeModel({ + id: "deepseek/deepseek-v4-flash", + reasoning: true, + thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), + }) + + for (const reasoning of ["off", "low"] as const) { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning }), + ) + assert.equal( + objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), + undefined, + `${reasoning} should not be sent when it has no supported Command Code field`, + ) + server.reset() + } + + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + await collectEvents( + streamCommandCode( + makeModel({ id: "new-model-without-metadata", reasoning: false }), + makeContext(), + { apiKey: "mock-key", reasoning: "high" }, + ), + ) + assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), undefined) + }) + + it("caps maxTokens and passes custom headers", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), { + apiKey: "mock-key", + maxTokens: 500_000, + headers: { "x-custom": "value" }, + }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 64_000) + assert.equal(server.lastRequestHeaders()["x-custom"], "value") + }) + + it("caps default maxTokens by the selected model", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode(makeModel({ maxTokens: 8_192 }), makeContext(), { + apiKey: "mock-key", + }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 8_192) + }) + + it("serializes OMP system prompt arrays as a string", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode( + makeModel(), + makeContext({ + systemPrompt: ["You are a test assistant.", "Use concise answers."] as unknown as string, + }), + { apiKey: "mock-key" }, + ), + ) + + assert.equal( + objectAt(server.lastRequestBody(), ["params", "system"]), + "You are a test assistant.\n\nUse concise answers.", + ) + }) + + it("times out a hung onResponse callback", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const started = Date.now() + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + timeoutMs: 25, + onResponse: async () => new Promise(() => {}), + }), + 1_000, + ) + + assert.ok(Date.now() - started < 500) + assert.deepEqual(eventTypes(events), ["start", "error"]) + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.match(error.error.errorMessage ?? "", /timed out after 25ms/) + }) + + it("times out a hung onPayload callback", async () => { + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const started = Date.now() + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + timeoutMs: 25, + onPayload: async () => new Promise(() => {}), + }), + 1_000, + ) + + assert.ok(Date.now() - started < 500) + assert.deepEqual(eventTypes(events), ["start", "error"]) + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.match(error.error.errorMessage ?? "", /timed out after 25ms/) + assert.equal(server.requestCount(), 0) + }) + + it("runs onPayload and onResponse hooks", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + let responseStatus = 0 + + await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + onPayload: () => ({ replaced: true }), + onResponse: (response) => { + responseStatus = response.status + }, + }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true) + assert.equal(responseStatus, 200) + }) +}) + +describe("streamCommandCode — upstream errors and malformed streams", () => { + it("emits error for HTTP failures", async () => { + server.mockResponse({ type: "error", status: 429, body: "rate limited" }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + assert.deepEqual(eventTypes(events), ["start", "error"]) + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.match(error.error.errorMessage ?? "", /429/) + }) + + it("emits error for provider error events", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "error", + error: { message: "provider failed" }, + }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.equal(error.error.errorMessage, "provider failed") + }) + + it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => { + const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n` + const finishEvent = JSON.stringify({ + type: "finish", + finishReason: "max_tokens", + }) + server.mockResponse({ + type: "success", + chunks: [ + "not json\n", + textEvent.slice(0, 12), + textEvent.slice(12), + "event: ignored\n", + "data: [DONE]\n", + finishEvent, + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const done = events.at(-1) + if (done?.type !== "done") throw new Error("expected done") + assert.equal(done.reason, "length") + assert.equal( + done.message.content[0]?.type === "text" ? done.message.content[0].text : "", + "split", + ) + }) +}) diff --git a/tests/test-transport.ts b/tests/test-transport.ts new file mode 100644 index 0000000..59ea51b --- /dev/null +++ b/tests/test-transport.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { createCommandCodeTransportRouter } from "../src/transport.ts" +import type { + AssistantMessageEvent, + AssistantMessageEventStreamLike, + StreamOptions, +} from "../src/types.ts" +import { collectEvents, createTestEventStream, makeContext, makeModel } from "./helpers.ts" + +function completedStream(text: string): AssistantMessageEventStreamLike { + const stream = createTestEventStream() + const model = makeModel() + const message = { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + } + const events: AssistantMessageEvent[] = [ + { type: "start", partial: message }, + { type: "text_start", contentIndex: 0, partial: message }, + { type: "text_delta", contentIndex: 0, delta: text, partial: message }, + { type: "text_end", contentIndex: 0, content: text, partial: message }, + { type: "done", reason: "stop", message }, + ] + for (const event of events) stream.push(event) + stream.end() + return stream +} + +function providerStream( + response: Response, + text: string, + options?: StreamOptions, +): AssistantMessageEventStreamLike { + const stream = createTestEventStream() + const run = async () => { + const received = await (options?.fetch ?? fetch)("https://provider.test", {}) + await options?.onResponse?.( + { status: received.status, headers: {} }, + makeModel({ api: "openai-completions" }), + ) + const source = completedStream(text) + for await (const event of source) stream.push(event) + stream.end() + } + run().catch(() => stream.end()) + return stream +} + +describe("Command Code transport router", () => { + it("keeps using the Provider API after a successful request", async () => { + let providerCalls = 0 + let generateCalls = 0 + const router = createCommandCodeTransportRouter({ + createStream: createTestEventStream, + streamProvider: (_model, _context, options) => { + providerCalls += 1 + return providerStream(new Response("ok", { status: 200 }), "provider", options) + }, + streamGenerate: () => { + generateCalls += 1 + return completedStream("generate") + }, + }) + + const options: StreamOptions = { + fetch: () => Promise.resolve(new Response("ok", { status: 200 })), + } + const first = await collectEvents(router.stream(makeModel(), makeContext(), options)) + const second = await collectEvents(router.stream(makeModel(), makeContext(), options)) + + assert.equal(first.at(-1)?.type, "done") + assert.equal(second.at(-1)?.type, "done") + assert.equal(router.getTransport(), "provider") + assert.equal(providerCalls, 2) + assert.equal(generateCalls, 0) + }) + + it("falls back only for 403 upgrade_required and remembers generate", async () => { + let providerCalls = 0 + let generateCalls = 0 + const responseBody = JSON.stringify({ + error: { code: "upgrade_required", type: "permission_error" }, + }) + const router = createCommandCodeTransportRouter({ + createStream: createTestEventStream, + streamProvider: (_model, _context, options) => { + providerCalls += 1 + return providerStream(new Response(responseBody, { status: 403 }), "blocked", options) + }, + streamGenerate: () => { + generateCalls += 1 + return completedStream("generate") + }, + }) + const options: StreamOptions = { + fetch: () => Promise.resolve(new Response(responseBody, { status: 403 })), + } + + const first = await collectEvents(router.stream(makeModel(), makeContext(), options)) + const second = await collectEvents(router.stream(makeModel(), makeContext(), options)) + + assert.equal(first.at(-1)?.type, "done") + assert.equal(second.at(-1)?.type, "done") + assert.equal(router.getTransport(), "generate") + assert.equal(providerCalls, 1) + assert.equal(generateCalls, 2) + }) + + it("re-detects the transport after the API key changes", async () => { + let providerCalls = 0 + let generateCalls = 0 + const upgradeBody = JSON.stringify({ error: { code: "upgrade_required" } }) + const router = createCommandCodeTransportRouter({ + createStream: createTestEventStream, + streamProvider: (_model, _context, options) => { + providerCalls += 1 + const response = + options?.apiKey === "go-key" + ? new Response(upgradeBody, { status: 403 }) + : new Response("ok", { status: 200 }) + return providerStream(response, "provider", options) + }, + streamGenerate: () => { + generateCalls += 1 + return completedStream("generate") + }, + }) + + await collectEvents( + router.stream(makeModel(), makeContext(), { + apiKey: "go-key", + fetch: () => Promise.resolve(new Response(upgradeBody, { status: 403 })), + }), + ) + await collectEvents( + router.stream(makeModel(), makeContext(), { + apiKey: "provider-key", + fetch: () => Promise.resolve(new Response("ok", { status: 200 })), + }), + ) + + assert.equal(router.getTransport(), "provider") + assert.equal(providerCalls, 2) + assert.equal(generateCalls, 1) + }) + + it("does not fall back for other 403 errors", async () => { + let generateCalls = 0 + const responseBody = JSON.stringify({ error: { code: "permission_denied" } }) + const router = createCommandCodeTransportRouter({ + createStream: createTestEventStream, + streamProvider: (_model, _context, options) => + providerStream(new Response(responseBody, { status: 403 }), "blocked", options), + streamGenerate: () => { + generateCalls += 1 + return completedStream("generate") + }, + }) + const options: StreamOptions = { + fetch: () => Promise.resolve(new Response(responseBody, { status: 403 })), + } + + await collectEvents(router.stream(makeModel(), makeContext(), options)) + + assert.equal(router.getTransport(), "provider") + assert.equal(generateCalls, 0) + }) +}) From 91032722172ca867bec89d95686405b8b4640400 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 12:11:47 +0200 Subject: [PATCH 07/13] docs(api): explain automatic transport selection --- CHANGELOG.md | 5 +++-- README.md | 13 +++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 887e50d..b58dd8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ ## Unreleased -- Replace the reverse-engineered `/alpha/generate` integration with Command Code's documented Provider API: `/provider/v1/chat/completions` for non-Claude models and `/provider/v1/messages` for Claude models. -- Use Pi's native OpenAI- and Anthropic-compatible providers for streaming, tools, reasoning, images, usage, errors, and retries while preserving dynamic model discovery, offline cache, refresh/status commands, pricing, and OAuth credentials. +- Prefer Command Code's Provider API (`/provider/v1/chat/completions` and `/provider/v1/messages`) and automatically fall back to the existing `/alpha/generate` transport only when the Provider API returns `403 upgrade_required` for a Go-plan account. +- Remember the detected transport for the running process, re-detect it when credentials change, and never fall back for unrelated authentication, permission, rate-limit, network, or server failures. +- Use Pi's native OpenAI- and Anthropic-compatible providers for Provider API streaming while preserving the existing hardened generate transport, dynamic model discovery, offline cache, refresh/status commands, pricing, and OAuth credentials. - Let `/login` use browser authentication, an explicit API-key prompt, or a directly pasted API key. - Add optional zero-data-retention headers through `COMMANDCODE_ZDR=1`. - Refresh GPT-5.6 Terra and Luna display prices after their temporary 50% promotion ended. diff --git a/README.md b/README.md index 1ac02a6..e1e4a3a 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ A custom provider for [pi](https://github.com/earendil-works/pi) that connects t > **Disclaimer:** This is an unofficial, community-maintained integration. It is not affiliated with, endorsed by, or supported by Command Code. You need your own Command Code account, API key, and a plan with Provider API access. Command Code's terms, availability, and pricing apply. -The extension uses only Command Code's documented Provider API endpoints: +The extension uses one provider and automatically selects the transport supported by the authenticated account: -- `GET /provider/v1/models` -- `POST /provider/v1/chat/completions` for non-Claude models -- `POST /provider/v1/messages` for Claude models +- `GET /provider/v1/models` for model discovery +- `POST /provider/v1/chat/completions` for non-Claude models with Provider API access +- `POST /provider/v1/messages` for Claude models with Provider API access +- `/alpha/generate` after the Provider API explicitly returns `403 upgrade_required`, which currently identifies Go-plan accounts -Every current Command Code plan except **Go** has Provider API access: GOAT, Pro, Max, Team, and Provider. +The detected transport is remembered only for the running process and is re-evaluated when the credential changes. Other authentication, permission, rate-limit, network, and server errors never trigger the fallback. ## Install @@ -92,7 +93,7 @@ Open `/model` and select one of the models provided by Command Code. Model avail ### Reasoning support -Reasoning metadata is enriched only for models whose Command Code effort support is known. Those models register a model-specific `thinkingLevelMap`, so pi and OMP expose only supported levels. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level to the endpoint's documented reasoning fields. Unsupported levels and newly discovered models without metadata do not claim reasoning support. +Reasoning metadata is enriched only for models whose Command Code effort support is known. Those models register a model-specific `thinkingLevelMap`, so pi and OMP expose only supported levels. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level for Provider API accounts; the existing Command Code generate transport sends the matching `reasoning_effort` for Go accounts. Unsupported levels and newly discovered models without metadata do not claim reasoning support. List Command Code models from the terminal: From 75552ef4b90e08be96de011bd5ae3623b28a5a7d Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 17:20:44 +0200 Subject: [PATCH 08/13] test(e2e): add live account profiles --- index.ts | 1 + package.json | 3 ++ scripts/live-e2e-profile.mjs | 74 +++++++++++++++++++++++++++++++++++ src/runtime.ts | 15 ++++--- tests/test-live-e2e.mjs | 76 ++++++++++++++++++++++++------------ tests/test-runtime.ts | 2 + 6 files changed, 141 insertions(+), 30 deletions(-) create mode 100644 scripts/live-e2e-profile.mjs diff --git a/index.ts b/index.ts index d263138..1f4aeb2 100644 --- a/index.ts +++ b/index.ts @@ -127,6 +127,7 @@ export default async function (pi: ExtensionAPI) { timeoutMs: modelsTimeoutMs, }), createProviderConfig: (models) => createProviderConfig(models, apiBase, transport.stream), + getTransport: transport.getTransport, }) await runtime.initialize() diff --git a/package.json b/package.json index c99a0aa..f284d3b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,9 @@ "test:pi-local": "node tests/test-pi-local.mjs", "test:smoke": "node tests/test-smoke.mjs", "test:e2e:live": "node tests/test-live-e2e.mjs", + "test:e2e:live:go": "node scripts/live-e2e-profile.mjs go", + "test:e2e:live:provider": "node scripts/live-e2e-profile.mjs provider", + "test:e2e:live:all": "node scripts/live-e2e-profile.mjs go provider", "test:cost": "tsx tests/test-cost.ts" }, "pi": { diff --git a/scripts/live-e2e-profile.mjs b/scripts/live-e2e-profile.mjs new file mode 100644 index 0000000..01e9bab --- /dev/null +++ b/scripts/live-e2e-profile.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process" +import { readFile } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const liveTest = resolve(projectDir, "tests", "test-live-e2e.mjs") +const profiles = process.argv.slice(2) + +if ( + profiles.length === 0 || + profiles.some((profile) => profile !== "go" && profile !== "provider") +) { + console.error("Usage: node scripts/live-e2e-profile.mjs [go|provider]") + process.exit(2) +} + +async function credentialFor(profile) { + const prefix = profile === "go" ? "COMMANDCODE_E2E_GO" : "COMMANDCODE_E2E_PROVIDER" + const direct = process.env[`${prefix}_API_KEY`]?.trim() + const file = process.env[`${prefix}_API_KEY_FILE`] + + if (direct && file) + throw new Error(`${prefix}_API_KEY and ${prefix}_API_KEY_FILE are mutually exclusive`) + if (direct) return direct + if (file) { + const credential = (await readFile(file, "utf-8")).trim() + if (credential) return credential + } + + throw new Error(`Set ${prefix}_API_KEY_FILE (recommended) or ${prefix}_API_KEY`) +} + +function runProfile(profile, apiKey) { + const modelVariable = + profile === "go" ? "COMMANDCODE_E2E_GO_MODEL" : "COMMANDCODE_E2E_PROVIDER_MODEL" + const model = process.env[modelVariable] ?? "deepseek/deepseek-v4-flash" + const env = { + ...process.env, + COMMANDCODE_API_KEY: apiKey, + COMMANDCODE_E2E_MODEL: model, + COMMANDCODE_E2E_PROFILE: profile, + } + delete env.COMMANDCODE_E2E_GO_API_KEY + delete env.COMMANDCODE_E2E_PROVIDER_API_KEY + + return new Promise((resolveRun, reject) => { + console.log(`[live-e2e:${profile}] model ${model}`) + const child = spawn(process.execPath, [liveTest], { + cwd: projectDir, + env, + stdio: "inherit", + }) + child.on("error", reject) + child.on("close", (code, signal) => { + if (code === 0) { + resolveRun() + return + } + reject(new Error(`[live-e2e:${profile}] failed (${signal ?? `exit ${code}`})`)) + }) + }) +} + +try { + for (const profile of profiles) { + await runProfile(profile, await credentialFor(profile)) + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +} diff --git a/src/runtime.ts b/src/runtime.ts index 34c034a..e103f96 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -28,11 +28,13 @@ export interface CommandCodeRuntimeOptions { cachePath: string loadModels: () => Promise createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig + getTransport?: () => "unknown" | "provider" | "generate" now?: () => number logWarning?: (message: string) => void } export interface CommandCodeRuntimeStatus { + transport: "unknown" | "provider" | "generate" source: LoadCommandCodeModelsResult["source"] modelCount: number lastSuccess?: number @@ -86,6 +88,7 @@ function formatTimestamp(timestamp: number | undefined): string { export function formatCommandCodeStatus(status: CommandCodeRuntimeStatus): string { const lines = [ + `transport: ${status.transport}`, `source: ${status.source}`, `model count: ${status.modelCount}`, `last success: ${formatTimestamp(status.lastSuccess)}`, @@ -113,6 +116,7 @@ export class CommandCodeRuntime console.warn(`[commandcode] ${message}`)) const initialStatus: CommandCodeRuntimeStatus = { + transport: "unknown", source: "empty", modelCount: 0, cachePath: options.cachePath, @@ -123,7 +127,10 @@ export class CommandCodeRuntime { @@ -259,10 +266,8 @@ export class CommandCodeRuntime { - ctx.ui.notify( - formatCommandCodeStatus(this.status), - this.status.warning ? "warning" : "info", - ) + const status = this.getStatus() + ctx.ui.notify(formatCommandCodeStatus(status), status.warning ? "warning" : "info") }, }) } diff --git a/tests/test-live-e2e.mjs b/tests/test-live-e2e.mjs index 160882a..88ca636 100644 --- a/tests/test-live-e2e.mjs +++ b/tests/test-live-e2e.mjs @@ -25,6 +25,9 @@ import { fileURLToPath } from "node:url" const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") const extensionPath = join(projectDir, "index.ts") const testModel = process.env.COMMANDCODE_E2E_MODEL ?? "deepseek/deepseek-v4-flash" +const testProfile = process.env.COMMANDCODE_E2E_PROFILE +const expectedTransport = + testProfile === "go" ? "generate" : testProfile === "provider" ? "provider" : undefined const marker = "commandcode-live-e2e-ok" function findPiBinary() { @@ -57,9 +60,18 @@ if (!piBin || !hasAuthMetadata()) { process.exit(0) } +const profileAgentDir = testProfile + ? mkdtempSync(join(tmpdir(), `pi-commandcode-live-${testProfile}-agent-`)) + : undefined + function safeEnv(overrides = {}) { const env = { ...process.env, PI_SKIP_VERSION_CHECK: "1", ...overrides } - delete env.COMMANDCODE_API_KEY + if (testProfile && profileAgentDir) { + env.PI_CODING_AGENT_DIR = profileAgentDir + env.COMMANDCODE_MODELS_CACHE = join(profileAgentDir, "commandcode-models.json") + } else { + delete env.COMMANDCODE_API_KEY + } return env } @@ -228,12 +240,22 @@ try { return { firstThinkingDeltas, secondThinkingDeltas, stderr: getStderr() } }) - assert.ok(multiTurn.firstThinkingDeltas > 0, "first turn should stream reasoning") - assert.ok(multiTurn.secondThinkingDeltas > 0, "follow-up turn should stream fresh reasoning") + if (testProfile !== "provider") { + assert.ok(multiTurn.firstThinkingDeltas > 0, "first turn should stream reasoning") + assert.ok(multiTurn.secondThinkingDeltas > 0, "follow-up turn should stream fresh reasoning") + } assert.doesNotMatch(multiTurn.stderr, /Bearer\s+\S+/i) console.log("[live-e2e] live runtime refresh/status commands") const runtime = await runRpc(extensionPath, async ({ send, waitFor, getStderr }) => { + if (expectedTransport) { + send({ id: "transport-probe", type: "prompt", message: `Reply exactly: ${marker}` }) + await waitFor( + (event) => event.type === "response" && event.id === "transport-probe" && event.success, + ) + await waitFor((event) => event.type === "agent_settled") + } + send({ id: "commands", type: "get_commands" }) const commands = await waitFor( (event) => event.type === "response" && event.id === "commands" && event.success, @@ -264,6 +286,7 @@ try { assert.ok(runtime.names.includes("commandcode-refresh")) assert.ok(runtime.names.includes("commandcode-status")) assert.match(runtime.refresh, /model catalog (?:refreshed|unchanged)/) + if (expectedTransport) assert.match(runtime.status, new RegExp(`transport: ${expectedTransport}`)) assert.match(runtime.status, /source: (?:live|cache)/) assert.match(runtime.status, /model count: [1-9][0-9]*/) assert.doesNotMatch(`${runtime.refresh}\n${runtime.status}\n${runtime.stderr}`, /Bearer\s+\S+/i) @@ -296,30 +319,32 @@ try { assert.match(toolResult.stdout, new RegExp(marker)) assert.equal(readFileSync(targetPath, "utf-8"), marker) - console.log("[live-e2e] image rejection through real RPC host") - const image = await runRpc(extensionPath, async ({ send, waitFor, events }) => { - send({ - id: "image", - type: "prompt", - message: "Describe this image", - images: [{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }], + if (testProfile !== "provider") { + console.log("[live-e2e] image rejection through real RPC host") + const image = await runRpc(extensionPath, async ({ send, waitFor, events }) => { + send({ + id: "image", + type: "prompt", + message: "Describe this image", + images: [{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }], + }) + await waitFor((event) => event.type === "response" && event.id === "image") + await waitFor( + (event) => + event.type === "message_end" && + event.message?.role === "assistant" && + event.message?.stopReason === "error", + ) + return events }) - await waitFor((event) => event.type === "response" && event.id === "image") - await waitFor( - (event) => - event.type === "message_end" && - event.message?.role === "assistant" && - event.message?.stopReason === "error", + assert.ok( + image.some( + (event) => + event.type === "message_end" && + /does not support image content/i.test(event.message?.errorMessage ?? ""), + ), ) - return events - }) - assert.ok( - image.some( - (event) => - event.type === "message_end" && - /does not support image content/i.test(event.message?.errorMessage ?? ""), - ), - ) + } console.log("[live-e2e] packed artifact with existing authentication") const packDir = join(tempRoot, "pack") @@ -361,4 +386,5 @@ try { console.log("[live-e2e] PASS") } finally { rmSync(tempRoot, { recursive: true, force: true }) + if (profileAgentDir) rmSync(profileAgentDir, { recursive: true, force: true }) } diff --git a/tests/test-runtime.ts b/tests/test-runtime.ts index 397c54e..2a89f12 100644 --- a/tests/test-runtime.ts +++ b/tests/test-runtime.ts @@ -98,6 +98,7 @@ describe("Command Code runtime", () => { cachePath: "/tmp/commandcode-models.json", loadModels: () => firstLoad.promise, createProviderConfig: (models) => ({ models }), + getTransport: () => "provider", now: () => now, logWarning: () => {}, }) @@ -115,6 +116,7 @@ describe("Command Code runtime", () => { assert.ok(statusCommand) await statusCommand("", context) const statusMessage = context.notifications.at(-1)?.message ?? "" + assert.match(statusMessage, /transport: provider/) assert.match(statusMessage, /source: live/) assert.match(statusMessage, /model count: 1/) assert.match(statusMessage, /last success:/) From 33c405c06005f0c430f9ad260d53d90173c6853a Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 18 Aug 2026 17:20:51 +0200 Subject: [PATCH 09/13] docs(tests): document live account credentials --- CHANGELOG.md | 1 + CONTRIBUTING.md | 9 +++++++++ README.md | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b58dd8a..cf4942c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Let `/login` use browser authentication, an explicit API-key prompt, or a directly pasted API key. - Add optional zero-data-retention headers through `COMMANDCODE_ZDR=1`. - Refresh GPT-5.6 Terra and Luna display prices after their temporary 50% promotion ended. +- Add isolated live E2E profiles for separate Go-plan and Provider-API credentials, including an explicit selected-transport assertion and packed-package validation. ## 0.5.1 - 2026-08-11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da4d0a4..6adb7bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,15 @@ npm run pi:authenticated Both commands accept additional pi arguments after `--`, for example `npm run pi:authenticated -- --model claude-sonnet-4-6`. +Run the transport-specific live tests with separate credentials: + +```sh +COMMANDCODE_E2E_GO_API_KEY_FILE=/path/to/go-key npm run test:e2e:live:go +COMMANDCODE_E2E_PROVIDER_API_KEY_FILE=/path/to/provider-key npm run test:e2e:live:provider +``` + +Use `npm run test:e2e:live:all` with both file variables to run them sequentially. Store the keys in a secret manager and export each one to a new mode-`0600` temporary file for the test; never add key files to the repository. The direct `COMMANDCODE_E2E_GO_API_KEY` and `COMMANDCODE_E2E_PROVIDER_API_KEY` variables are intended primarily for protected CI secrets. + Before opening a PR, run: ```sh diff --git a/README.md b/README.md index e1e4a3a..6c37492 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,26 @@ npm run pi:authenticated Both commands accept additional pi arguments after `--`, for example `npm run pi:authenticated -- --model claude-sonnet-4-6`. +### Live transport tests + +Keep the Go-plan and Provider-API test keys in separate secret-manager entries. Pass them through protected files so the keys do not enter shell history: + +```sh +COMMANDCODE_E2E_GO_API_KEY_FILE=/path/to/go-key \ + npm run test:e2e:live:go + +COMMANDCODE_E2E_PROVIDER_API_KEY_FILE=/path/to/provider-key \ + npm run test:e2e:live:provider + +COMMANDCODE_E2E_GO_API_KEY_FILE=/path/to/go-key \ +COMMANDCODE_E2E_PROVIDER_API_KEY_FILE=/path/to/provider-key \ + npm run test:e2e:live:all +``` + +Each profile runs with an isolated Pi agent directory and asserts the selected transport through `/commandcode-status`: Go must select `generate`, while a Provider API account must select `provider`. The profile-specific `*_API_KEY` environment variables are also supported for CI secrets, but key files are preferred for local use. + +Override the default DeepSeek test model with `COMMANDCODE_E2E_GO_MODEL` or `COMMANDCODE_E2E_PROVIDER_MODEL`. A successful live Anthropic `/provider/v1/messages` test requires a Provider API account whose plan includes the selected Claude model. + See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup and tests. See [RELEASE.md](RELEASE.md) for the release process. ## License From 4f91a9b00b4fd06b87978986dbaa1748186968ff Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 20 Aug 2026 00:13:56 +0200 Subject: [PATCH 10/13] fix(stream): isolate transport state by credential --- src/transport.ts | 5 ++-- tests/test-transport.ts | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/transport.ts b/src/transport.ts index 9d83d5d..c30171c 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -71,6 +71,7 @@ export function createCommandCodeTransportRouter(deps: TransportDependencies) { apiKey = options?.apiKey transport = "unknown" } + const requestApiKey = options?.apiKey if (transport === "generate") return deps.streamGenerate(model, context, options) const output = deps.createStream() @@ -94,13 +95,13 @@ export function createCommandCodeTransportRouter(deps: TransportDependencies) { for await (const event of providerStream) { if (!upgradeRequired) { - transport = "provider" + if (apiKey === requestApiKey) transport = "provider" output.push(event) } } if (upgradeRequired) { - transport = "generate" + if (apiKey === requestApiKey) transport = "generate" await pipe(deps.streamGenerate(model, context, options), output) } output.end() diff --git a/tests/test-transport.ts b/tests/test-transport.ts index 59ea51b..82e9771 100644 --- a/tests/test-transport.ts +++ b/tests/test-transport.ts @@ -159,6 +159,70 @@ describe("Command Code transport router", () => { assert.equal(generateCalls, 1) }) + it("does not let a stale request overwrite the transport for a new API key", async () => { + let releaseGoRequest: (() => void) | undefined + const goRequestGate = new Promise((resolve) => { + releaseGoRequest = resolve + }) + let providerCalls = 0 + let generateCalls = 0 + const upgradeBody = JSON.stringify({ error: { code: "upgrade_required" } }) + const router = createCommandCodeTransportRouter({ + createStream: createTestEventStream, + streamProvider: (_model, _context, options) => { + providerCalls += 1 + const response = + options?.apiKey === "go-key" + ? new Response(upgradeBody, { status: 403 }) + : new Response("ok", { status: 200 }) + const stream = createTestEventStream() + const run = async () => { + if (options?.apiKey === "go-key") await goRequestGate + const received = await (options?.fetch ?? fetch)("https://provider.test", {}) + await options?.onResponse?.( + { status: received.status, headers: {} }, + makeModel({ api: "openai-completions" }), + ) + if (response.ok) { + for await (const event of completedStream("provider")) stream.push(event) + } + stream.end() + } + run().catch(() => stream.end()) + return stream + }, + streamGenerate: () => { + generateCalls += 1 + return completedStream("generate") + }, + }) + + const staleGoRequest = collectEvents( + router.stream(makeModel(), makeContext(), { + apiKey: "go-key", + fetch: () => Promise.resolve(new Response(upgradeBody, { status: 403 })), + }), + ) + await collectEvents( + router.stream(makeModel(), makeContext(), { + apiKey: "provider-key", + fetch: () => Promise.resolve(new Response("ok", { status: 200 })), + }), + ) + releaseGoRequest?.() + await staleGoRequest + await collectEvents( + router.stream(makeModel(), makeContext(), { + apiKey: "provider-key", + fetch: () => Promise.resolve(new Response("ok", { status: 200 })), + }), + ) + + assert.equal(router.getTransport(), "provider") + assert.equal(providerCalls, 3) + assert.equal(generateCalls, 1) + }) + it("does not fall back for other 403 errors", async () => { let generateCalls = 0 const responseBody = JSON.stringify({ error: { code: "permission_denied" } }) From ed29c59f3174f7fbcca1ed9d900c37f15c117ab2 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 20 Aug 2026 00:13:56 +0200 Subject: [PATCH 11/13] fix(models): use adaptive thinking for Claude --- index.ts | 1 + tests/test-pi-local.mjs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/index.ts b/index.ts index 1f4aeb2..72ae558 100644 --- a/index.ts +++ b/index.ts @@ -85,6 +85,7 @@ function createProviderConfig( supportsLongCacheRetention: false, supportsCacheControlOnTools: false, supportsToolReferences: false, + ...(model.reasoning ? { forceAdaptiveThinking: true } : {}), }, })), } diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 760a100..2d0a999 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -731,6 +731,8 @@ try { "commandcode", "--model", CLAUDE_TEST_MODEL, + "--thinking", + "high", ], 30_000, ) @@ -738,6 +740,8 @@ try { assert.match(claudePrint.stdout, /mock-pi-ok/) assert.equal(requestCount, 1) assert.equal(lastRequestBody?.model, CLAUDE_TEST_MODEL) + assert.equal(lastRequestBody?.thinking?.type, "adaptive") + assert.deepEqual(lastRequestBody?.output_config, { effort: "high" }) assert.equal(lastRequestHeaders["x-api-key"], "mock-key") assert.equal(lastRequestHeaders["x-cmd-zdr"], "1") From f6f0ab274d251c44fbd88844e1997e950ad19144 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 20 Aug 2026 00:13:56 +0200 Subject: [PATCH 12/13] fix(models): refresh DeepSeek V4 pricing --- src/pricing.ts | 17 +++++++++-------- tests/fixtures/commandcode-pricing.json | 6 +++--- tests/test-pricing.ts | 14 ++++++++++---- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/pricing.ts b/src/pricing.ts index f13a69e..0dd9115 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -20,7 +20,7 @@ export interface TemporaryPricing { } export const PRICING_SOURCE_URL = "https://commandcode.ai/docs/resources/pricing-limits" -export const PRICING_LAST_VERIFIED = "2026-08-18" +export const PRICING_LAST_VERIFIED = "2026-08-20" export const ZERO_MODEL_COST: CommandCodeModelCost = { input: 0, @@ -61,17 +61,18 @@ export const MODEL_COSTS: Readonly> = { "MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, "MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, "MiniMaxAI/MiniMax-M2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 }, - // Permanent 75% discount. + // DeepSeek V4 uses time-dependent rates. Display the documented off-peak + // rates, which apply for 17 hours per day; the Usage page remains authoritative. "deepseek/deepseek-v4-pro": { - input: 0.435, - output: 0.87, - cacheRead: 0.003625, + input: 0.66, + output: 1.98, + cacheRead: 0.022, cacheWrite: 0, }, "deepseek/deepseek-v4-flash": { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, + input: 0.22, + output: 0.66, + cacheRead: 0.007, cacheWrite: 0, }, "Qwen/Qwen3.8-Max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, diff --git a/tests/fixtures/commandcode-pricing.json b/tests/fixtures/commandcode-pricing.json index 786cec2..6a508fd 100644 --- a/tests/fixtures/commandcode-pricing.json +++ b/tests/fixtures/commandcode-pricing.json @@ -1,5 +1,5 @@ { - "verifiedAt": "2026-08-18", + "verifiedAt": "2026-08-20", "source": "https://commandcode.ai/docs/resources/pricing-limits", "tierPolicy": "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.", "tiers": { @@ -25,8 +25,8 @@ "MiniMaxAI/MiniMax-M3": [0.3, 1.2, 0.06, 0], "MiniMaxAI/MiniMax-M2.7": [0.3, 1.2, 0.06, 0], "MiniMaxAI/MiniMax-M2.5": [0.3, 1.2, 0.03, 0], - "deepseek/deepseek-v4-pro": [0.435, 0.87, 0.003625, 0], - "deepseek/deepseek-v4-flash": [0.14, 0.28, 0.0028, 0], + "deepseek/deepseek-v4-pro": [0.66, 1.98, 0.022, 0], + "deepseek/deepseek-v4-flash": [0.22, 0.66, 0.007, 0], "Qwen/Qwen3.8-Max": [2, 6, 0.25, 2.5], "Qwen/Qwen3.7-Max": [2.5, 7.5, 0.5, 3.13], "Qwen/Qwen3.7-Plus": [0.4, 1.6, 0.08, 0.5], diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts index c463fcf..6e17400 100644 --- a/tests/test-pricing.ts +++ b/tests/test-pricing.ts @@ -108,10 +108,16 @@ describe("MODEL_COSTS pricing overlay", () => { }) it("matches corrected official rates", () => { + assertCost("deepseek/deepseek-v4-pro", { + input: 0.66, + output: 1.98, + cacheRead: 0.022, + cacheWrite: 0, + }) assertCost("deepseek/deepseek-v4-flash", { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, + input: 0.22, + output: 0.66, + cacheRead: 0.007, cacheWrite: 0, }) assertCost("Qwen/Qwen3.7-Max", { @@ -163,7 +169,7 @@ describe("MODEL_COSTS pricing overlay", () => { it("tracks pricing provenance", () => { assert.equal(PRICING_SOURCE_URL, "https://commandcode.ai/docs/resources/pricing-limits") - assert.equal(PRICING_LAST_VERIFIED, "2026-08-18") + assert.equal(PRICING_LAST_VERIFIED, "2026-08-20") }) it("fails once temporary pricing needs review", () => { From f07ece7731c03bed7a55f469b0701af982854257 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 20 Aug 2026 00:13:56 +0200 Subject: [PATCH 13/13] docs(api): document provider follow-up fixes --- CHANGELOG.md | 6 +++--- README.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4942c..ee3cd9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,11 @@ ## Unreleased - Prefer Command Code's Provider API (`/provider/v1/chat/completions` and `/provider/v1/messages`) and automatically fall back to the existing `/alpha/generate` transport only when the Provider API returns `403 upgrade_required` for a Go-plan account. -- Remember the detected transport for the running process, re-detect it when credentials change, and never fall back for unrelated authentication, permission, rate-limit, network, or server failures. -- Use Pi's native OpenAI- and Anthropic-compatible providers for Provider API streaming while preserving the existing hardened generate transport, dynamic model discovery, offline cache, refresh/status commands, pricing, and OAuth credentials. +- Remember the detected transport for the running process, re-detect it when credentials change, prevent stale in-flight requests from overwriting the new credential's transport, and never fall back for unrelated authentication, permission, rate-limit, network, or server failures. +- Use Pi's native OpenAI- and Anthropic-compatible providers for Provider API streaming, including adaptive thinking for current reasoning-capable Claude models, while preserving the existing hardened generate transport, dynamic model discovery, offline cache, refresh/status commands, pricing, and OAuth credentials. - Let `/login` use browser authentication, an explicit API-key prompt, or a directly pasted API key. - Add optional zero-data-retention headers through `COMMANDCODE_ZDR=1`. -- Refresh GPT-5.6 Terra and Luna display prices after their temporary 50% promotion ended. +- Refresh GPT-5.6 Terra and Luna display prices after their temporary 50% promotion ended, and display the current DeepSeek V4 off-peak rates for its time-dependent pricing. - Add isolated live E2E profiles for separate Go-plan and Provider-API credentials, including an explicit selected-transport assertion and packed-package validation. ## 0.5.1 - 2026-08-11 diff --git a/README.md b/README.md index 6c37492..4b32189 100644 --- a/README.md +++ b/README.md @@ -147,9 +147,9 @@ For vision-capable models, Pi's native provider adapters forward image blocks fr ## Pricing display -The Command Code Provider API does not currently include prices in its model catalog. This extension therefore keeps a static table for models with known prices so pi can display estimated request costs. +The Command Code Provider API does not currently include prices in its model catalog. This extension therefore keeps a static table for models with known prices so pi can display estimated request costs. DeepSeek V4 uses time-dependent rates; pi displays the documented off-peak rate, which applies for 17 hours per day. -Models missing from that table display zero cost in pi. This does **not** mean that Command Code will bill the request at zero. Check the current [Command Code pricing](https://commandcode.ai/docs/resources/pricing-limits) before relying on the displayed value. +Models missing from that table display zero cost in pi. This does **not** mean that Command Code will bill the request at zero. The Command Code Usage page remains authoritative for each request. Check the current [Command Code pricing](https://commandcode.ai/docs/resources/pricing-limits) before relying on the displayed value. ## Update and remove