init
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 } };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user