This commit is contained in:
Sirius
2026-08-05 15:36:36 +08:00
commit 39d1d8de6c
19 changed files with 2495 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
import type { BridgeConfig } from "./config.js";
import type { CommandUsage } from "./openai.js";
import { TerminalRenderer } from "./renderer.js";
interface ResultFrame {
type: "result";
subtype: "success" | "error" | "max_turns" | string;
finalText: string;
durationMs: number;
usage?: CommandUsage;
error?: unknown;
}
export interface CommandResult {
finalText: string;
durationMs: number;
usage?: CommandUsage;
}
export interface InstallationStatus {
installed: boolean;
authenticated: boolean;
version?: string;
}
export class CommandCodeError extends Error {
constructor(
message: string,
readonly exitCode?: number | null,
) {
super(message);
this.name = "CommandCodeError";
}
}
function signalProcessGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
if (child.pid === undefined) return;
try {
if (process.platform === "win32") child.kill(signal);
else process.kill(-child.pid, signal);
} catch {
try { child.kill(signal); } catch { /* process already exited */ }
}
}
function terminateChild(child: ChildProcessWithoutNullStreams): () => void {
signalProcessGroup(child, "SIGINT");
const termTimer = setTimeout(() => signalProcessGroup(child, "SIGTERM"), 2_000);
const killTimer = setTimeout(() => signalProcessGroup(child, "SIGKILL"), 7_000);
termTimer.unref();
killTimer.unref();
return () => {
clearTimeout(termTimer);
clearTimeout(killTimer);
};
}
function parseFrame(line: string): Record<string, unknown> | undefined {
if (line.trim() === "") return undefined;
const value: unknown = JSON.parse(line);
if (typeof value !== "object" || value === null) throw new Error("NDJSON line is not an object");
return value as Record<string, unknown>;
}
export async function runCommandCode(
config: BridgeConfig,
cliModel: string,
effort: string,
prompt: string,
signal: AbortSignal,
): Promise<CommandResult> {
const args = [
"-p",
"--output-format", "json",
"--no-session",
"--skip-onboarding",
"--no-auto-update",
"--trust",
"--max-turns", String(config.max_turns),
"--model", cliModel,
"--effort", effort,
];
if (config.dangerously_skip_permissions) args.push("--yolo");
else args.push("--permission-mode", config.permission_mode);
const renderer = new TerminalRenderer();
renderer.begin(cliModel, effort);
return new Promise<CommandResult>((resolve, reject) => {
const startedAt = Date.now();
let child: ChildProcessWithoutNullStreams;
try {
child = spawn(config.command_code_executable, args, {
cwd: config.resolvedWorkingDirectory,
env: process.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
});
} catch (error) {
reject(new CommandCodeError(`CLI 启动失败:${String(error)}`));
return;
}
let settled = false;
let result: ResultFrame | undefined;
let parseError: Error | undefined;
let stderr = "";
let cancelEscalation: (() => void) | undefined;
let lastMessageText: string | undefined;
let finalTurnText: string | undefined;
const eventUsage: Required<CommandUsage> = { inputTokens: 0, outputTokens: 0 };
const finish = (error?: Error, value?: CommandResult) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
signal.removeEventListener("abort", onAbort);
cancelEscalation?.();
if (error) reject(error);
else resolve(value!);
};
const onAbort = () => {
cancelEscalation ??= terminateChild(child);
};
const timeout = setTimeout(() => {
renderer.fail(`超过 ${config.timeout_seconds} 秒总超时`);
cancelEscalation ??= terminateChild(child);
}, config.timeout_seconds * 1000);
timeout.unref();
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
child.once("error", (error) => {
renderer.fail(error.message);
finish(new CommandCodeError(`CLI 启动失败:${error.message}`));
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
if (stderr.length > 64 * 1024) stderr = stderr.slice(-64 * 1024);
renderer.stderr(chunk);
});
child.stdout.setEncoding("utf8");
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
lines.on("line", (line) => {
try {
// run_end carries nextState, which repeats the complete prompt and can be very large.
// The following result line is the authoritative compact result.
if (line.startsWith('{"type":"event","event":{"type":"run_end"')) return;
const frame = parseFrame(line);
if (!frame) return;
if (frame.type === "event" && typeof frame.event === "object" && frame.event !== null) {
const event = frame.event as Record<string, unknown>;
if (event.type === "message_end" && Array.isArray(event.content)) {
const textParts = event.content
.filter((part): part is { type: string; text?: unknown } => (
typeof part === "object" && part !== null && "type" in part
))
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => String(part.text));
lastMessageText = textParts.length > 0 ? textParts.join("") : undefined;
}
if (event.type === "turn_end") {
if (event.hadToolCalls === false && lastMessageText !== undefined) {
finalTurnText = lastMessageText;
}
if (typeof event.usage === "object" && event.usage !== null) {
const usage = event.usage as CommandUsage;
eventUsage.inputTokens += usage.inputTokens ?? 0;
eventUsage.outputTokens += usage.outputTokens ?? 0;
}
lastMessageText = undefined;
}
renderer.event(event);
} else if (frame.type === "result") {
result = frame as unknown as ResultFrame;
}
} catch (error) {
parseError = error instanceof Error ? error : new Error(String(error));
process.stderr.write(`\n无法解析 Command Code NDJSON${line.slice(0, 500)}\n`);
}
});
child.once("close", (code, closeSignal) => {
lines.close();
if (signal.aborted) {
renderer.fail("请求已取消");
finish(new CommandCodeError("Command Code request cancelled", code));
return;
}
if (code !== 0) {
const detail = stderr.trim().split("\n").slice(-3).join(" | ");
renderer.fail(`退出码 ${String(code)}${closeSignal ? `,信号 ${closeSignal}` : ""}${detail ? `${detail}` : ""}`);
finish(new CommandCodeError("Command Code request failed", code));
return;
}
if (!result) {
if (finalTurnText !== undefined) {
const durationMs = Date.now() - startedAt;
process.stderr.write("\n警告:Command Code 未输出最终 result 行,已使用最后一个完整结构化 turn 的文本。\n");
renderer.finish(durationMs);
finish(undefined, {
finalText: finalTurnText,
durationMs,
usage: eventUsage,
});
return;
}
renderer.fail(parseError ? `结构化事件解析失败:${parseError.message}` : "没有收到最终 result 行");
finish(new CommandCodeError("Unable to identify final Command Code response", code));
return;
}
if (result.subtype !== "success") {
renderer.fail(`结果状态 ${result.subtype}`);
finish(new CommandCodeError("Command Code request failed", code));
return;
}
renderer.finish(result.durationMs);
finish(undefined, {
finalText: result.finalText,
durationMs: result.durationMs,
...(result.usage ? { usage: result.usage } : {}),
});
});
child.stdin.on("error", (error: NodeJS.ErrnoException) => {
if (error.code !== "EPIPE") renderer.fail(`stdin 错误:${error.message}`);
});
child.stdin.end(prompt, "utf8");
});
}
function capture(executable: string, args: string[], timeoutMs = 10_000): Promise<{ code: number | null; stdout: string }> {
return new Promise((resolve) => {
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "ignore"] });
let stdout = "";
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
timer.unref();
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => { stdout += chunk; });
child.on("error", () => {
clearTimeout(timer);
resolve({ code: null, stdout: "" });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout });
});
});
}
export async function inspectInstallation(executable: string): Promise<InstallationStatus> {
const versionResult = await capture(executable, ["--version"]);
if (versionResult.code !== 0) return { installed: false, authenticated: false };
const statusResult = await capture(executable, ["status", "--json"]);
let authenticated = false;
try {
const status = JSON.parse(statusResult.stdout) as { authenticated?: boolean };
authenticated = statusResult.code === 0 && status.authenticated === true;
} catch {
authenticated = false;
}
return {
installed: true,
authenticated,
version: versionResult.stdout.trim(),
};
}
+43
View File
@@ -0,0 +1,43 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
import { z } from "zod";
const modelSchema = z.object({
cli_model: z.string().min(1),
effort: z.string().min(1),
});
const configSchema = z.object({
host: z.literal("127.0.0.1").default("127.0.0.1"),
port: z.number().int().min(1).max(65535).default(18000),
command_code_executable: z.string().min(1).default("command-code"),
command_code_working_directory: z.string().min(1).default("."),
timeout_seconds: z.number().int().positive().default(1800),
max_request_bytes: z.number().int().positive().default(20 * 1024 * 1024),
max_turns: z.number().int().positive().default(100),
permission_mode: z.enum(["default", "standard", "plan", "auto-accept", "dont-ask"]).default("auto-accept"),
dangerously_skip_permissions: z.boolean().default(false),
models: z.record(z.string().min(1), modelSchema).refine(
(models) => Object.keys(models).length > 0,
"At least one model mapping is required",
),
});
export type BridgeConfig = z.infer<typeof configSchema> & {
configDirectory: string;
resolvedWorkingDirectory: string;
};
export async function loadConfig(configPath: string): Promise<BridgeConfig> {
const absolutePath = path.resolve(configPath);
const source = await readFile(absolutePath, "utf8");
const parsed = configSchema.parse(YAML.parse(source));
const configDirectory = path.dirname(absolutePath);
return {
...parsed,
configDirectory,
resolvedWorkingDirectory: path.resolve(configDirectory, parsed.command_code_working_directory),
};
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
import path from "node:path";
import { loadConfig } from "./config.js";
import { createServer } from "./server.js";
function configPathFromArgs(args: string[]): string {
const index = args.indexOf("--config");
if (index === -1) return "config.yaml";
const value = args[index + 1];
if (!value) throw new Error("--config requires a file path");
return value;
}
async function main(): Promise<void> {
const configPath = path.resolve(configPathFromArgs(process.argv.slice(2)));
const config = await loadConfig(configPath);
const bridge = await createServer(config);
if (!bridge.installation.installed) {
process.stderr.write(`警告:找不到 ${config.command_code_executable}API 将返回 503。\n`);
} else if (!bridge.installation.authenticated) {
process.stderr.write("警告:Command Code 未登录。运行 command-code login 后重启服务。\n");
}
await bridge.app.listen({ host: config.host, port: config.port });
process.stdout.write(`Command Code OpenAI Bridge 已启动\n`);
process.stdout.write(`API: http://${config.host}:${config.port}/v1\n`);
process.stdout.write(`工作目录: ${config.resolvedWorkingDirectory}\n`);
process.stdout.write(`Command Code: ${bridge.installation.version ?? "unavailable"}\n`);
process.stdout.write(`权限模式: ${config.dangerously_skip_permissions ? "yolo" : config.permission_mode}\n`);
process.stdout.write("空闲时按 Ctrl+C 停止;请求运行中第一次 Ctrl+C 只取消当前请求。\n");
let shuttingDown = false;
const shutdown = async (signal: NodeJS.Signals) => {
if (bridge.abortActive()) {
process.stderr.write(`\n收到 ${signal},正在取消当前 Command Code 请求。\n`);
return;
}
if (shuttingDown) return;
shuttingDown = true;
process.stdout.write(`\n收到 ${signal},正在停止服务。\n`);
await bridge.app.close();
process.exitCode = 0;
};
process.on("SIGINT", () => { void shutdown("SIGINT"); });
process.on("SIGTERM", () => { void shutdown("SIGTERM"); });
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
});
+133
View File
@@ -0,0 +1,133 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
const textPartSchema = z.object({
type: z.literal("text"),
text: z.string(),
}).strict();
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.union([z.string(), z.array(textPartSchema)]),
}).strict();
export const chatCompletionRequestSchema = z.object({
model: z.string().min(1),
messages: z.array(messageSchema).min(1),
stream: z.boolean().optional().default(false),
stream_options: z.object({
include_usage: z.boolean().optional().default(false),
}).passthrough().optional(),
}).passthrough();
export type ChatCompletionRequest = z.infer<typeof chatCompletionRequestSchema>;
export interface CommandUsage {
inputTokens?: number;
outputTokens?: number;
}
export function normalizeMessages(request: ChatCompletionRequest) {
return request.messages.map((message) => ({
role: message.role,
content: typeof message.content === "string"
? message.content
: message.content.map((part) => part.text).join(""),
}));
}
export function buildCommandPrompt(request: ChatCompletionRequest): string {
const envelope = {
protocol: "openai-chat-completions-history-v1",
messages: normalizeMessages(request),
};
return [
"下面 JSON 对象的 messages 数组是外部客户端提交的完整任务。",
"严格执行 system 消息和最后一条 user 消息中的具体指令,并结合此前消息理解上下文。",
"调用方可能要求改写问题、生成检索词、提取数据、分类或输出特定格式。这些属于内部处理任务,也必须严格执行。",
"如果最后一条 user 消息要求改写、压缩、提取或输出指定格式,只返回要求的结果,不回答消息中包含的问题。",
"不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。",
"JSON 数据开始:",
JSON.stringify(envelope),
].join("\n\n");
}
export function completionResponse(model: string, content: string, usage?: CommandUsage) {
const promptTokens = usage?.inputTokens ?? 0;
const completionTokens = usage?.outputTokens ?? 0;
return {
id: `chatcmpl-local-${randomUUID().replaceAll("-", "")}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{
index: 0,
message: { role: "assistant", content },
finish_reason: "stop",
}],
usage: {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
},
};
}
export function completionStreamResponse(
model: string,
content: string,
usage?: CommandUsage,
includeUsage = false,
): string {
const id = `chatcmpl-local-${randomUUID().replaceAll("-", "")}`;
const created = Math.floor(Date.now() / 1000);
const base = { id, object: "chat.completion.chunk", created, model };
const chunks: object[] = [
{
...base,
choices: [{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null,
}],
},
{
...base,
choices: [{
index: 0,
delta: { content },
finish_reason: null,
}],
},
{
...base,
choices: [{
index: 0,
delta: {},
finish_reason: "stop",
}],
},
];
if (includeUsage) {
const promptTokens = usage?.inputTokens ?? 0;
const completionTokens = usage?.outputTokens ?? 0;
chunks.push({
...base,
choices: [],
usage: {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
},
});
}
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`;
}
export function openAIError(message: string, code: string, type = "invalid_request_error") {
return { error: { message, type, code } };
}
+87
View File
@@ -0,0 +1,87 @@
import { inspect } from "node:util";
const tty = process.stdout.isTTY;
const color = (code: number, text: string) => tty ? `\u001b[${code}m${text}\u001b[0m` : text;
const cyan = (text: string) => color(36, text);
const green = (text: string) => color(32, text);
const yellow = (text: string) => color(33, text);
const dim = (text: string) => color(2, text);
function details(event: Record<string, unknown>): string {
const copy = { ...event };
delete copy.type;
return inspect(copy, { colors: tty, depth: 8, compact: false, breakLength: 120 });
}
export class TerminalRenderer {
private answerStarted = false;
private thinkingShown = false;
begin(model: string, effort: string): void {
this.answerStarted = false;
this.thinkingShown = false;
process.stdout.write(`\n${cyan("━━━━━━━━ Command Code 请求 ━━━━━━━━")}\n`);
process.stdout.write(`${dim("模型")} ${model}\n`);
process.stdout.write(`${dim("思考深度")} ${effort}\n`);
}
event(event: Record<string, unknown>): void {
const type = typeof event.type === "string" ? event.type : "unknown";
switch (type) {
case "run_start":
process.stdout.write(`${dim("会话")} ${String(event.sessionId ?? "-")}\n`);
return;
case "turn_start":
process.stdout.write(`${cyan(`\n[Turn ${String(event.turnNumber ?? "?")}]`)}\n`);
return;
case "model_request_start":
process.stdout.write(`${dim("模型请求开始")} ${String(event.model ?? "")}\n`);
return;
case "thinking_start":
if (!this.thinkingShown) {
process.stdout.write(`${dim("思考中…")}\n`);
this.thinkingShown = true;
}
return;
case "thinking_delta":
case "thinking_end":
case "message_update":
case "model_trace":
case "run_end":
return;
case "text_delta": {
if (!this.answerStarted) {
process.stdout.write(`${green("\n最终回答:")}\n`);
this.answerStarted = true;
}
process.stdout.write(String(event.delta ?? ""));
return;
}
case "model_request_end":
process.stdout.write(`\n${dim(`模型请求结束 · ${String(event.stopReason ?? "unknown")}`)}\n`);
return;
case "turn_end":
process.stdout.write(`${dim(`Turn ${String(event.turnNumber ?? "?")} 结束`)}\n`);
return;
default:
if (type.includes("tool") || type.includes("permission") || type.includes("question")) {
process.stdout.write(`${yellow(`\n[${type}]`)}\n${details(event)}\n`);
}
}
}
stderr(chunk: string): void {
process.stderr.write(chunk);
}
finish(durationMs: number): void {
if (this.answerStarted) process.stdout.write("\n");
process.stdout.write(`${dim(`完成 · ${(durationMs / 1000).toFixed(2)}s`)}\n`);
process.stdout.write(`${cyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")}\n\n`);
}
fail(message: string): void {
process.stderr.write(`${color(31, `\nCommand Code 失败:${message}`)}\n`);
}
}
+188
View File
@@ -0,0 +1,188 @@
import Fastify, { type FastifyInstance } from "fastify";
import { ZodError } from "zod";
import type { BridgeConfig } from "./config.js";
import {
CommandCodeError,
inspectInstallation,
runCommandCode,
type InstallationStatus,
} from "./command-code.js";
import {
buildCommandPrompt,
chatCompletionRequestSchema,
completionResponse,
completionStreamResponse,
openAIError,
} from "./openai.js";
interface ActiveRequest {
abortController: AbortController;
}
export interface BridgeServer {
app: FastifyInstance;
installation: InstallationStatus;
abortActive: () => boolean;
}
export async function createServer(config: BridgeConfig): Promise<BridgeServer> {
const installation = await inspectInstallation(config.command_code_executable);
const app = Fastify({
logger: false,
bodyLimit: config.max_request_bytes,
requestTimeout: 0,
});
let active: ActiveRequest | undefined;
app.setErrorHandler((error, _request, reply) => {
const fastifyError = error as Error & { code?: string; statusCode?: number };
if (fastifyError.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
reply.status(413).send(openAIError(
`Request body exceeds max_request_bytes (${config.max_request_bytes})`,
"request_too_large",
));
return;
}
const errorText = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${errorText}\n`);
reply.status(fastifyError.statusCode ?? 500).send(openAIError(
"Internal bridge error",
"bridge_error",
"server_error",
));
});
app.get("/health", async () => ({
status: installation.installed && installation.authenticated ? "ok" : "degraded",
command_code: installation,
busy: active !== undefined,
}));
app.get("/v1/models", async () => ({
object: "list",
data: Object.keys(config.models).map((id) => ({
id,
object: "model",
created: 0,
owned_by: "command-code-local",
})),
}));
app.post("/v1/chat/completions", async (request, reply) => {
if (active) {
return reply.status(429).send(openAIError(
"Another Command Code request is already running",
"busy",
"server_error",
));
}
if (!installation.installed) {
return reply.status(503).send(openAIError(
"Command Code CLI is not installed",
"command_code_not_installed",
"server_error",
));
}
if (!installation.authenticated) {
return reply.status(503).send(openAIError(
"Command Code is not authenticated; run command-code login",
"command_code_not_authenticated",
"server_error",
));
}
let parsed;
try {
parsed = chatCompletionRequestSchema.parse(request.body);
} catch (error) {
const message = error instanceof ZodError
? error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
: "Invalid request body";
return reply.status(400).send(openAIError(message, "invalid_request"));
}
const model = config.models[parsed.model];
if (!model) {
return reply.status(404).send(openAIError(
`Model '${parsed.model}' is not configured`,
"model_not_found",
));
}
const abortController = new AbortController();
active = { abortController };
let responseCompleted = false;
const cancelOnDisconnect = () => {
if (!responseCompleted) abortController.abort("client disconnected");
};
request.raw.once("aborted", cancelOnDisconnect);
reply.raw.once("close", cancelOnDisconnect);
try {
const result = await runCommandCode(
config,
model.cli_model,
model.effort,
buildCommandPrompt(parsed),
abortController.signal,
);
responseCompleted = true;
if (parsed.stream) {
return reply
.type("text/event-stream; charset=utf-8")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.send(completionStreamResponse(
parsed.model,
result.finalText,
result.usage,
parsed.stream_options?.include_usage,
));
}
return reply.send(completionResponse(parsed.model, result.finalText, result.usage));
} catch (error) {
responseCompleted = true;
if (abortController.signal.aborted) {
if (!reply.raw.destroyed) {
return reply.status(499).send(openAIError(
"Command Code request cancelled",
"cancelled",
"server_error",
));
}
return;
}
const code = error instanceof CommandCodeError ? error.exitCode : undefined;
const status = code === 5 ? 429 : code === 10 ? 402 : 502;
const errorCode = code === 5
? "rate_limit_exceeded"
: code === 10
? "insufficient_credits"
: "command_code_error";
return reply.status(status).send(openAIError(
"Command Code request failed",
errorCode,
"server_error",
));
} finally {
request.raw.off("aborted", cancelOnDisconnect);
reply.raw.off("close", cancelOnDisconnect);
active = undefined;
}
});
return {
app,
installation,
abortActive: () => {
if (!active) return false;
active.abortController.abort("bridge interrupted");
return true;
},
};
}