From 6f8738030e8a4949faf219b663eedcf2d6463201 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 26 May 2026 22:18:43 +0200 Subject: [PATCH 1/4] Use official Command Code Provider API --- README.md | 23 ++- index.ts | 37 ++-- src/api-key.ts | 53 ++++++ src/converters.ts | 276 ---------------------------- src/core.ts | 458 ---------------------------------------------- src/models.ts | 18 +- src/types.ts | 156 ---------------- 7 files changed, 99 insertions(+), 922 deletions(-) create mode 100644 src/api-key.ts delete mode 100644 src/converters.ts delete mode 100644 src/core.ts delete mode 100644 src/types.ts diff --git a/README.md b/README.md index cffca47..7a77cc8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # pi-commandcode-provider -A [pi](https://github.com/badlogic/pi-mono) custom provider that connects pi to the [Command Code](https://commandcode.ai) API. +A [pi](https://github.com/badlogic/pi-mono) custom provider that connects pi to the official [Command Code Provider API](https://commandcode.ai/docs/provider-api). > **Disclaimer:** This is an unofficial, community-maintained package. I am not affiliated with, endorsed by, or connected to Command Code in any way. This provider simply forwards requests to the public Command Code API using your own API key. -> **Note:** This package only provides a model _provider_. It does **not** include an API key. You must bring your own Command Code API key or subscription. +> **Note:** This package only provides a model _provider_. It does **not** include an API key. You must bring your own Command Code API key and a plan that can use the Provider API. > 💰 **Current offers:** Command Code offers [4× usage of DeepSeek V4 Pro](https://commandcode.ai/docs/resources/pricing-limits#deepseek-v4-pro-4x-usage) and [2× usage of Qwen 3.7 Max](https://commandcode.ai/docs/resources/pricing-limits#qwen-3.7-max-2x-usage). @@ -40,7 +40,7 @@ Then reload pi: Set your Command Code API key using one of these methods: -### 1. Browser login (recommended) +### 1. Login flow (recommended) In pi, run: @@ -48,11 +48,12 @@ In pi, run: /login ``` -Then select **Command Code** from the provider list. +Then select **Command Code** from the provider list. The login flow lets you either: -image +- press Enter for the current browser-assisted login, or +- type `key` / paste a Studio API key directly. -This opens Command Code in your browser and stores the returned API key in pi's auth file. If the browser shows "Copy your API key" because automatic transfer failed, copy that key and paste it into the pi terminal prompt. +Browser login opens Command Code in your browser and stores the returned API key in pi's auth file. If the browser shows "Copy your API key" because automatic transfer failed, copy that key and paste it into the pi terminal prompt. > Note: `/login commandcode` is not supported by pi currently; use interactive `/login` and select Command Code. @@ -94,15 +95,17 @@ Any query will then use the Command Code API. You can list available models with /models ``` -## Model discovery +## Provider API -On startup, the provider fetches: +The provider uses the official Command Code Provider API: ```txt -https://api.commandcode.ai/provider/v1/models +https://api.commandcode.ai/provider/v1 ``` -For tests or local mocks, override it with `COMMANDCODE_MODELS_URL`. +On startup, it fetches models from `/models`. Non-Claude models use `/chat/completions`; Claude models use `/messages`. + +For tests or local mocks, override the API base with `COMMANDCODE_API_BASE` and the model-list URL with `COMMANDCODE_MODELS_URL`. ## Publish diff --git a/index.ts b/index.ts index 917f268..feeb244 100644 --- a/index.ts +++ b/index.ts @@ -1,10 +1,11 @@ /** * Command Code provider for pi. * - * Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate). + * Uses the official Command Code Provider API: + * https://api.commandcode.ai/provider/v1 * * Authentication (pick one): - * 1. Run `/login`, then select Command Code — opens browser to commandcode.ai, auto-stores API key + * 1. Run `/login`, then choose browser login or paste a Command Code API key * 2. Set COMMANDCODE_API_KEY environment variable * 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json` * as {"apiKey": "user_..."} or {"commandcode": "user_..."} @@ -12,22 +13,20 @@ * Models are fetched from Command Code's Provider API at startup. */ -import { calculateCost, createAssistantMessageEventStream } from "@mariozechner/pi-ai" import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" -import { createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" -import { DEFAULT_MODELS_URL, fetchCommandCodeModels } from "./src/models.ts" +import { getConfiguredApiKey } from "./src/api-key.ts" +import { + DEFAULT_MODELS_URL, + DEFAULT_PROVIDER_API_BASE, + baseUrlForModel, + fetchCommandCodeModels, +} from "./src/models.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts" -const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE +const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_PROVIDER_API_BASE const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL -const streamCommandCode = createStreamCommandCode({ - createStream: createAssistantMessageEventStream, - calculateCost, - apiBase: API_BASE, -}) - // --------------------------------------------------------------------------- // Extension entry point // --------------------------------------------------------------------------- @@ -38,14 +37,8 @@ export default async function (pi: ExtensionAPI) { pi.registerProvider("commandcode", { name: "Command Code", baseUrl: API_BASE, - apiKey: "COMMANDCODE_API_KEY", - authHeader: true, - api: "commandcode-custom", - streamSimple: streamCommandCode, - headers: { - "x-command-code-version": "0.24.1", - "x-cli-environment": "production", - }, + apiKey: getConfiguredApiKey() ?? "COMMANDCODE_API_KEY", + api: "openai-completions", oauth: { name: "Command Code", login, @@ -55,8 +48,10 @@ export default async function (pi: ExtensionAPI) { models: models.map((model) => ({ id: model.id, name: model.name, + api: model.api, + baseUrl: baseUrlForModel(API_BASE, model.api), reasoning: model.reasoning, - input: ["text"] as const, + input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: model.contextWindow, maxTokens: model.maxTokens, diff --git a/src/api-key.ts b/src/api-key.ts new file mode 100644 index 0000000..2195e51 --- /dev/null +++ b/src/api-key.ts @@ -0,0 +1,53 @@ +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")] +} + +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 = isRecord(parsed.commandcode) ? parsed.commandcode : undefined + if (providerKey && stringValue(providerKey.type) === "oauth") { + const access = stringValue(providerKey.access) + if (access) return access + } + } catch { + // Ignore malformed or unreadable auth files. + } + } + + return undefined +} diff --git a/src/converters.ts b/src/converters.ts deleted file mode 100644 index 8eaaaf1..0000000 --- a/src/converters.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" - -import type { MessageLike, StopReason, ToolLike } from "./types.ts" - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -export function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined -} - -function booleanValue(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined -} - -export function recordArray(value: unknown): readonly Record[] { - if (!Array.isArray(value)) return [] - return value.filter(isRecord) -} - -export function recordOrEmpty(value: unknown): Record { - if (isRecord(value)) return value - if (typeof value === "string") { - try { - const parsed: unknown = JSON.parse(value) - if (isRecord(parsed)) return parsed - } catch { - // Some providers stream incomplete JSON argument fragments. - } - } - return {} -} - -export function numberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined -} - -function defaultAuthPaths(home: string): string[] { - return [join(home, ".commandcode", "auth.json"), join(home, ".pi", "agent", "auth.json")] -} - -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 - - // OAuth: pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"...","refresh":"...","expires":...}} - const providerKey = isRecord(parsed.commandcode) ? parsed.commandcode : undefined - if (providerKey && stringValue(providerKey.type) === "oauth") { - const access = stringValue(providerKey.access) - if (access) return access - } - } catch { - // Ignore malformed or unreadable auth files. - } - } - - return undefined -} - -export function textContent(message: { content?: unknown }): string { - return recordArray(message.content) - .filter((part) => part.type === "text") - .map((part) => stringValue(part.text) ?? "") - .join("\n") -} - -export function getEnvironmentInfo(): string { - return `${process.platform}-${process.arch}, Node.js ${process.version}` -} - -export function toJsonSchema(schema: unknown): unknown { - if (!isRecord(schema)) return {} - - const kind = stringValue(schema.kind) ?? stringValue(schema.type) - const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined - if (enumValues) { - return { type: typeof enumValues[0], enum: enumValues } - } - - switch (kind) { - case "string": - case "String": - return { type: "string" } - case "number": - case "Number": - return { type: "number" } - case "boolean": - case "Boolean": - return { type: "boolean" } - case "object": - case "Object": { - const properties: Record = {} - const inferredRequired: string[] = [] - const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined - const optional = Array.isArray(schema.optional) - ? schema.optional.filter((item): item is string => typeof item === "string") - : [] - - if (sourceProperties) { - for (const [key, value] of Object.entries(sourceProperties)) { - properties[key] = toJsonSchema(value) - const valueRecord = isRecord(value) ? value : undefined - if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) { - inferredRequired.push(key) - } - } - } - - const explicitRequired = Array.isArray(schema.required) - ? schema.required.filter((item): item is string => typeof item === "string") - : undefined - const required = explicitRequired ?? inferredRequired - const out: Record = { type: "object" } - if (Object.keys(properties).length > 0) out.properties = properties - if (required.length > 0) out.required = required - return out - } - case "array": - case "Array": - return { - type: "array", - items: toJsonSchema(schema.items ?? schema.element), - } - case "union": - case "Union": { - const variants = Array.isArray(schema.variants) - ? schema.variants - : Array.isArray(schema.anyOf) - ? schema.anyOf - : [] - for (const variant of variants) { - const converted = toJsonSchema(variant) - if (isRecord(converted) && Object.keys(converted).length > 0) return converted - } - return {} - } - case "optional": - case "Optional": - return toJsonSchema(schema.wrapped ?? schema.inner) - default: - return {} - } -} - -export function toolsToJson(tools?: readonly ToolLike[]): unknown[] { - if (!tools) return [] - return tools.map((tool) => ({ - type: "function", - name: tool.name, - description: tool.description, - input_schema: tool.parameters ? toJsonSchema(tool.parameters) : {}, - })) -} - -function completeToolCallIds(messages?: readonly MessageLike[]): Set { - const callIds = new Set() - const resultIds = new Set() - - for (const message of messages ?? []) { - if (message.role === "assistant") { - for (const content of recordArray(message.content)) { - if (content.type === "toolCall") { - const id = stringValue(content.id) - if (id) callIds.add(id) - } - } - } else if (message.role === "toolResult") { - if (message.toolCallId) resultIds.add(message.toolCallId) - } - } - - return new Set([...callIds].filter((id) => resultIds.has(id))) -} - -export function messagesToCC(messages?: readonly MessageLike[]): unknown[] { - const out: unknown[] = [] - const pairedToolCallIds = completeToolCallIds(messages) - - for (const message of messages ?? []) { - if (message.role === "user") { - out.push({ - role: "user", - content: typeof message.content === "string" ? message.content : message.content, - }) - } else if (message.role === "assistant") { - const parts: unknown[] = [] - for (const content of recordArray(message.content)) { - if (content.type === "text") { - parts.push({ type: "text", text: stringValue(content.text) ?? "" }) - } else if (content.type === "thinking") { - parts.push({ - type: "reasoning", - text: stringValue(content.thinking) ?? "", - }) - } else if (content.type === "toolCall") { - const toolCallId = stringValue(content.id) ?? "" - if (!pairedToolCallIds.has(toolCallId)) continue - parts.push({ - type: "tool-call", - toolCallId, - toolName: stringValue(content.name) ?? "", - input: recordOrEmpty(content.arguments), - }) - } - } - if (parts.length > 0) out.push({ role: "assistant", content: parts }) - } else if (message.role === "toolResult") { - if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId)) continue - out.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: message.toolCallId, - toolName: message.toolName, - output: message.isError - ? { type: "error-text", value: textContent(message) } - : { type: "text", value: textContent(message) }, - }, - ], - }) - } - } - return out -} - -export function parseStreamEventLine(line: string): unknown | undefined { - let trimmed = line.trim() - if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined - if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim() - if (!trimmed || trimmed === "[DONE]") return undefined - - try { - const parsed: unknown = JSON.parse(trimmed) - return parsed - } catch { - return undefined - } -} - -export function mapFinishReason(reason: unknown): StopReason { - if (reason === "tool-calls") return "toolUse" - if ( - reason === "length" || - reason === "max_tokens" || - reason === "max-tokens" || - reason === "max_output_tokens" - ) { - return "length" - } - return "stop" -} diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index 1373073..0000000 --- a/src/core.ts +++ /dev/null @@ -1,458 +0,0 @@ -/** - * Testable Command Code provider core. - * - * The runtime imports live in index.ts; this module takes injected stream/cost - * dependencies so tests can exercise the real serialization and stream parser. - */ - -import { randomUUID } from "node:crypto" - -import { - getApiKey, - getEnvironmentInfo, - isRecord, - mapFinishReason, - messagesToCC, - numberValue, - parseStreamEventLine, - recordOrEmpty, - stringValue, - toolsToJson, -} from "./converters.ts" -import type { - AssistantMessageEventStreamLike, - AssistantMessageLike, - ContextLike, - CoreDependencies, - ErrorReason, - ModelLike, - StopReason, - StreamOptions, - TerminalReason, - TextContent, - ToolCallContent, - Usage, -} from "./types.ts" - -export * from "./converters.ts" -export * from "./types.ts" - -export const DEFAULT_API_BASE = "https://api.commandcode.ai" - -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 successStopReason(reason: TerminalReason): StopReason { - if (reason === "length" || reason === "toolUse") return reason - return "stop" -} - -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()) - - function raceAbort(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortError()) - - return new Promise((resolve, reject) => { - const onAbort = () => reject(abortError()) - signal.addEventListener("abort", onAbort, { once: true }) - promise.then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort) - reject(error) - }, - ) - }) - } - - return function streamCommandCode( - model: ModelLike, - context: ContextLike, - options?: StreamOptions, - ): AssistantMessageEventStreamLike { - const stream = deps.createStream() - - async function run() { - const apiKey = - options?.apiKey ?? - 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 COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/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 thinkingBlock: string[] = [] - 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 flushThinkingBlock = () => { - if (thinkingBlock.length === 0) return - const thinkingText = thinkingBlock.join("") - thinkingBlock = [] - output.content.push({ type: "thinking", thinking: thinkingText }) - const idx = output.content.length - 1 - stream.push({ - type: "thinking_start", - contentIndex: idx, - partial: output, - }) - stream.push({ - type: "thinking_delta", - contentIndex: idx, - delta: thinkingText, - partial: output, - }) - stream.push({ - type: "thinking_end", - contentIndex: idx, - content: thinkingText, - partial: output, - }) - } - - const handleEvent = (event: unknown) => { - if (!isRecord(event)) return - - switch (event.type) { - case "text-delta": { - 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-delta": { - thinkingBlock.push(stringValue(event.text) ?? "") - break - } - - case "reasoning-end": { - flushThinkingBlock() - break - } - - case "tool-call": { - endTextBlock() - const toolCall: ToolCallContent = { - type: "toolCall", - id: stringValue(event.toolCallId) ?? "", - name: stringValue(event.toolName) ?? "", - arguments: recordOrEmpty(event.input ?? event.args ?? event.arguments), - } - output.content.push(toolCall) - const idx = output.content.length - 1 - stream.push({ - type: "toolcall_start", - contentIndex: idx, - partial: output, - }) - stream.push({ - type: "toolcall_end", - contentIndex: idx, - toolCall, - partial: output, - }) - break - } - - case "finish": { - const usage = commandCodeUsage(event) - if (usage) { - const details = commandCodeInputTokenDetails(usage) - output.usage.input = numberValue(usage.inputTokens) ?? 0 - output.usage.output = numberValue(usage.outputTokens) ?? 0 - output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0 - output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0 - output.usage.totalTokens = - output.usage.input + - output.usage.output + - output.usage.cacheRead + - output.usage.cacheWrite - deps.calculateCost(model, output.usage) - } - output.stopReason = mapFinishReason(event.finishReason) - finished = true - break - } - - case "error": { - const errorRecord = isRecord(event.error) ? event.error : undefined - const message = - stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error" - output.stopReason = "error" - output.errorMessage = message - throw new Error(message) - } - } - } - - try { - stream.push({ type: "start", partial: output }) - - let body: unknown = { - config: { - workingDir: cwd(), - date: new Date(now()).toISOString().split("T")[0], - environment: getEnvironmentInfo(), - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], - }, - memory: "", - taste: "", - skills: null, - permissionMode: "standard", - params: { - model: model.id, - messages: messagesToCC(context.messages), - tools: toolsToJson(context.tools), - system: context.systemPrompt ?? "", - max_tokens: Math.min(options?.maxTokens ?? model.maxTokens, 200_000), - stream: true, - }, - } - - const nextBody = await raceAbort( - Promise.resolve(options?.onPayload?.(body, model)), - controller.signal, - ) - if (nextBody !== undefined) body = nextBody - - const response = await raceAbort( - fetchImpl(`${apiBase}/alpha/generate`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "x-command-code-version": "0.24.1", - "x-cli-environment": "production", - "x-project-slug": "pi-cc", - "x-taste-learning": "false", - "x-co-flag": "false", - "x-session-id": uuid(), - ...options?.headers, - }, - body: JSON.stringify(body), - signal: controller.signal, - }), - controller.signal, - ) - - await raceAbort( - Promise.resolve( - options?.onResponse?.( - { - status: response.status, - headers: headersToRecord(response.headers), - }, - model, - ), - ), - controller.signal, - ) - - if (!response.ok) { - const errBody = await raceAbort( - response.text().catch(() => ""), - controller.signal, - ) - throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`) - } - - reader = response.body?.getReader() - if (!reader) throw new Error("No response body") - - const decoder = new TextDecoder() - let buffer = "" - - readLoop: for (;;) { - if (controller.signal.aborted) throw abortError("Aborted") - const { done, value } = await raceAbort(reader.read(), controller.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 - } - } - - endTextBlock() - flushThinkingBlock() - - stream.push({ - type: "done", - reason: successStopReason(output.stopReason), - message: output, - }) - stream.end() - } catch (error: unknown) { - const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error" - output.stopReason = reason - output.errorMessage = - reason === "aborted" - ? "Request aborted" - : error instanceof Error - ? error.message - : String(error) - stream.push({ type: "error", reason, error: output }) - stream.end() - } finally { - options?.signal?.removeEventListener("abort", abortUpstream) - try { - await reader?.cancel() - } catch { - // Reader may already be closed/cancelled. - } - try { - reader?.releaseLock() - } catch { - // Reader may already be released/cancelled by the abort path. - } - } - } - - run().catch((error: unknown) => { - const msg: AssistantMessageLike = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: defaultUsage(), - stopReason: "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: now(), - } - stream.push({ type: "error", reason: "error", error: msg }) - stream.end() - }) - - return stream - } -} diff --git a/src/models.ts b/src/models.ts index c4be26f..daef654 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,4 +1,7 @@ -export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" +import type { Api } from "@mariozechner/pi-ai" + +export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1" +export const DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models` const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 @@ -11,6 +14,7 @@ interface ApiModel { export interface CommandCodeModel { id: string name: string + api: Api reasoning: boolean contextWindow: number maxTokens: number @@ -47,6 +51,17 @@ function parseApiModel(value: unknown): ApiModel { } } +export function apiForModelId(id: string): Api { + if (id.startsWith("claude-")) return "anthropic-messages" + return "openai-completions" +} + +export function baseUrlForModel(apiBase: string, api: Api): string { + const normalized = apiBase.replace(/\/+$/g, "") + if (api !== "anthropic-messages") return normalized + return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized +} + export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { if (!isRecord(value)) throw new Error("Expected models response to be an object") if (value.object !== "list") throw new Error("Expected models response object to be 'list'") @@ -57,6 +72,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma return data.map(parseApiModel).map((model) => ({ id: model.id, name: `${model.name} (CC)`, + api: apiForModelId(model.id), reasoning: true, contextWindow: model.contextLength, maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS), diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 00e2d04..0000000 --- a/src/types.ts +++ /dev/null @@ -1,156 +0,0 @@ -export type StopReason = "stop" | "length" | "toolUse" -export type ErrorReason = "error" | "aborted" -export type TerminalReason = StopReason | ErrorReason - -export interface UsageCost { - input: number - output: number - cacheRead: number - cacheWrite: number - total: number -} - -export interface Usage { - input: number - output: number - cacheRead: number - cacheWrite: number - totalTokens: number - cost: UsageCost -} - -export interface TextContent { - type: "text" - text: string -} - -export interface ThinkingContent { - type: "thinking" - thinking: string -} - -export interface ToolCallContent { - type: "toolCall" - id: string - name: string - arguments: Record -} - -export type AssistantContent = TextContent | ThinkingContent | ToolCallContent - -export interface AssistantMessageLike { - role: "assistant" - content: AssistantContent[] - api: unknown - provider: string - model: string - usage: Usage - stopReason: TerminalReason - errorMessage?: string - timestamp: number -} - -export interface ModelLike { - id: string - api: unknown - provider: string - maxTokens: number -} - -export interface MessageLike { - role: string - content?: unknown - toolCallId?: string - toolName?: string - isError?: boolean -} - -export interface ToolLike { - name: string - description?: string - parameters?: unknown -} - -export interface ContextLike { - systemPrompt?: string - messages?: readonly MessageLike[] - tools?: readonly ToolLike[] -} - -export interface ProviderResponseInfo { - status: number - headers: Record -} - -export interface StreamOptions { - apiKey?: string - signal?: AbortSignal - headers?: Record - maxTokens?: number - onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise - onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise -} - -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 -} From d6161a15b27d480fd63cf80a90d67ba58c60ac59 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 26 May 2026 22:18:50 +0200 Subject: [PATCH 2/4] Add direct Command Code API key login --- src/oauth.ts | 67 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/src/oauth.ts b/src/oauth.ts index be77391..c775138 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,13 +1,13 @@ /** * Command Code OAuth provider for pi's /login flow. * - * Implements a browser-assisted API key retrieval flow: - * 1. Starts a local HTTP server on a Command Code CLI-compatible port - * 2. Opens the Command Code Studio auth page in the browser - * 3. The user authenticates on the Command Code website - * 4. The website POSTs the API key back to the local server - * 5. If browser transfer fails, the user can paste the API key manually - * 6. The API key is stored in pi's auth.json as OAuth credentials + * Implements two API key retrieval flows: + * 1. Browser-assisted login: opens Command Code Studio and waits for the + * website to POST the API key back to a local callback server. + * 2. Direct API key login: prompts the user to paste a Command Code Studio API key. + * + * If browser transfer fails, the user can still paste the API key manually. + * The API key is stored in pi's auth.json as OAuth credentials. * * Since Command Code API keys don't expire, we store them as * OAuth credentials with a far-future expiry. @@ -101,13 +101,35 @@ async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) return credentialsFromApiKey(apiKey) } -/** - * Starts the browser-based login flow for Command Code. - * - * Returns OAuth credentials where access == refresh == the user's API key. - * The keys don't expire, so we set a far-future expiry. - */ -export async function login(callbacks: OAuthLoginCallbacks): Promise { +type LoginChoice = { type: "browser" } | { type: "prompt" } | { type: "apiKey"; apiKey: string } + +async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise { + const input = sanitizeApiKey( + await callbacks.onPrompt({ + message: + "Command Code login: press Enter for browser login, type 'key' to paste an API key, or paste the API key directly:", + }), + ) + const normalized = input.toLowerCase() + + if (!input || normalized === "1" || normalized === "b" || normalized === "browser") { + return { type: "browser" } + } + + if ( + normalized === "2" || + normalized === "k" || + normalized === "key" || + normalized === "api" || + normalized === "paste" + ) { + return { type: "prompt" } + } + + return { type: "apiKey", apiKey: input } +} + +async function browserLogin(callbacks: OAuthLoginCallbacks): Promise { let authServer try { authServer = await startAuthServer() @@ -151,6 +173,23 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise { + const choice = await chooseLoginFlow(callbacks) + + if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey) + if (choice.type === "prompt") { + return promptForApiKey(callbacks, "Paste your Command Code API key:") + } + + return browserLogin(callbacks) +} + /** * Command Code API keys don't expire, so "refresh" is a no-op. * Returns the same credentials with an updated far-future expiry. From f13439e8cf2bbd31fdf85d725462c2ddc0b902aa Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 26 May 2026 22:18:54 +0200 Subject: [PATCH 3/4] Update tests for Provider API flow --- package.json | 7 +- tests/helpers.ts | 273 -------------------------- tests/test-abort.ts | 85 -------- tests/test-api-key.ts | 66 +++++++ tests/test-models.ts | 24 ++- tests/test-oauth.ts | 43 +++- tests/test-pi-local.mjs | 92 ++++----- tests/test-pure-functions.ts | 285 --------------------------- tests/test-stream.ts | 371 ----------------------------------- 9 files changed, 178 insertions(+), 1068 deletions(-) delete mode 100644 tests/helpers.ts delete mode 100644 tests/test-abort.ts create mode 100644 tests/test-api-key.ts delete mode 100644 tests/test-pure-functions.ts delete mode 100644 tests/test-stream.ts diff --git a/package.json b/package.json index 971b815..bf57ffd 100644 --- a/package.json +++ b/package.json @@ -25,15 +25,14 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs", + "test": "npm run typecheck && tsx tests/test-api-key.ts && tsx tests/test-models.ts && tsx tests/test-oauth.ts && node tests/test-pi-local.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", - "test:unit": "tsx tests/test-pure-functions.ts", + "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-models.ts", + "test:api-key": "tsx tests/test-api-key.ts", "test:models": "tsx tests/test-models.ts", "test:oauth": "tsx tests/test-oauth.ts", - "test:abort": "tsx tests/test-abort.ts", - "test:stream": "tsx tests/test-stream.ts", "test:pi-local": "node tests/test-pi-local.mjs", "test:smoke": "node tests/test-smoke.mjs" }, diff --git a/tests/helpers.ts b/tests/helpers.ts deleted file mode 100644 index c05c7b3..0000000 --- a/tests/helpers.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { createServer, type IncomingHttpHeaders, type Server } from "node:http" - -import { - createStreamCommandCode, - type AssistantMessageEvent, - type AssistantMessageEventStreamLike, - type ContextLike, - type CoreDependencies, - type ModelLike, - type Usage, -} from "../src/core.ts" - -export function createTestEventStream(): AssistantMessageEventStreamLike { - const events: AssistantMessageEvent[] = [] - const waiters: Array<() => void> = [] - let ended = false - - const wake = () => { - const waiter = waiters.shift() - if (waiter) waiter() - } - - return { - push(event: AssistantMessageEvent) { - events.push(event) - wake() - }, - end() { - ended = true - while (waiters.length > 0) wake() - }, - [Symbol.asyncIterator]() { - let index = 0 - return { - async next(): Promise> { - while (index >= events.length && !ended) { - await new Promise((resolve) => waiters.push(resolve)) - } - if (index < events.length) { - const value = events[index] - index += 1 - return { done: false, value } - } - return { done: true, value: undefined } - }, - } - }, - } -} - -export async function collectEvents( - stream: AssistantMessageEventStreamLike, - timeoutMs = 2_000, -): Promise { - const events: AssistantMessageEvent[] = [] - - const collect = async () => { - for await (const event of stream) { - events.push(event) - if (event.type === "done" || event.type === "error") break - } - return events - } - - return await Promise.race([ - collect(), - new Promise((_, reject) => { - setTimeout( - () => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), - timeoutMs, - ) - }), - ]) -} - -export function makeModel(overrides: Partial = {}): ModelLike { - return { - id: "deepseek/deepseek-v4-flash", - api: "commandcode-custom", - provider: "commandcode", - maxTokens: 384_000, - ...overrides, - } -} - -export function makeContext(overrides: Partial = {}): ContextLike { - return { - systemPrompt: "You are a test assistant.", - messages: [{ role: "user", content: "hello" }], - tools: [], - ...overrides, - } -} - -export interface TestDepsResult { - streamCommandCode: ReturnType - calculatedUsages: Usage[] -} - -export function createTestDeps(overrides: Partial = {}): TestDepsResult { - const calculatedUsages: Usage[] = [] - const streamCommandCode = createStreamCommandCode({ - createStream: createTestEventStream, - calculateCost: (_model, usage) => { - calculatedUsages.push({ - ...usage, - cost: { ...usage.cost }, - }) - }, - env: {}, - authPaths: [], - now: () => new Date("2026-05-05T12:00:00Z").getTime(), - uuid: () => "00000000-0000-4000-8000-000000000000", - cwd: () => "/repo", - ...overrides, - }) - return { streamCommandCode, calculatedUsages } -} - -type SuccessPlan = { - type: "success" - status?: number - events?: string[] - chunks?: string[] - delays?: number[] - hangAfterLast?: boolean -} - -type ErrorPlan = { - type: "error" - status: number - body: string -} - -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 - reset(): void - close(): Promise - lastRequestBody(): unknown - lastRequestHeaders(): Record - requestCount(): number - responseClosedBeforeEnd(): boolean -} - -export async function startMockCommandCodeServer(): Promise { - let nextPlan: 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 - } - - const plan = nextPlan - if (plan.type === "error") { - res.writeHead(plan.status, { "Content-Type": "text/plain" }) - 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() - } - } - - 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) { - nextPlan = plan - }, - reset() { - nextPlan = { type: "success", events: [] } - lastBody = undefined - lastHeaders = {} - requests = 0 - closedBeforeEnd = false - }, - close() { - return new Promise((resolve) => server.close(() => resolve())) - }, - lastRequestBody: () => lastBody, - lastRequestHeaders: () => lastHeaders, - requestCount: () => requests, - responseClosedBeforeEnd: () => closedBeforeEnd, - } -} - -export function objectAt(value: unknown, path: readonly string[]): unknown { - let current = value - for (const key of path) { - if (Array.isArray(current)) { - const index = Number(key) - if (!Number.isInteger(index)) return undefined - current = current[index] - continue - } - if (typeof current !== "object" || current === null) return undefined - current = Object.getOwnPropertyDescriptor(current, key)?.value - } - return current -} diff --git a/tests/test-abort.ts b/tests/test-abort.ts deleted file mode 100644 index 8db0ad5..0000000 --- a/tests/test-abort.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Abort tests against the real streamCommandCode core. - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -describe("streamCommandCode — abort behavior", () => { - it("emits aborted error when signal is already aborted", async () => { - const controller = new AbortController() - controller.abort() - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - }), - ) - - assert.deepEqual( - events.map((event) => event.type), - ["start", "error"], - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - assert.equal(error.error.stopReason, "aborted") - assert.equal(server.requestCount(), 0) - }) - - it("emits aborted error and cancels the response reader mid-stream", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "text-delta", text: "first" })], - hangAfterLast: true, - }) - const controller = new AbortController() - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const stream = streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - }) - - setTimeout(() => controller.abort(), 50) - const events = await collectEvents(stream, 2_000) - - assert.ok( - events.some((event) => event.type === "text_delta"), - "stream should process data before abort", - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - assert.equal(error.error.errorMessage, "Request aborted") - await new Promise((resolve) => setTimeout(resolve, 50)) - assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response") - }) -}) diff --git a/tests/test-api-key.ts b/tests/test-api-key.ts new file mode 100644 index 0000000..922c476 --- /dev/null +++ b/tests/test-api-key.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, it } from "node:test" + +import { getConfiguredApiKey } from "../src/api-key.ts" + +describe("getConfiguredApiKey()", () => { + it("uses COMMANDCODE_API_KEY from provided env", () => { + assert.equal( + getConfiguredApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), + "env-key", + ) + }) + + it("reads apiKey, commandcode, and pi OAuth credential fields from explicit auth paths", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) + try { + const first = join(dir, "first.json") + const second = join(dir, "second.json") + const oauth = join(dir, "oauth.json") + 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, + }, + }), + ) + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [first, second] }), "file-key") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [second] }), "fallback-key") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("ignores malformed auth files", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-")) + try { + const bad = join(dir, "bad.json") + writeFileSync(bad, "not json") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [bad] }), undefined) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("uses injected homeDir for default 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(getConfiguredApiKey({ env: {}, homeDir: () => dir }), "pi-key") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/test-models.ts b/tests/test-models.ts index 15ee3fd..a2f75c0 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -1,7 +1,28 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { commandCodeModelsFromApiResponse } from "../src/models.ts" +import { apiForModelId, baseUrlForModel, commandCodeModelsFromApiResponse } from "../src/models.ts" + +describe("apiForModelId()", () => { + it("routes Claude models to Anthropic Messages and other models to OpenAI Chat Completions", () => { + assert.equal(apiForModelId("claude-sonnet-4-6"), "anthropic-messages") + assert.equal(apiForModelId("Qwen/Qwen3.7-Max"), "openai-completions") + assert.equal(apiForModelId("deepseek/deepseek-v4-flash"), "openai-completions") + }) +}) + +describe("baseUrlForModel()", () => { + it("uses the Provider API /v1 base for OpenAI-compatible models and strips /v1 for Anthropic SDK models", () => { + assert.equal( + baseUrlForModel("https://api.commandcode.ai/provider/v1", "openai-completions"), + "https://api.commandcode.ai/provider/v1", + ) + assert.equal( + baseUrlForModel("https://api.commandcode.ai/provider/v1", "anthropic-messages"), + "https://api.commandcode.ai/provider", + ) + }) +}) describe("commandCodeModelsFromApiResponse()", () => { it("converts the Provider API model list to pi models", () => { @@ -23,6 +44,7 @@ describe("commandCodeModelsFromApiResponse()", () => { { id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max (CC)", + api: "openai-completions", reasoning: true, contextWindow: 1_000_000, maxTokens: 65_536, diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts index 0ff0fab..62c0123 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -185,8 +185,8 @@ describe("login()", () => { onAuth(params: { url: string }) { authUrl = params.url }, - onPrompt(_params: { message: string }): Promise { - throw new Error("onPrompt should not be called in browser flow") + async onPrompt(_params: { message: string }): Promise { + return "" }, } @@ -248,6 +248,7 @@ describe("login()", () => { }, async onPrompt(params: { message: string }): Promise { promptMessage = params.message + if (promptMessage.includes("press Enter for browser login")) return "" return "\u001b[200~ user_manualApiKey\n\u001b[201~" }, }) @@ -263,14 +264,48 @@ describe("login()", () => { } }) + it("accepts a pasted API key without starting browser login", async () => { + let authOpened = false + const result = await login({ + onAuth(_params: { url: string }) { + authOpened = true + }, + async onPrompt(_params: { message: string }): Promise { + return "\u001b[200~ user_directApiKey\n\u001b[201~" + }, + }) + + assert.equal(authOpened, false) + assert.equal(result.access, "user_directApiKey") + assert.equal(result.refresh, "user_directApiKey") + assert.ok(result.expires > Date.now(), "expiry should be far in the future") + }) + + it("can explicitly choose the API key prompt", async () => { + const prompts: string[] = [] + const result = await login({ + onAuth(_params: { url: string }) { + throw new Error("onAuth should not be called for direct API key flow") + }, + async onPrompt(params: { message: string }): Promise { + prompts.push(params.message) + return prompts.length === 1 ? "key" : "user_promptedApiKey" + }, + }) + + assert.equal(prompts.length, 2) + assert.equal(result.access, "user_promptedApiKey") + assert.equal(result.refresh, "user_promptedApiKey") + }) + it("rejects on state token mismatch", async () => { let authUrl = "" const callbacks = { onAuth(params: { url: string }) { authUrl = params.url }, - onPrompt(_params: { message: string }): Promise { - throw new Error("should not prompt") + async onPrompt(_params: { message: string }): Promise { + return "" }, } diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 8b37560..6cabaf8 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -1,23 +1,14 @@ #!/usr/bin/env node /** * Local end-to-end test: loads the real extension through the pi CLI while the - * Command Code API is replaced by a deterministic local mock server. + * Command Code Provider API is replaced by a deterministic local mock server. */ import assert from "node:assert/strict" import { spawn, spawnSync } from "node:child_process" -import { - accessSync, - constants, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from "node:fs" +import { accessSync, constants } from "node:fs" import { createServer } from "node:http" -import { homedir, tmpdir } from "node:os" -import { delimiter, dirname, join, resolve } from "node:path" +import { delimiter, dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -57,6 +48,7 @@ if (piCheck.error) { let requestCount = 0 let modelListRequestCount = 0 +let alphaGenerateRequestCount = 0 let lastRequestBody let lastRequestHeaders = {} @@ -90,7 +82,14 @@ const server = createServer((req, res) => { return } - if (req.method !== "POST" || req.url !== "/alpha/generate") { + if (req.method === "POST" && req.url === "/alpha/generate") { + alphaGenerateRequestCount += 1 + res.writeHead(410) + res.end("Do not use internal API") + return + } + + if (req.method !== "POST" || req.url !== "/provider/v1/chat/completions") { res.writeHead(404) res.end("Not found") return @@ -116,13 +115,33 @@ const server = createServer((req, res) => { } res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", }) - res.write(`${JSON.stringify({ type: "text-delta", text: "mock-pi-ok" })}\n`) res.write( - `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`, + `data: ${JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + })}\n\n`, + ) + res.write( + `data: ${JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { content: "mock-pi-ok" }, finish_reason: null }], + })}\n\n`, + ) + res.write( + `data: ${JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + })}\n\n`, ) + res.write("data: [DONE]\n\n") res.end() }) }) @@ -130,33 +149,13 @@ const server = createServer((req, res) => { await new Promise((resolve) => server.listen(0, resolve)) const address = server.address() const port = typeof address === "object" && address ? address.port : 0 -const apiBase = `http://127.0.0.1:${port}` - -function hasLivePiAuth() { - return ( - !!process.env.COMMANDCODE_API_KEY || - existsSync(join(homedir(), ".commandcode", "auth.json")) || - existsSync(join(homedir(), ".pi", "agent", "auth.json")) - ) -} +const apiRoot = `http://127.0.0.1:${port}` -let tempHome const env = { ...process.env, - COMMANDCODE_API_BASE: apiBase, - COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, -} - -if (hasLivePiAuth()) { - console.log("[pi-local] using live pi auth") -} else { - console.log("[pi-local] live pi auth not found; using mock auth fallback") - tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-")) - mkdirSync(join(tempHome, ".commandcode"), { recursive: true }) - writeFileSync(join(tempHome, ".commandcode", "auth.json"), JSON.stringify({ apiKey: "mock-key" })) - env.HOME = tempHome - env.USERPROFILE = tempHome - env.COMMANDCODE_API_KEY = "mock-key" + COMMANDCODE_API_BASE: `${apiRoot}/provider/v1`, + COMMANDCODE_MODELS_URL: `${apiRoot}/provider/v1/models`, + COMMANDCODE_API_KEY: "mock-key", } function runPi(args, timeoutMs = 30_000) { @@ -299,8 +298,9 @@ try { assert.match(list.stdout, /Qwen\/Qwen3\.7-Max/) assert.equal(modelListRequestCount, 1) - console.log("[pi-local] print mode through real extension and mock API") + console.log("[pi-local] print mode through real extension and mock Provider API") requestCount = 0 + alphaGenerateRequestCount = 0 const print = await runPi( [ "--no-extensions", @@ -318,15 +318,17 @@ try { assert.equal(print.code, 0, print.stderr) assert.match(print.stdout, /mock-pi-ok/) assert.equal(requestCount, 1) + assert.equal(alphaGenerateRequestCount, 0) assert.ok( typeof lastRequestHeaders.authorization === "string" && lastRequestHeaders.authorization.startsWith("Bearer "), "should send a bearer Authorization header", ) - assert.equal(lastRequestBody?.params?.model, TEST_MODEL) + assert.equal(lastRequestBody?.model, TEST_MODEL) - console.log("[pi-local] RPC prompt through real extension and mock API") + console.log("[pi-local] RPC prompt through real extension and mock Provider API") requestCount = 0 + alphaGenerateRequestCount = 0 const rpc = await runRpcQuery() assert.equal( rpc.ok, @@ -341,9 +343,9 @@ try { assert.equal(rpc.sawAssistantMessage, true) assert.equal(rpc.sawTextDelta, true) assert.equal(requestCount, 1) + assert.equal(alphaGenerateRequestCount, 0) console.log("[pi-local] PASS") } finally { await new Promise((resolve) => server.close(resolve)) - if (tempHome) rmSync(tempHome, { recursive: true, force: true }) } diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts deleted file mode 100644 index 751f573..0000000 --- a/tests/test-pure-functions.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Unit tests for the real pure helpers exported by src/core.ts. - * These are hermetic: no pi runtime and no network. - */ - -import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { describe, it } from "node:test" - -import { - getApiKey, - getEnvironmentInfo, - mapFinishReason, - messagesToCC, - parseStreamEventLine, - textContent, - toJsonSchema, - toolsToJson, -} from "../src/core.ts" - -import { objectAt } from "./helpers.ts" - -describe("getApiKey()", () => { - it("uses COMMANDCODE_API_KEY from provided env", () => { - assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key") - }) - - it("reads apiKey, commandcode, and pi OAuth credential fields from explicit auth paths", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) - try { - const first = join(dir, "first.json") - const second = join(dir, "second.json") - const oauth = join(dir, "oauth.json") - 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, - }, - }), - ) - 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") - } 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("textContent()", () => { - it("extracts and joins text blocks", () => { - assert.equal( - textContent({ - content: [ - { type: "text", text: "hello" }, - { type: "image", data: "x" }, - { type: "text", text: "world" }, - ], - }), - "hello\nworld", - ) - }) - - it("handles empty or missing content", () => { - assert.equal(textContent({ content: [] }), "") - assert.equal(textContent({}), "") - }) -}) - -describe("getEnvironmentInfo()", () => { - it("returns platform, arch, and Node version", () => { - const info = getEnvironmentInfo() - assert.match(info, /^(darwin|linux|win32)-/) - assert.ok(info.includes("Node.js")) - }) -}) - -describe("toJsonSchema()", () => { - it("converts scalar, enum, object, optional, array, and union schema shapes", () => { - assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" }) - assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" }) - assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" }) - assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), { - type: "string", - enum: ["left", "right"], - }) - assert.deepEqual( - toJsonSchema({ - kind: "object", - properties: { - name: { kind: "string" }, - tags: { kind: "array", items: { kind: "string" }, optional: true }, - }, - }), - { - type: "object", - properties: { - name: { type: "string" }, - tags: { type: "array", items: { type: "string" } }, - }, - required: ["name"], - }, - ) - assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), { - type: "string", - }) - assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), { - type: "number", - }) - }) - - it("preserves explicit required arrays and handles unknown values", () => { - assert.deepEqual( - toJsonSchema({ - type: "object", - properties: { name: { type: "string" }, nickname: { type: "string" } }, - required: ["name"], - }), - { - type: "object", - properties: { name: { type: "string" }, nickname: { type: "string" } }, - required: ["name"], - }, - ) - assert.deepEqual(toJsonSchema(undefined), {}) - assert.deepEqual(toJsonSchema({ kind: "wat" }), {}) - }) -}) - -describe("toolsToJson()", () => { - it("converts pi tools to Command Code tool JSON", () => { - assert.deepEqual( - toolsToJson([ - { - name: "get_weather", - description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - ]), - [ - { - type: "function", - name: "get_weather", - description: "Get weather", - input_schema: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"], - }, - }, - ], - ) - }) - - it("returns an empty array for missing tools", () => { - assert.deepEqual(toolsToJson(), []) - }) -}) - -describe("messagesToCC()", () => { - it("converts user, assistant, and tool result messages", () => { - const result = messagesToCC([ - { role: "user", content: "read /tmp/test" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "I will read" }, - { type: "text", text: "Sure" }, - { - type: "toolCall", - id: "c1", - name: "read", - arguments: { path: "/tmp/test" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "c1", - toolName: "read", - isError: false, - content: [ - { type: "text", text: "hello" }, - { type: "text", text: "world" }, - ], - }, - ]) - - assert.equal(objectAt(result, ["0", "role"]), "user") - assert.equal(objectAt(result, ["1", "role"]), "assistant") - assert.equal(objectAt(result, ["1", "content", "0", "type"]), "reasoning") - assert.equal(objectAt(result, ["1", "content", "2", "type"]), "tool-call") - assert.equal(objectAt(result, ["2", "role"]), "tool") - assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld") - }) - - it("drops orphaned tool calls that have no matching tool result", () => { - const result = messagesToCC([ - { role: "user", content: "edit a file" }, - { - role: "assistant", - content: [ - { type: "text", text: "I will edit it" }, - { - type: "toolCall", - id: "missing-result", - name: "edit", - arguments: { path: "x" }, - }, - ], - }, - ]) - - assert.equal(objectAt(result, ["1", "role"]), "assistant") - assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") - assert.equal(objectAt(result, ["1", "content", "1"]), undefined) - }) - - it("handles empty conversations", () => { - assert.deepEqual(messagesToCC([]), []) - }) -}) - -describe("parseStreamEventLine()", () => { - it("parses plain JSON and SSE data lines", () => { - assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), { - type: "text-delta", - text: "x", - }) - assert.deepEqual(parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), { - type: "finish", - finishReason: "stop", - }) - }) - - it("ignores comments, event labels, done markers, and malformed JSON", () => { - assert.equal(parseStreamEventLine(":"), undefined) - assert.equal(parseStreamEventLine("event: message"), undefined) - assert.equal(parseStreamEventLine("data: [DONE]"), undefined) - assert.equal(parseStreamEventLine("not-json"), undefined) - }) -}) - -describe("mapFinishReason()", () => { - it("maps provider finish reasons to pi stop reasons", () => { - assert.equal(mapFinishReason("stop"), "stop") - assert.equal(mapFinishReason("tool-calls"), "toolUse") - assert.equal(mapFinishReason("max_tokens"), "length") - assert.equal(mapFinishReason("max_output_tokens"), "length") - }) -}) diff --git a/tests/test-stream.ts b/tests/test-stream.ts deleted file mode 100644 index 8cebadb..0000000 --- a/tests/test-stream.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** - * Integration tests for the real streamCommandCode core using a local mock - * Command Code server. No real API key or pi runtime required. - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import type { AssistantMessageEvent } from "../src/core.ts" -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - objectAt, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -function eventTypes(events: readonly AssistantMessageEvent[]): string[] { - return events.map((event) => event.type) -} - -describe("streamCommandCode — auth", () => { - it("emits a missing-key error without touching the network", async () => { - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: {}, - authPaths: [], - }) - const stream = streamCommandCode(makeModel(), makeContext(), { - apiKey: "", - }) - const events = await collectEvents(stream) - - assert.deepEqual(eventTypes(events), ["error"]) - assert.equal(events[0].type, "error") - assert.equal(events[0].reason, "error") - assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/) - assert.equal(server.requestCount(), 0) - }) - - it("uses options.apiKey in the Authorization header", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ - apiBase: server.baseUrl(), - env: { COMMANDCODE_API_KEY: "env-key" }, - }) - - await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "option-key" })) - - assert.equal(server.lastRequestHeaders().authorization, "Bearer option-key") - }) -}) - -describe("streamCommandCode — successful streams", () => { - it("emits start → text events → done and accumulates usage", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "Hel" }), - JSON.stringify({ type: "text-delta", text: "lo" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 5, - outputTokens: 2, - inputTokenDetails: { cacheReadTokens: 3, cacheWriteTokens: 1 }, - }, - }), - ], - }) - const { streamCommandCode, calculatedUsages } = createTestDeps({ - apiBase: server.baseUrl(), - }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - ) - - assert.deepEqual(eventTypes(events), [ - "start", - "text_start", - "text_delta", - "text_delta", - "text_end", - "done", - ]) - const done = events.at(-1) - assert.equal(done?.type, "done") - if (done?.type !== "done") throw new Error("expected done") - assert.equal(done.reason, "stop") - assert.equal(done.message.content[0]?.type, "text") - assert.equal( - done.message.content[0]?.type === "text" ? done.message.content[0].text : "", - "Hello", - ) - assert.equal(done.message.usage.totalTokens, 11) - assert.equal(calculatedUsages.length, 1) - }) - - it("ends on finish without waiting for an open upstream connection", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "text-delta", text: "done" }), - JSON.stringify({ type: "finish", finishReason: "stop" }), - ], - hangAfterLast: true, - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), - 500, - ) - - assert.equal(events.at(-1)?.type, "done") - await new Promise((resolve) => setTimeout(resolve, 50)) - assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body") - }) - - it("emits reasoning and tool-call blocks in order", async () => { - server.mockResponse({ - type: "success", - events: [ - JSON.stringify({ type: "reasoning-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") - }) -}) - -describe("streamCommandCode — request serialization", () => { - it("sends the expected request body and default headers", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "finish", finishReason: "stop" })], - }) - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - const context = makeContext({ - messages: [ - { role: "user", content: "first" }, - { - role: "assistant", - content: [{ type: "text", text: "first response" }], - }, - { role: "user", content: "second" }, - ], - tools: [ - { - name: "get_weather", - description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - ], - }) - - await collectEvents( - streamCommandCode(makeModel(), context, { - apiKey: "mock-key", - maxTokens: 500, - }), - ) - - const body = server.lastRequestBody() - assert.equal(objectAt(body, ["config", "workingDir"]), "/repo") - assert.equal(objectAt(body, ["config", "date"]), "2026-05-05") - assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash") - assert.equal(objectAt(body, ["params", "stream"]), true) - assert.equal(objectAt(body, ["params", "max_tokens"]), 500) - assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.") - assert.equal( - objectAt(body, ["params", "messages", "1", "content", "0", "text"]), - "first response", - ) - assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "get_weather") - - const headers = server.lastRequestHeaders() - assert.equal(headers.authorization, "Bearer mock-key") - assert.equal(headers["x-command-code-version"], "0.24.1") - assert.equal(headers["x-session-id"], "00000000-0000-4000-8000-000000000000") - }) - - 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"]), 200_000) - assert.equal(server.lastRequestHeaders()["x-custom"], "value") - }) - - 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 b02fde60402f67cb76ce0fd99793990991c7b883 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 26 May 2026 22:21:03 +0200 Subject: [PATCH 4/4] Document recommended Command Code API key auth --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7a77cc8..587be38 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Then reload pi: Set your Command Code API key using one of these methods: -### 1. Login flow (recommended) +### 1. Login flow: paste API key (recommended) In pi, run: @@ -48,14 +48,9 @@ In pi, run: /login ``` -Then select **Command Code** from the provider list. The login flow lets you either: +Then select **Command Code** from the provider list. Type `key` or paste your Studio API key directly. The key is stored in pi's auth file. -- press Enter for the current browser-assisted login, or -- type `key` / paste a Studio API key directly. - -Browser login opens Command Code in your browser and stores the returned API key in pi's auth file. If the browser shows "Copy your API key" because automatic transfer failed, copy that key and paste it into the pi terminal prompt. - -> Note: `/login commandcode` is not supported by pi currently; use interactive `/login` and select Command Code. +> Recommended: use a Command Code Studio API key with the official Provider API. ### 2. Environment variable @@ -81,6 +76,14 @@ Or use pi's auth file at `~/.pi/agent/auth.json`: } ``` +### Legacy browser-assisted login + +The previous browser-assisted login flow is still available by pressing Enter at the Command Code login prompt. It opens Command Code in your browser and waits for the returned API key. + +> Warning: this legacy browser flow follows Command Code's CLI auth flow. In [#5](https://github.com/patlux/pi-commandcode-provider/issues/5), Command Code warned that use of reverse-engineered/internal paths may lead to accounts being banned. Prefer the API key flow above with the official Provider API. +> +> Note: `/login commandcode` is not supported by pi currently; use interactive `/login` and select Command Code. + ## Usage After installing and setting your API key, select a Command Code model in pi: