Add native AI SDK provider for CommandCode /alpha/generate

This commit is contained in:
nhat.nguyenhong 2026-09-10 20:32:40 +07:00
commit 2411a5cdc8
12 changed files with 1036 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.log
dump/

63
package-lock.json generated Normal file
View File

@ -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
}
}
}

27
package.json Normal file
View File

@ -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"
}
}

55
scripts/smoke.mjs Normal file
View File

@ -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));

40
src/constants.ts Normal file
View File

@ -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 },
};

44
src/events.ts Normal file
View File

@ -0,0 +1,44 @@
/** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */
export async function* iterateEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<Record<string, any>> {
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<string, any> | 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;
}
}

2
src/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { createCommandCode, default } from "./model.js";
export type { CommandCodeOptions } from "./model.js";

402
src/model.ts Normal file
View File

@ -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<string, string>;
baseURL?: string;
ccVersion?: string;
maxRetries?: number;
retryMaxDelaySeconds?: number;
};
type ResolvedOptions = {
providerId: string;
apiKey?: string;
headers: Record<string, string>;
baseURL: string;
ccVersion: string;
maxRetries: number;
retryMaxDelay: number;
};
function headerValue(headers: Record<string, string>, 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<T>(iterator: AsyncGenerator<T>): ReadableStream<T> {
return new ReadableStream<T>({
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<string, string> {
const out: Record<string, string> = {};
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<string, RegExp[]> = {};
private requestHeaders(extra?: Record<string, string | undefined>): Record<string, string> {
const headers: Record<string, string> = {
"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<Response> {
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<Error> {
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<LanguageModelV3StreamResult> {
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<LanguageModelV3GenerateResult> {
const { stream, request, response } = await this.doStream(options);
let text = "";
let reasoning = "";
const toolCalls: Array<Extract<LanguageModelV3Content, { type: "tool-call" }>> = [];
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<Uint8Array>): AsyncGenerator<LanguageModelV3StreamPart> {
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;

30
src/redact.ts Normal file
View File

@ -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]";
});
}

263
src/transform.ts Normal file
View File

@ -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<string, unknown>;
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<string, string> {
const names = new Map<string, string>();
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<string> {
const calls = new Set<string>();
const results = new Set<string>();
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<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = [];
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<Record<string, unknown>>,
toolChoice: LanguageModelV3CallOptions["toolChoice"],
): { tools: Array<Record<string, unknown>>; toolChoice: Record<string, unknown> | 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<string, unknown> | 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<Record<string, unknown>> = [];
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<string, unknown> = { 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 };

88
src/usage.ts Normal file
View File

@ -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<string, never>,
};
}
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 };
}

18
tsconfig.json Normal file
View File

@ -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"]
}