From 2411a5cdc802a35d495e95d23d9e06956c131e8f Mon Sep 17 00:00:00 2001 From: "nhat.nguyenhong" Date: Thu, 10 Sep 2026 20:32:40 +0700 Subject: [PATCH] Add native AI SDK provider for CommandCode /alpha/generate --- .gitignore | 4 + package-lock.json | 63 ++++++++ package.json | 27 ++++ scripts/smoke.mjs | 55 +++++++ src/constants.ts | 40 +++++ src/events.ts | 44 +++++ src/index.ts | 2 + src/model.ts | 402 ++++++++++++++++++++++++++++++++++++++++++++++ src/redact.ts | 30 ++++ src/transform.ts | 263 ++++++++++++++++++++++++++++++ src/usage.ts | 88 ++++++++++ tsconfig.json | 18 +++ 12 files changed, 1036 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/smoke.mjs create mode 100644 src/constants.ts create mode 100644 src/events.ts create mode 100644 src/index.ts create mode 100644 src/model.ts create mode 100644 src/redact.ts create mode 100644 src/transform.ts create mode 100644 src/usage.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf1f9d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +dump/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..35a128d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,63 @@ +{ + "name": "opencode-commandcode-provider", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-commandcode-provider", + "version": "0.1.0", + "devDependencies": { + "@ai-sdk/provider": "3.0.8", + "@types/node": "22.10.2", + "typescript": "5.8.2" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7a5146 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "opencode-commandcode-provider", + "version": "0.1.0", + "description": "Native AI SDK provider for the CommandCode /alpha/generate API (opencode).", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "smoke": "node scripts/smoke.mjs" + }, + "devDependencies": { + "@ai-sdk/provider": "3.0.8", + "@types/node": "22.10.2", + "typescript": "5.8.2" + } +} diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..8c2f422 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,55 @@ +// Live smoke test for the CommandCode provider. Run: node scripts/smoke.mjs [modelId] +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createCommandCode } from "../dist/index.js"; + +function apiKey() { + if (process.env.COMMANDCODE_API_KEY) return process.env.COMMANDCODE_API_KEY; + const configPath = join(homedir(), ".config", "opencode", "opencode.json"); + try { + const cfg = JSON.parse(readFileSync(configPath, "utf8")); + return cfg.provider?.commandcode?.options?.apiKey; + } catch { + return undefined; + } +} + +const modelId = process.argv[2] ?? "deepseek/deepseek-v4-flash-vision-exp"; +const key = apiKey(); +if (!key) { + console.error("No API key: set COMMANDCODE_API_KEY or configure provider.commandcode.options.apiKey"); + process.exit(1); +} + +const provider = createCommandCode({ name: "commandcode", apiKey: key }); +const model = provider.languageModel(modelId); + +console.log(`== doGenerate: ${modelId} ==`); +const result = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Reply with exactly: pong" }] }], + maxOutputTokens: 64, +}); +console.log("content:", JSON.stringify(result.content)); +console.log("finishReason:", result.finishReason); +console.log("usage:", JSON.stringify(result.usage)); + +console.log(`\n== doStream: ${modelId} ==`); +const { stream } = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Count from 1 to 3." }] }], + maxOutputTokens: 128, +}); +const reader = stream.getReader(); +let text = ""; +try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value.type === "text-delta") text += value.delta; + if (value.type === "finish") console.log("stream finish:", JSON.stringify(value.finishReason)); + if (value.type === "error") throw value.error; + } +} finally { + reader.releaseLock(); +} +console.log("stream text:", JSON.stringify(text)); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..5b86236 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,40 @@ +// Shared constants. Ported from server.py so the wire shape stays identical. + +export const DEFAULT_BASE_URL = "https://api.commandcode.ai"; +export const GENERATE_PATH = "/alpha/generate"; +export const MODELS_PATH = "/provider/v1/models"; + +export const DEFAULT_CC_VERSION = "1.15.1"; +export const DEFAULT_MAX_TOKENS = 32000; +export const DEFAULT_MAX_RETRIES = 2; +export const BASE_RETRY_DELAY = 0.5; +export const DEFAULT_RETRY_MAX_DELAY = 60; + +// Sampling knobs CommandCode accepts (probed individually in server.py). Forwarded verbatim. +export const PASSTHROUGH_PARAMS = [ + "temperature", + "top_p", + "top_k", + "stop", + "seed", + "presence_penalty", + "frequency_penalty", + "reasoning_effort", +] as const; + +export const STATIC_CONFIG = { + workingDir: "", + date: "", + environment: "", + structure: [] as unknown[], + isGitRepo: false, + currentBranch: "", + mainBranch: "main", + gitStatus: "", + recentCommits: [] as unknown[], +}; + +export const ZERO_USAGE = { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, +}; diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..25768ca --- /dev/null +++ b/src/events.ts @@ -0,0 +1,44 @@ +/** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */ +export async function* iterateEvents(body: ReadableStream): AsyncGenerator> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + let newline: number; + while ((newline = buffer.indexOf("\n")) >= 0) { + const raw = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + const event = parseLine(raw); + if (event) yield event; + } + } + + // flush any trailing line without a newline + buffer += decoder.decode(); + if (buffer.trim()) { + const event = parseLine(buffer); + if (event) yield event; + } + } finally { + reader.releaseLock(); + } +} + +function parseLine(raw: string): Record | null { + let line = raw.trim(); + if (!line || line.startsWith(":") || line.startsWith("event:")) return null; + if (line.startsWith("data:")) line = line.slice(5).trim(); + if (!line || line === "[DONE]") return null; + try { + const parsed = JSON.parse(line); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..02d1553 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export { createCommandCode, default } from "./model.js"; +export type { CommandCodeOptions } from "./model.js"; diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..d0ad2e6 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,402 @@ +import type { + LanguageModelV3, + LanguageModelV3Content, + LanguageModelV3CallOptions, + LanguageModelV3FinishReason, + LanguageModelV3GenerateResult, + LanguageModelV3StreamPart, + LanguageModelV3StreamResult, + LanguageModelV3Usage, + SharedV3Warning, +} from "@ai-sdk/provider"; +import { + BASE_RETRY_DELAY, + DEFAULT_BASE_URL, + DEFAULT_CC_VERSION, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_MAX_DELAY, + GENERATE_PATH, +} from "./constants.js"; +import { iterateEvents } from "./events.js"; +import { redact } from "./redact.js"; +import { transform } from "./transform.js"; +import { finishReasonFrom, usageFromFinish, type FinishEvent } from "./usage.js"; + +export type CommandCodeOptions = { + name?: string; + apiKey?: string; + headers?: Record; + baseURL?: string; + ccVersion?: string; + maxRetries?: number; + retryMaxDelaySeconds?: number; +}; + +type ResolvedOptions = { + providerId: string; + apiKey?: string; + headers: Record; + baseURL: string; + ccVersion: string; + maxRetries: number; + retryMaxDelay: number; +}; + +function headerValue(headers: Record, key: string): string | undefined { + const wanted = key.toLowerCase(); + for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v; + return undefined; +} + +function bearer(token: string | undefined): string | undefined { + if (!token) return undefined; + return /^Bearer\s/i.test(token) ? token : `Bearer ${token}`; +} + +function resolve(options: CommandCodeOptions): ResolvedOptions { + const headers = { ...(options.headers ?? {}) }; + const fromHeader = headerValue(headers, "authorization"); + const apiKey = options.apiKey ?? fromHeader?.replace(/^Bearer\s+/i, ""); + return { + providerId: options.name ?? "commandcode", + apiKey, + headers, + baseURL: (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, ""), + ccVersion: options.ccVersion ?? DEFAULT_CC_VERSION, + maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, + retryMaxDelay: options.retryMaxDelaySeconds ?? DEFAULT_RETRY_MAX_DELAY, + }; +} + +function zeroUsage(): LanguageModelV3Usage { + return { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }; +} + +function isRetryable(status: number): boolean { + return status === 429 || (status >= 500 && status < 600); +} + +function retryDelay(attempt: number, retryAfter: string | null, maxDelay: number): number { + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) return seconds > maxDelay ? -1 : seconds; + } + const exponential = BASE_RETRY_DELAY * 2 ** attempt; + return Math.min(exponential + exponential * 0.2 * Math.random(), maxDelay); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function toReadableStream(iterator: AsyncGenerator): ReadableStream { + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await iterator.next(); + if (done) controller.close(); + else controller.enqueue(value); + } catch (error) { + controller.error(error); + } + }, + async cancel(reason) { + await iterator.return?.(reason as never); + }, + }); +} + +function headerObject(headers: Headers): Record { + const out: Record = {}; + headers.forEach((value, key) => { + out[key] = value; + }); + return out; +} + +class CommandCodeLanguageModel implements LanguageModelV3 { + readonly specificationVersion = "v3" as const; + readonly provider: string; + readonly modelId: string; + + private readonly opts: ResolvedOptions; + + constructor(modelId: string, opts: ResolvedOptions) { + this.modelId = modelId; + this.provider = opts.providerId; + this.opts = opts; + } + + supportedUrls: Record = {}; + + private requestHeaders(extra?: Record): Record { + const headers: Record = { + "Content-Type": "application/json", + "x-command-code-version": this.opts.ccVersion, + "x-cli-environment": "production", + "x-project-slug": "project", + "x-taste-learning": "true", + "x-co-flag": "false", + ...this.opts.headers, + }; + const auth = bearer(this.opts.apiKey); + if (auth) headers["Authorization"] = auth; + for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v; + return headers; + } + + private async fetchWithRetry(body: string, options: LanguageModelV3CallOptions): Promise { + const headers = this.requestHeaders(options.headers); + let lastError: unknown; + + for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) { + if (options.abortSignal?.aborted) throw options.abortSignal.reason ?? new Error("Aborted"); + let response: Response; + try { + response = await fetch(`${this.opts.baseURL}${GENERATE_PATH}`, { + method: "POST", + headers, + body, + signal: options.abortSignal, + }); + } catch (error) { + lastError = error; + if (attempt < this.opts.maxRetries && !options.abortSignal?.aborted) { + await sleep(retryDelay(attempt, null, this.opts.retryMaxDelay)); + continue; + } + throw error; + } + + if (isRetryable(response.status) && attempt < this.opts.maxRetries) { + const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay); + if (wait >= 0) { + try { + await response.body?.cancel(); + } catch { + /* ignore */ + } + await sleep(wait); + continue; + } + } + return response; + } + + throw lastError instanceof Error ? lastError : new Error("Upstream unreachable"); + } + + private async errorFrom(response: Response): Promise { + let body = ""; + try { + body = await response.text(); + } catch { + /* ignore */ + } + let message = body; + try { + const parsed = JSON.parse(body); + const err = parsed?.error; + if (typeof err === "string") message = err; + else if (err && typeof err === "object" && typeof err.message === "string") message = err.message; + } catch { + /* keep raw body */ + } + return new Error(redact(message.slice(0, 2000) || `Upstream returned ${response.status}`)); + } + + async doStream(options: LanguageModelV3CallOptions): Promise { + const body = transform(options, this.modelId); + const response = await this.fetchWithRetry(body, options); + if (!response.ok || !response.body) throw await this.errorFrom(response); + + const stream = toReadableStream(this.streamParts(response.body)); + return { + stream, + request: { body: JSON.parse(body) as unknown }, + response: { headers: headerObject(response.headers) }, + }; + } + + async doGenerate(options: LanguageModelV3CallOptions): Promise { + const { stream, request, response } = await this.doStream(options); + + let text = ""; + let reasoning = ""; + const toolCalls: Array> = []; + let usage: LanguageModelV3Usage = zeroUsage(); + let finishReason: LanguageModelV3FinishReason = { unified: "other", raw: undefined }; + let warnings: SharedV3Warning[] = []; + + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + switch (value.type) { + case "stream-start": + warnings = value.warnings; + break; + case "text-delta": + text += value.delta; + break; + case "reasoning-delta": + reasoning += value.delta; + break; + case "tool-call": + toolCalls.push({ type: "tool-call", toolCallId: value.toolCallId, toolName: value.toolName, input: value.input }); + break; + case "finish": + usage = value.usage; + finishReason = value.finishReason; + break; + case "error": + throw value.error instanceof Error ? value.error : new Error(String(value.error)); + default: + break; + } + } + } finally { + reader.releaseLock(); + } + + const content: LanguageModelV3Content[] = []; + if (reasoning) content.push({ type: "reasoning", text: reasoning }); + if (text) content.push({ type: "text", text }); + content.push(...toolCalls); + + return { content, finishReason, usage, warnings, request, response }; + } + + private async *streamParts(body: ReadableStream): AsyncGenerator { + yield { type: "stream-start", warnings: [] }; + + const textId = "text-0"; + const reasoningId = "reasoning-0"; + let textOpen = false; + let reasoningOpen = false; + let toolId: string | null = null; + let hadToolCalls = false; + let usage: LanguageModelV3Usage | undefined; + let finishReason: LanguageModelV3FinishReason | undefined; + let errored = false; + + for await (const evt of iterateEvents(body)) { + switch (evt.type) { + case "text-start": + if (!textOpen) { + textOpen = true; + yield { type: "text-start", id: textId }; + } + break; + case "text-delta": + if (!textOpen) { + textOpen = true; + yield { type: "text-start", id: textId }; + } + yield { type: "text-delta", id: textId, delta: evt.text ?? "" }; + break; + case "text-end": + if (textOpen) { + textOpen = false; + yield { type: "text-end", id: textId }; + } + break; + case "reasoning-start": + if (!reasoningOpen) { + reasoningOpen = true; + yield { type: "reasoning-start", id: reasoningId }; + } + break; + case "reasoning-delta": + if (!reasoningOpen) { + reasoningOpen = true; + yield { type: "reasoning-start", id: reasoningId }; + } + yield { type: "reasoning-delta", id: reasoningId, delta: evt.text ?? "" }; + break; + case "reasoning-end": + if (reasoningOpen) { + reasoningOpen = false; + yield { type: "reasoning-end", id: reasoningId }; + } + break; + case "tool-input-start": + hadToolCalls = true; + toolId = evt.id ?? null; + yield { type: "tool-input-start", id: String(evt.id ?? ""), toolName: String(evt.toolName ?? "unknown") }; + break; + case "tool-input-delta": + if (toolId) yield { type: "tool-input-delta", id: toolId, delta: evt.delta ?? "" }; + break; + case "tool-input-end": + if (toolId) { + yield { type: "tool-input-end", id: toolId }; + toolId = null; + } + break; + case "tool-call": { + hadToolCalls = true; + const id = evt.toolCallId ?? toolId; + const name = evt.toolName ?? evt.name ?? "unknown"; + if (!toolId && id) yield { type: "tool-input-start", id, toolName: name }; + if (id) { + const input = typeof evt.input === "string" ? evt.input : JSON.stringify(evt.input ?? {}); + yield { type: "tool-call", toolCallId: id, toolName: name, input }; + } + toolId = null; + break; + } + case "finish": + usage = usageFromFinish(evt as FinishEvent); + finishReason = finishReasonFrom(evt as FinishEvent, hadToolCalls); + break; + case "error": { + errored = true; + const err = evt.error; + const message = err && typeof err === "object" && typeof err.message === "string" ? err.message : JSON.stringify(err); + if (textOpen) { + textOpen = false; + yield { type: "text-end", id: textId }; + } + if (reasoningOpen) { + reasoningOpen = false; + yield { type: "reasoning-end", id: reasoningId }; + } + yield { type: "error", error: new Error(redact(message)) }; + break; + } + default: + break; + } + } + + if (errored) return; + if (textOpen) yield { type: "text-end", id: textId }; + if (reasoningOpen) yield { type: "reasoning-end", id: reasoningId }; + yield { + type: "finish", + usage: usage ?? zeroUsage(), + finishReason: finishReason ?? { unified: hadToolCalls ? "tool-calls" : "stop", raw: undefined }, + }; + } +} + +/** + * Factory consumed by opencode's provider loader: it picks the first export whose name starts + * with `create` and calls it as `fn({ name: providerID, ...options })`. The returned object must + * expose `languageModel(id)`. + */ +export function createCommandCode(options: CommandCodeOptions = {}): { + languageModel(modelId: string): LanguageModelV3; +} { + const resolved = resolve(options); + return { + languageModel(modelId: string) { + return new CommandCodeLanguageModel(modelId, resolved); + }, + }; +} + +export default createCommandCode; diff --git a/src/redact.ts b/src/redact.ts new file mode 100644 index 0000000..87aa0e1 --- /dev/null +++ b/src/redact.ts @@ -0,0 +1,30 @@ +// Credential redaction. Ported from server.py (which ported pi-commandcode-provider +// src/overflow.ts). Upstream error bodies can echo the caller's credentials and are +// forwarded to the client, so scrub them first. + +const REDACT: Array<[RegExp, string]> = [ + [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]"], + [/\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi, "[redacted]"], + [ + /([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|password)=)[^&#\s]+/gi, + "$1[redacted]", + ], + [ + /\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, + "[redacted]", + ], +]; + +const REDACT_KV = + /\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*[^\s,;)"']+/gi; + +export function redact(text: string | undefined | null): string { + if (!text) return text ?? ""; + let out = text; + for (const [pattern, repl] of REDACT) out = out.replace(pattern, repl); + return out.replace(REDACT_KV, (match) => { + const indexes = ["=", ":"].map((c) => match.indexOf(c)).filter((i) => i >= 0); + const i = indexes.length ? Math.min(...indexes) : -1; + return i < 0 ? match : match.slice(0, i + 1) + "[redacted]"; + }); +} diff --git a/src/transform.ts b/src/transform.ts new file mode 100644 index 0000000..cc3a5a5 --- /dev/null +++ b/src/transform.ts @@ -0,0 +1,263 @@ +import type { + LanguageModelV3CallOptions, + LanguageModelV3FunctionTool, + LanguageModelV3ToolResultOutput, + LanguageModelV3ToolResultPart, +} from "@ai-sdk/provider"; +import { DEFAULT_MAX_TOKENS, PASSTHROUGH_PARAMS, STATIC_CONFIG } from "./constants.js"; + +type ContentPart = Record; + +type ToolCallRef = { id: string; name: string }; + +function environmentInfo(): string { + const plat = typeof process !== "undefined" ? `${process.platform}-${process.arch}` : "unknown"; + const ver = typeof process !== "undefined" && process.version ? process.version : ""; + return `${plat}${ver ? `, Node ${ver}` : ""}`; +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** tool_call_id -> tool name, so tool results can be attributed. */ +function collectToolNames(prompt: LanguageModelV3CallOptions["prompt"]): Map { + const names = new Map(); + for (const message of prompt) { + if (message.role !== "assistant") continue; + for (const part of message.content) { + if (part.type === "tool-call" && part.toolCallId) { + names.set(part.toolCallId, part.toolName); + } + } + } + return names; +} + +/** Tool ids that have both a call and a result. Unmatched ids make upstream reject the request. */ +function pairedToolCallIds(prompt: LanguageModelV3CallOptions["prompt"]): Set { + const calls = new Set(); + const results = new Set(); + for (const message of prompt) { + if (message.role === "assistant") { + for (const part of message.content) { + if (part.type === "tool-call" && part.toolCallId) calls.add(part.toolCallId); + } + } else if (message.role === "tool") { + for (const part of message.content) { + if (part.type === "tool-result" && part.toolCallId) results.add(part.toolCallId); + } + } + } + return new Set([...calls].filter((id) => results.has(id))); +} + +function stringify(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function toolResultOutput(output: LanguageModelV3ToolResultOutput): ContentPart { + switch (output.type) { + case "text": + case "error-text": + return { type: "text", value: output.value }; + case "json": + case "error-json": + return { type: "text", value: stringify(output.value) }; + case "execution-denied": + return { type: "text", value: output.reason ?? "Execution denied" }; + case "content": { + const parts: ContentPart[] = []; + for (const item of output.value) { + if (item.type === "text") { + parts.push({ type: "text", text: item.text }); + } else if (item.type === "image-data") { + parts.push({ + type: "image", + image: `data:${item.mediaType};base64,${item.data}`, + mimeType: item.mediaType, + }); + } else if (item.type === "image-url" || item.type === "file-url") { + parts.push({ type: "image", image: item.url }); + } else if (item.type === "file-data") { + parts.push({ + type: "image", + image: `data:${item.mediaType};base64,${item.data}`, + mimeType: item.mediaType, + }); + } else { + parts.push({ type: "text", text: stringify(item) }); + } + } + return { type: "text", value: stringify(parts) }; + } + default: + return { type: "text", value: stringify(output) }; + } +} + +/** AI SDK file part -> CommandCode image part. */ +function imagePart(data: unknown, mediaType: string): ContentPart | null { + if (data instanceof URL) return { type: "image", image: data.toString() }; + if (typeof data === "string") { + if (/^data:[^;,]+;base64,/.test(data)) return { type: "image", image: data, mimeType: mediaType }; + if (/^https?:\/\//i.test(data)) return { type: "image", image: data }; + return { type: "image", image: `data:${mediaType};base64,${data}`, mimeType: mediaType }; + } + if (data instanceof Uint8Array) { + return { type: "image", image: `data:${mediaType};base64,${Buffer.from(data).toString("base64")}`, mimeType: mediaType }; + } + return null; +} + +function mapTools(tools: LanguageModelV3CallOptions["tools"]): Array> { + const out: Array> = []; + for (const tool of tools ?? []) { + if (tool.type !== "function") continue; + const fn = tool as LanguageModelV3FunctionTool; + out.push({ + type: "function", + name: fn.name, + description: fn.description ?? "", + input_schema: fn.inputSchema ?? { type: "object", properties: {} }, + }); + } + return out; +} + +/** + * OpenAI/upstream `tool_choice` -> (tools, params.tool_choice). + * + * Upstream accepts exactly one value, `{"type":"auto"}`. `none` is emulated by sending no + * tools; a named tool by sending only that tool; `required` is not expressible. + */ +function applyToolChoice( + tools: Array>, + toolChoice: LanguageModelV3CallOptions["toolChoice"], +): { tools: Array>; toolChoice: Record | null } { + if (!toolChoice) return { tools, toolChoice: null }; + if (toolChoice.type === "auto") return { tools, toolChoice: { type: "auto" } }; + if (toolChoice.type === "none") return { tools: [], toolChoice: null }; + if (toolChoice.type === "required") return { tools, toolChoice: null }; + if (toolChoice.type === "tool") { + const picked = tools.filter((t) => t.name === toolChoice.toolName); + return { tools: picked.length ? picked : tools, toolChoice: null }; + } + return { tools, toolChoice: null }; +} + +function reasoningEffort(options: LanguageModelV3CallOptions): unknown { + const scoped = options.providerOptions?.["commandcode"] as Record | undefined; + return scoped?.["reasoning_effort"] ?? scoped?.["reasoningEffort"]; +} + +/** AI SDK call options -> CommandCode `/alpha/generate` envelope (JSON string). */ +export function transform(options: LanguageModelV3CallOptions, modelId: string): string { + const systemParts: string[] = []; + const messages: Array> = []; + const paired = pairedToolCallIds(options.prompt); + const toolNames = collectToolNames(options.prompt); + + for (const message of options.prompt) { + if (message.role === "system") { + if (message.content) systemParts.push(message.content); + continue; + } + + if (message.role === "tool") { + for (const part of message.content) { + if (part.type !== "tool-result") continue; + if (!paired.has(part.toolCallId)) continue; + messages.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: part.toolCallId, + toolName: part.toolName ?? toolNames.get(part.toolCallId) ?? "unknown", + output: toolResultOutput((part as LanguageModelV3ToolResultPart).output), + }, + ], + }); + } + continue; + } + + if (message.role === "assistant") { + const parts: ContentPart[] = []; + for (const part of message.content) { + if (part.type === "text" && part.text) { + parts.push({ type: "text", text: part.text }); + } else if (part.type === "tool-call") { + if (!paired.has(part.toolCallId)) continue; + parts.push({ + type: "tool-call", + toolCallId: part.toolCallId, + toolName: part.toolName, + input: (part as { input?: unknown }).input ?? {}, + }); + } + // reasoning parts are not replayed upstream + } + if (parts.length) messages.push({ role: "assistant", content: parts }); + continue; + } + + // user + const parts: ContentPart[] = []; + for (const part of message.content) { + if (part.type === "text") { + parts.push({ type: "text", text: part.text }); + } else if (part.type === "file") { + if (part.mediaType?.startsWith("image/")) { + const img = imagePart((part as { data?: unknown }).data, part.mediaType); + if (img) parts.push(img); + } else { + parts.push({ type: "text", text: `[file: ${part.filename ?? part.mediaType}]` }); + } + } + } + messages.push({ role: "user", content: parts }); + } + + const mapped = mapTools(options.tools); + const { tools, toolChoice } = applyToolChoice(mapped, options.toolChoice); + + const params: Record = { model: modelId }; + const systemText = systemParts.filter(Boolean).join("\n\n"); + if (systemText) params["system"] = systemText; + params["messages"] = messages; + if (tools.length) params["tools"] = tools; + if (toolChoice) params["tool_choice"] = toolChoice; + params["max_tokens"] = options.maxOutputTokens ?? DEFAULT_MAX_TOKENS; + // Always stream upstream; `stream:false` makes the endpoint answer "Proxy use detected". + params["stream"] = true; + + if (options.temperature !== undefined) params["temperature"] = options.temperature; + if (options.topP !== undefined) params["top_p"] = options.topP; + if (options.topK !== undefined) params["top_k"] = options.topK; + if (options.stopSequences !== undefined) params["stop"] = options.stopSequences; + if (options.seed !== undefined) params["seed"] = options.seed; + if (options.presencePenalty !== undefined) params["presence_penalty"] = options.presencePenalty; + if (options.frequencyPenalty !== undefined) params["frequency_penalty"] = options.frequencyPenalty; + const effort = reasoningEffort(options); + if (effort !== undefined) params["reasoning_effort"] = effort; + + const config = { ...STATIC_CONFIG, date: today(), environment: environmentInfo() }; + + return JSON.stringify({ + config, + memory: null, + taste: null, + skills: null, + permissionMode: "standard", + params, + }); +} + +export { PASSTHROUGH_PARAMS }; diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..5f00059 --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,88 @@ +import type { LanguageModelV3FinishReason, LanguageModelV3Usage } from "@ai-sdk/provider"; +import { ZERO_USAGE } from "./constants.js"; + +export type CommandCodeUsage = { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + cachedInputTokens?: number; + inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number }; + outputTokenDetails?: { textTokens?: number; reasoningTokens?: number }; +}; + +export type FinishEvent = { + finishReason?: string; + rawFinishReason?: string; + totalUsage?: CommandCodeUsage; +}; + +/** CommandCode `finish` -> LanguageModelV3Usage. */ +export function usageFromFinish(evt: FinishEvent): LanguageModelV3Usage { + const tu = evt.totalUsage; + if (!tu || typeof tu !== "object") return structuredClone(ZERO_USAGE); + + const input = tu.inputTokens ?? 0; + const output = tu.outputTokens ?? 0; + const details = tu.inputTokenDetails ?? {}; + const outDetails = tu.outputTokenDetails ?? {}; + const cacheRead = tu.cachedInputTokens ?? details.cacheReadTokens; + + return { + inputTokens: { + total: tu.totalTokens ?? input + output, + noCache: details.noCacheTokens, + cacheRead, + cacheWrite: undefined, + }, + outputTokens: { + total: output, + text: outDetails.textTokens, + reasoning: outDetails.reasoningTokens, + }, + raw: tu as Record, + }; +} + +const OPENAI_RAW = new Set(["stop", "length", "tool_calls", "content_filter", "function_call"]); + +function unify(value: string | undefined): LanguageModelV3FinishReason["unified"] | undefined { + switch (value) { + case "tool-calls": + case "tool_calls": + return "tool-calls"; + case "length": + case "max_tokens": + case "max-tokens": + case "max_output_tokens": + return "length"; + case "content-filter": + case "content_filter": + return "content-filter"; + case "error": + return "error"; + case "stop": + return "stop"; + default: + return undefined; + } +} + +/** Ported from server.py `finish_reason_from`. `rawFinishReason` is already OpenAI-spelled. */ +export function finishReasonFrom(evt: FinishEvent, hadToolCalls: boolean): LanguageModelV3FinishReason { + const raw = evt.rawFinishReason; + const reason = evt.finishReason; + + if (raw && OPENAI_RAW.has(raw)) { + if (raw === "stop") return { unified: hadToolCalls ? "tool-calls" : "stop", raw }; + const u = unify(raw); + if (u) return { unified: u, raw }; + } + + const fromReason = unify(reason); + if (fromReason) { + if (fromReason === "stop") return { unified: hadToolCalls ? "tool-calls" : "stop", raw: raw ?? reason }; + return { unified: fromReason, raw: raw ?? reason }; + } + + return { unified: hadToolCalls ? "tool-calls" : "other", raw: raw ?? reason }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2d4608e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +}