Files
command-code-openai-bridge/src/server.ts
T
2026-08-12 16:03:25 +08:00

509 lines
16 KiB
TypeScript

import Fastify, { type FastifyInstance } from "fastify";
import { ZodError } from "zod";
import { startChatCompletionSse, type ChatCompletionSseWriter } from "./chat-stream.js";
import type { BridgeConfig } from "./config.js";
import {
CommandCodeError,
inspectInstallation,
runCommandCode,
type CommandResult,
type InstallationStatus,
} from "./command-code.js";
import {
buildCommandPrompt,
buildToolDecisionRepairPrompt,
buildToolStructuredOutputRepairPrompt,
chatResponseTextFormat,
chatCompletionRequestSchema,
completionResponse,
ignoredChatCompatibilityParameters,
openAIError,
toolCallsCompletionResponse,
usesChatStructuredOutput,
usesToolCalling,
type ChatCompletionRequest,
} from "./openai.js";
import { RequestCoordinator } from "./request-coordinator.js";
import {
addUsage,
buildRepairPrompt,
createStructuredValidation,
registerResponseRoutes,
type ResponseTextFormat,
type StructuredValidation,
} from "./responses.js";
import {
parseToolDecision,
ToolDecisionError,
validateToolSchemas,
type ToolDecision,
} from "./tool-calling.js";
export interface BridgeServer {
app: FastifyInstance;
installation: InstallationStatus;
abortActive: () => number;
}
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,
});
const coordinator = new RequestCoordinator(
config.max_concurrent_requests,
config.max_queue_size,
);
app.addHook("onRequest", async (request, reply) => {
const requestedHeaders = request.headers["access-control-request-headers"];
reply.raw.setHeader("Access-Control-Allow-Origin", "*");
reply.raw.setHeader(
"Access-Control-Allow-Headers",
requestedHeaders || "Content-Type, Authorization, dangerously-allow-browser",
);
reply.raw.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
reply.raw.setHeader("Access-Control-Max-Age", "600");
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Command-Code-Ignored-Parameters");
reply.raw.setHeader(
"Vary",
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
);
if (request.headers["access-control-request-private-network"] === "true") {
reply.raw.setHeader("Access-Control-Allow-Private-Network", "true");
}
});
app.options("/*", async (_request, reply) => reply.status(204).send());
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: coordinator.busy,
active_requests: coordinator.activeRequests,
max_concurrent_requests: config.max_concurrent_requests,
queue_length: coordinator.queueLength,
queue_capacity: config.max_queue_size,
}));
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 (!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: ChatCompletionRequest;
try {
parsed = chatCompletionRequestSchema.parse(request.body);
} catch (error) {
const unknown = error instanceof ZodError
? error.issues.find((issue) => issue.code === "unrecognized_keys")
: undefined;
if (unknown?.code === "unrecognized_keys") {
const key = unknown.keys[0] ?? "unknown";
const param = [...unknown.path, key].join(".");
return reply.status(400).send(openAIError(
`Unsupported parameter: '${param}'`,
"unsupported_parameter",
"invalid_request_error",
param,
));
}
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 responseFormat: ResponseTextFormat = chatResponseTextFormat(parsed);
let structuredValidation: StructuredValidation | undefined;
try {
structuredValidation = createStructuredValidation(responseFormat);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid JSON Schema";
return reply.status(400).send(openAIError(
message,
"invalid_json_schema",
"invalid_request_error",
"response_format.json_schema.schema",
));
}
const model = config.models[parsed.model];
if (!model) {
return reply.status(404).send(openAIError(
`Model '${parsed.model}' is not configured`,
"model_not_found",
));
}
const toolSchemaErrors = validateToolSchemas(parsed);
if (toolSchemaErrors.length > 0) {
return reply.status(400).send(openAIError(
toolSchemaErrors.join("; "),
"invalid_tool_schema",
"invalid_request_error",
"tools",
));
}
const ignoredParameters = ignoredChatCompatibilityParameters(parsed);
if (ignoredParameters.length > 0) {
const parameterList = ignoredParameters.join(",");
reply.raw.setHeader("X-Command-Code-Ignored-Parameters", parameterList);
process.stderr.write(
`警告:Chat Completions 参数 ${parameterList} 已为客户端兼容而接受;Command Code CLI 不支持映射,实际生成不会应用这些参数。\n`,
);
}
const lease = coordinator.begin();
if (!lease) {
return reply.status(429).send(openAIError(
"Command Code request queue is full",
"queue_full",
"server_error",
));
}
const { controller: abortController } = lease;
let responseCompleted = false;
let acquired = false;
const cancelOnDisconnect = () => {
if (!responseCompleted) coordinator.cancel(abortController, "client disconnected");
};
request.raw.once("aborted", cancelOnDisconnect);
reply.raw.once("close", cancelOnDisconnect);
let writer: ChatCompletionSseWriter | undefined;
try {
if (parsed.stream) {
writer = startChatCompletionSse(
reply,
parsed.model,
config.stream_thinking && !usesChatStructuredOutput(parsed),
);
}
if (request.raw.aborted || reply.raw.destroyed) cancelOnDisconnect();
acquired = await lease.ready;
if (!acquired) {
responseCompleted = true;
return reply;
}
const toolMode = usesToolCalling(parsed);
const execution = await executeChatTurn({
config,
cliModel: model.cli_model,
effort: model.effort,
request: parsed,
signal: abortController.signal,
deadline: Date.now() + config.timeout_seconds * 1000,
toolMode,
format: responseFormat,
...(structuredValidation ? { validation: structuredValidation } : {}),
...(writer ? { writer } : {}),
});
const { result, decision } = execution;
responseCompleted = true;
if (decision?.type === "tool_calls") {
if (writer) {
writer.finishToolCalls(
decision.toolCalls,
result.usage,
parsed.stream_options?.include_usage,
);
return reply;
}
return reply.send(toolCallsCompletionResponse(
parsed.model,
decision.toolCalls,
result.usage,
));
}
const finalText = decision?.type === "final" ? decision.content : result.finalText;
if (writer) {
writer.finish(
finalText,
result.usage,
parsed.stream_options?.include_usage,
);
return reply;
}
return reply.send(completionResponse(parsed.model, finalText, result.usage));
} catch (error) {
responseCompleted = true;
if (abortController.signal.aborted) {
if (writer) {
writer.error(openAIError(
"Command Code request cancelled",
"cancelled",
"server_error",
));
return reply;
}
if (!reply.raw.destroyed) {
return reply.status(499).send(openAIError(
"Command Code request cancelled",
"cancelled",
"server_error",
));
}
return;
}
if (error instanceof ToolDecisionError) {
process.stderr.write(`\n工具调用决策连续两次校验失败:${error.errors.join("; ")}\n`);
if (writer) {
writer.error(openAIError(
"Command Code returned an invalid tool decision",
"invalid_tool_decision",
"server_error",
));
return reply;
}
return reply.status(502).send(openAIError(
"Command Code returned an invalid tool decision",
"invalid_tool_decision",
"server_error",
));
}
if (error instanceof ChatStructuredOutputError) {
process.stderr.write(`\nChat 结构化输出连续两次校验失败:${error.errors.join("; ")}\n`);
const payload = openAIError(
"Command Code returned invalid structured output after one repair attempt",
"invalid_structured_output",
"server_error",
"response_format",
);
if (writer) {
writer.error(payload);
return reply;
}
return reply.status(502).send(payload);
}
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";
if (writer) {
writer.error(openAIError(
"Command Code request failed",
errorCode,
"server_error",
));
return reply;
}
return reply.status(status).send(openAIError(
"Command Code request failed",
errorCode,
"server_error",
));
} finally {
request.raw.off("aborted", cancelOnDisconnect);
reply.raw.off("close", cancelOnDisconnect);
if (!acquired) coordinator.cancel(abortController, "request ended before execution");
coordinator.finish(abortController);
}
});
await registerResponseRoutes(app, { config, installation, coordinator });
return {
app,
installation,
abortActive: () => coordinator.abortActive(),
};
}
interface ChatExecution {
result: CommandResult;
decision?: ToolDecision;
}
interface ChatTurnOptions {
config: BridgeConfig;
cliModel: string;
effort: string;
request: ChatCompletionRequest;
signal: AbortSignal;
deadline: number;
toolMode: boolean;
format: ResponseTextFormat;
validation?: StructuredValidation;
writer?: ChatCompletionSseWriter;
}
class ChatStructuredOutputError extends Error {
constructor(readonly errors: string[]) {
super(errors.join("; "));
this.name = "ChatStructuredOutputError";
}
}
async function executeChatTurn(options: ChatTurnOptions): Promise<ChatExecution> {
const originalPrompt = buildCommandPrompt(options.request);
const constrained = options.toolMode || options.validation !== undefined;
const first = await runCommandCode(
options.config,
options.cliModel,
options.effort,
originalPrompt,
options.signal,
{
timeoutMs: options.deadline - Date.now(),
...(!constrained && options.writer
? { onEvent: (event: Record<string, unknown>) => options.writer?.commandEvent(event) }
: {}),
},
);
if (!options.toolMode) {
if (!options.validation) return { result: first };
const firstValidation = options.validation.validate(first.finalText);
if (firstValidation.valid) return { result: first };
process.stderr.write(`\nChat 结构化输出校验失败,正在修复:${firstValidation.errors.join("; ")}\n`);
const second = await runCommandCode(
options.config,
options.cliModel,
options.effort,
buildRepairPrompt(
originalPrompt,
options.format,
first.finalText,
firstValidation.errors,
),
options.signal,
{ timeoutMs: options.deadline - Date.now() },
);
const secondValidation = options.validation.validate(second.finalText);
if (!secondValidation.valid) throw new ChatStructuredOutputError(secondValidation.errors);
return { result: combineCommandResults(first, second) };
}
let execution: ChatExecution;
try {
execution = {
result: first,
decision: parseToolDecision(first.finalText, options.request),
};
} catch (error) {
if (!(error instanceof ToolDecisionError)) throw error;
process.stderr.write(`\n工具调用决策校验失败,正在修复:${error.errors.join("; ")}\n`);
const repairPrompt = buildToolDecisionRepairPrompt(
originalPrompt,
first.finalText,
error.errors,
);
const second = await runCommandCode(
options.config,
options.cliModel,
options.effort,
repairPrompt,
options.signal,
{ timeoutMs: options.deadline - Date.now() },
);
execution = {
result: combineCommandResults(first, second),
decision: parseToolDecision(second.finalText, options.request),
};
}
if (execution.decision?.type !== "final" || !options.validation) return execution;
const firstValidation = options.validation.validate(execution.decision.content);
if (firstValidation.valid) return execution;
process.stderr.write(`\nChat 最终工具正文结构化校验失败,正在修复:${firstValidation.errors.join("; ")}\n`);
const repair = await runCommandCode(
options.config,
options.cliModel,
options.effort,
buildToolStructuredOutputRepairPrompt(
originalPrompt,
options.request,
execution.decision.content,
firstValidation.errors,
),
options.signal,
{ timeoutMs: options.deadline - Date.now() },
);
const result = combineCommandResults(execution.result, repair);
let decision: ToolDecision;
try {
decision = parseToolDecision(repair.finalText, options.request);
} catch (error) {
if (error instanceof ToolDecisionError) {
throw new ChatStructuredOutputError([
"Structured output repair did not return a valid final decision",
...error.errors,
]);
}
throw error;
}
if (decision.type !== "final") {
throw new ChatStructuredOutputError(["Structured output repair returned tool_calls instead of final"]);
}
const repairedValidation = options.validation.validate(decision.content);
if (!repairedValidation.valid) throw new ChatStructuredOutputError(repairedValidation.errors);
return { result, decision };
}
function combineCommandResults(left: CommandResult, right: CommandResult): CommandResult {
const usage = addUsage(left.usage, right.usage);
return {
finalText: right.finalText,
durationMs: left.durationMs + right.durationMs,
...(usage ? { usage } : {}),
};
}