509 lines
16 KiB
TypeScript
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 } : {}),
|
|
};
|
|
}
|