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