Add native AI SDK provider for CommandCode /alpha/generate
This commit is contained in:
commit
2411a5cdc8
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
dump/
|
||||
63
package-lock.json
generated
Normal file
63
package-lock.json
generated
Normal 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
27
package.json
Normal 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
55
scripts/smoke.mjs
Normal 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
40
src/constants.ts
Normal 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
44
src/events.ts
Normal 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
2
src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { createCommandCode, default } from "./model.js";
|
||||
export type { CommandCodeOptions } from "./model.js";
|
||||
402
src/model.ts
Normal file
402
src/model.ts
Normal 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
30
src/redact.ts
Normal 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
263
src/transform.ts
Normal 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
88
src/usage.ts
Normal 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
18
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user