工具使用
This commit is contained in:
+46
-1
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import type { FastifyReply } from "fastify";
|
||||
import { FinalTurnAccumulator } from "./final-turn.js";
|
||||
import type { CommandUsage } from "./openai.js";
|
||||
import type { ChatCompletionToolCall, CommandUsage } from "./openai.js";
|
||||
|
||||
export class ChatCompletionSseWriter {
|
||||
private readonly id = `chatcmpl-local-${randomUUID().replaceAll("-", "")}`;
|
||||
@@ -78,6 +78,51 @@ export class ChatCompletionSseWriter {
|
||||
this.writeDone();
|
||||
}
|
||||
|
||||
finishToolCalls(
|
||||
toolCalls: ChatCompletionToolCall[],
|
||||
usage?: CommandUsage,
|
||||
includeUsage = false,
|
||||
): void {
|
||||
this.closeThinking();
|
||||
for (const [index, toolCall] of toolCalls.entries()) {
|
||||
this.write({
|
||||
...this.base(),
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index,
|
||||
id: toolCall.id,
|
||||
type: toolCall.type,
|
||||
function: toolCall.function,
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
});
|
||||
}
|
||||
this.write({
|
||||
...this.base(),
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
||||
});
|
||||
|
||||
if (includeUsage) {
|
||||
const promptTokens = usage?.inputTokens ?? 0;
|
||||
const completionTokens = usage?.outputTokens ?? 0;
|
||||
this.write({
|
||||
...this.base(),
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: promptTokens + completionTokens,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.writeDone();
|
||||
}
|
||||
|
||||
error(error: object): void {
|
||||
this.closeThinking();
|
||||
this.write(error);
|
||||
|
||||
@@ -15,6 +15,7 @@ const configSchema = z.object({
|
||||
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_queue_size: z.number().int().min(0).max(1000).default(8),
|
||||
max_turns: z.number().int().positive().default(100),
|
||||
stream_thinking: z.boolean().default(false),
|
||||
response_store_directory: z.string().min(1).default(".command-code-openai-bridge/responses"),
|
||||
|
||||
+222
-10
@@ -6,11 +6,80 @@ const textPartSchema = z.object({
|
||||
text: z.string(),
|
||||
}).strict();
|
||||
|
||||
const messageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.union([z.string(), z.array(textPartSchema)]),
|
||||
const messageContentSchema = z.union([z.string(), z.array(textPartSchema)]);
|
||||
const messageNameSchema = z.string().min(1).max(64);
|
||||
const functionNameSchema = z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
const assistantToolCallSchema = z.object({
|
||||
id: z.string().min(1).max(128),
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: functionNameSchema,
|
||||
arguments: z.string(),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
|
||||
const systemMessageSchema = z.object({
|
||||
role: z.literal("system"),
|
||||
content: messageContentSchema,
|
||||
name: messageNameSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const developerMessageSchema = z.object({
|
||||
role: z.literal("developer"),
|
||||
content: messageContentSchema,
|
||||
name: messageNameSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const userMessageSchema = z.object({
|
||||
role: z.literal("user"),
|
||||
content: messageContentSchema,
|
||||
name: messageNameSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const assistantMessageSchema = z.object({
|
||||
role: z.literal("assistant"),
|
||||
content: messageContentSchema.nullable(),
|
||||
name: messageNameSchema.optional(),
|
||||
tool_calls: z.array(assistantToolCallSchema).min(1).optional(),
|
||||
}).strict();
|
||||
|
||||
const toolMessageSchema = z.object({
|
||||
role: z.literal("tool"),
|
||||
content: messageContentSchema,
|
||||
tool_call_id: z.string().min(1).max(128),
|
||||
name: functionNameSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const messageSchema = z.discriminatedUnion("role", [
|
||||
systemMessageSchema,
|
||||
developerMessageSchema,
|
||||
userMessageSchema,
|
||||
assistantMessageSchema,
|
||||
toolMessageSchema,
|
||||
]);
|
||||
|
||||
const functionToolSchema = z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: functionNameSchema,
|
||||
description: z.string().optional(),
|
||||
parameters: z.record(z.unknown()).optional().default({
|
||||
type: "object",
|
||||
properties: {},
|
||||
}),
|
||||
strict: z.boolean().optional(),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
|
||||
const toolChoiceSchema = z.union([
|
||||
z.enum(["none", "auto", "required"]),
|
||||
z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({ name: functionNameSchema }).strict(),
|
||||
}).strict(),
|
||||
]);
|
||||
|
||||
export const chatCompletionRequestSchema = z.object({
|
||||
model: z.string().min(1),
|
||||
messages: z.array(messageSchema).min(1),
|
||||
@@ -25,7 +94,41 @@ export const chatCompletionRequestSchema = z.object({
|
||||
frequency_penalty: z.number().min(-2).max(2).nullable().optional(),
|
||||
presence_penalty: z.number().min(-2).max(2).nullable().optional(),
|
||||
n: z.literal(1).nullable().optional(),
|
||||
}).strict();
|
||||
tools: z.array(functionToolSchema).min(1).max(128).optional(),
|
||||
tool_choice: toolChoiceSchema.optional(),
|
||||
parallel_tool_calls: z.boolean().optional(),
|
||||
}).strict().superRefine((request, context) => {
|
||||
const toolNames = new Set<string>();
|
||||
for (const [index, tool] of (request.tools ?? []).entries()) {
|
||||
if (toolNames.has(tool.function.name)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tools", index, "function", "name"],
|
||||
message: `Duplicate tool name '${tool.function.name}'`,
|
||||
});
|
||||
}
|
||||
toolNames.add(tool.function.name);
|
||||
}
|
||||
|
||||
if (request.tool_choice !== undefined && request.tools === undefined) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tool_choice"],
|
||||
message: "tool_choice requires tools",
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof request.tool_choice === "object") {
|
||||
const name = request.tool_choice.function.name;
|
||||
if (!toolNames.has(name)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tool_choice", "function", "name"],
|
||||
message: `Unknown forced tool '${name}'`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ChatCompletionRequest = z.infer<typeof chatCompletionRequestSchema>;
|
||||
|
||||
@@ -34,6 +137,15 @@ export interface CommandUsage {
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
export interface ChatCompletionToolCall {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ignoredCompatibilityParameters = [
|
||||
"temperature",
|
||||
"max_tokens",
|
||||
@@ -48,15 +160,46 @@ export function ignoredChatCompatibilityParameters(request: ChatCompletionReques
|
||||
}
|
||||
|
||||
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(""),
|
||||
}));
|
||||
return request.messages.map((message) => {
|
||||
const content = message.content === null
|
||||
? null
|
||||
: typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content.map((part) => part.text).join("");
|
||||
|
||||
if (message.role === "assistant") {
|
||||
return {
|
||||
role: message.role,
|
||||
content,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
...(message.tool_calls ? { tool_calls: message.tool_calls } : {}),
|
||||
};
|
||||
}
|
||||
if (message.role === "tool") {
|
||||
return {
|
||||
role: message.role,
|
||||
content,
|
||||
tool_call_id: message.tool_call_id,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: message.role,
|
||||
content,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function usesToolCalling(request: ChatCompletionRequest): boolean {
|
||||
return request.tools !== undefined || request.messages.some((message) => (
|
||||
message.role === "tool" || (message.role === "assistant" && message.tool_calls !== undefined)
|
||||
));
|
||||
}
|
||||
|
||||
export function buildCommandPrompt(request: ChatCompletionRequest): string {
|
||||
if (usesToolCalling(request)) return buildToolCommandPrompt(request);
|
||||
|
||||
const envelope = {
|
||||
protocol: "openai-chat-completions-history-v1",
|
||||
messages: normalizeMessages(request),
|
||||
@@ -74,6 +217,49 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string {
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function buildToolCommandPrompt(request: ChatCompletionRequest): string {
|
||||
const effectiveToolChoice = request.tool_choice ?? (request.tools ? "auto" : "none");
|
||||
const envelope = {
|
||||
protocol: "openai-chat-completions-tools-v1",
|
||||
messages: normalizeMessages(request),
|
||||
tools: request.tools ?? [],
|
||||
tool_choice: effectiveToolChoice,
|
||||
parallel_tool_calls: request.parallel_tool_calls ?? true,
|
||||
};
|
||||
|
||||
return [
|
||||
"下面 JSON 对象是外部客户端提交的一次 OpenAI Chat Completions 工具调用任务。",
|
||||
"你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。",
|
||||
"不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 messages、tools 和工具结果作出决定。",
|
||||
"严格执行 messages 中的 system、developer 和 user 指令,并结合 assistant.tool_calls 与 tool 消息理解完整历史。",
|
||||
"只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:",
|
||||
"最终回答:{\"type\":\"final\",\"content\":\"面向用户的完整回答\"}",
|
||||
"调用工具:{\"type\":\"tool_calls\",\"calls\":[{\"name\":\"工具名称\",\"arguments\":{}}]}",
|
||||
"调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象。",
|
||||
"tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。",
|
||||
"需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。",
|
||||
"JSON 数据开始:",
|
||||
JSON.stringify(envelope),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export function buildToolDecisionRepairPrompt(
|
||||
originalPrompt: string,
|
||||
invalidOutput: string,
|
||||
errors: string[],
|
||||
): string {
|
||||
return [
|
||||
"上一次输出不符合外部工具调用传输协议。保持原来的决策意图,只修复 JSON 结构、工具名称或参数。",
|
||||
"只返回原任务要求的 final 或 tool_calls JSON 对象,禁止 Markdown 代码围栏、前后说明和额外字段。",
|
||||
"校验错误:",
|
||||
errors.join("\n"),
|
||||
"上一次输出:",
|
||||
invalidOutput,
|
||||
"原始任务:",
|
||||
originalPrompt,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export function completionResponse(model: string, content: string, usage?: CommandUsage) {
|
||||
const promptTokens = usage?.inputTokens ?? 0;
|
||||
const completionTokens = usage?.outputTokens ?? 0;
|
||||
@@ -96,6 +282,32 @@ export function completionResponse(model: string, content: string, usage?: Comma
|
||||
};
|
||||
}
|
||||
|
||||
export function toolCallsCompletionResponse(
|
||||
model: string,
|
||||
toolCalls: ChatCompletionToolCall[],
|
||||
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: null, tool_calls: toolCalls },
|
||||
finish_reason: "tool_calls",
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: promptTokens + completionTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function openAIError(
|
||||
message: string,
|
||||
code: string,
|
||||
|
||||
@@ -1,18 +1,73 @@
|
||||
export interface RequestLease {
|
||||
controller: AbortController;
|
||||
ready: Promise<boolean>;
|
||||
}
|
||||
|
||||
interface QueuedRequest {
|
||||
controller: AbortController;
|
||||
resolve: (ready: boolean) => void;
|
||||
}
|
||||
|
||||
export class RequestCoordinator {
|
||||
private activeController: AbortController | undefined;
|
||||
private readonly queue: QueuedRequest[] = [];
|
||||
|
||||
constructor(private readonly maxQueueSize: number) {}
|
||||
|
||||
get busy(): boolean {
|
||||
return this.activeController !== undefined;
|
||||
}
|
||||
|
||||
begin(): AbortController | undefined {
|
||||
if (this.activeController) return undefined;
|
||||
this.activeController = new AbortController();
|
||||
return this.activeController;
|
||||
get queueLength(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
begin(): RequestLease | undefined {
|
||||
const controller = new AbortController();
|
||||
if (!this.activeController) {
|
||||
this.activeController = controller;
|
||||
return { controller, ready: Promise.resolve(true) };
|
||||
}
|
||||
if (this.queue.length >= this.maxQueueSize) return undefined;
|
||||
|
||||
let resolveReady: (ready: boolean) => void = () => undefined;
|
||||
const ready = new Promise<boolean>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
this.queue.push({ controller, resolve: resolveReady });
|
||||
return { controller, ready };
|
||||
}
|
||||
|
||||
cancel(controller: AbortController, reason = "request cancelled"): boolean {
|
||||
if (this.activeController === controller) {
|
||||
controller.abort(reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
const index = this.queue.findIndex((entry) => entry.controller === controller);
|
||||
if (index === -1) return false;
|
||||
const [entry] = this.queue.splice(index, 1);
|
||||
if (!entry) return false;
|
||||
entry.controller.abort(reason);
|
||||
entry.resolve(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
finish(controller: AbortController): void {
|
||||
if (this.activeController === controller) this.activeController = undefined;
|
||||
if (this.activeController !== controller) return;
|
||||
this.activeController = undefined;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const next = this.queue.shift();
|
||||
if (!next) return;
|
||||
if (next.controller.signal.aborted) {
|
||||
next.resolve(false);
|
||||
continue;
|
||||
}
|
||||
this.activeController = next.controller;
|
||||
next.resolve(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
abortActive(reason = "bridge interrupted"): boolean {
|
||||
|
||||
+19
-11
@@ -459,7 +459,6 @@ export async function registerResponseRoutes(
|
||||
});
|
||||
|
||||
app.post("/v1/responses", async (request, reply) => {
|
||||
if (coordinator.busy) return sendBusy(reply);
|
||||
if (!installation.installed) {
|
||||
return reply.status(503).send(openAIError(
|
||||
"Command Code CLI is not installed",
|
||||
@@ -494,12 +493,14 @@ export async function registerResponseRoutes(
|
||||
));
|
||||
}
|
||||
|
||||
const abortController = coordinator.begin();
|
||||
if (!abortController) return sendBusy(reply);
|
||||
const lease = coordinator.begin();
|
||||
if (!lease) return sendBusy(reply);
|
||||
const { controller: abortController } = lease;
|
||||
|
||||
let responseCompleted = false;
|
||||
let acquired = false;
|
||||
const cancelOnDisconnect = () => {
|
||||
if (!responseCompleted) abortController.abort("client disconnected");
|
||||
if (!responseCompleted) coordinator.cancel(abortController, "client disconnected");
|
||||
};
|
||||
request.raw.once("aborted", cancelOnDisconnect);
|
||||
reply.raw.once("close", cancelOnDisconnect);
|
||||
@@ -520,17 +521,23 @@ export async function registerResponseRoutes(
|
||||
|
||||
let writer: ResponsesSseWriter | undefined;
|
||||
try {
|
||||
if (parsed.stream) {
|
||||
writer = startSse(reply, messageId);
|
||||
writer.begin(initialResponse);
|
||||
}
|
||||
if (request.raw.aborted || reply.raw.destroyed) cancelOnDisconnect();
|
||||
acquired = await lease.ready;
|
||||
if (!acquired) {
|
||||
responseCompleted = true;
|
||||
return reply;
|
||||
}
|
||||
|
||||
const chain = parsed.previous_response_id
|
||||
? await store.loadChain(parsed.previous_response_id)
|
||||
: [];
|
||||
const prompt = buildResponsesPrompt(parsed, chain, inputItems);
|
||||
const deadline = Date.now() + config.timeout_seconds * 1000;
|
||||
|
||||
if (parsed.stream) {
|
||||
writer = startSse(reply, messageId);
|
||||
writer.begin(initialResponse);
|
||||
}
|
||||
|
||||
const execution = await executeResponse({
|
||||
config,
|
||||
cliModel: model.cli_model,
|
||||
@@ -609,6 +616,7 @@ export async function registerResponseRoutes(
|
||||
} finally {
|
||||
request.raw.off("aborted", cancelOnDisconnect);
|
||||
reply.raw.off("close", cancelOnDisconnect);
|
||||
if (!acquired) coordinator.cancel(abortController, "request ended before execution");
|
||||
coordinator.finish(abortController);
|
||||
}
|
||||
});
|
||||
@@ -1082,8 +1090,8 @@ function sendRouteError(reply: FastifyReply, error: unknown) {
|
||||
|
||||
function sendBusy(reply: FastifyReply) {
|
||||
return reply.status(429).send(openAIError(
|
||||
"Another Command Code request is already running",
|
||||
"busy",
|
||||
"Command Code request queue is full",
|
||||
"queue_full",
|
||||
"server_error",
|
||||
));
|
||||
}
|
||||
|
||||
+161
-23
@@ -6,17 +6,29 @@ import {
|
||||
CommandCodeError,
|
||||
inspectInstallation,
|
||||
runCommandCode,
|
||||
type CommandResult,
|
||||
type InstallationStatus,
|
||||
} from "./command-code.js";
|
||||
import {
|
||||
buildCommandPrompt,
|
||||
buildToolDecisionRepairPrompt,
|
||||
chatCompletionRequestSchema,
|
||||
completionResponse,
|
||||
ignoredChatCompatibilityParameters,
|
||||
openAIError,
|
||||
toolCallsCompletionResponse,
|
||||
usesToolCalling,
|
||||
type ChatCompletionRequest,
|
||||
type CommandUsage,
|
||||
} from "./openai.js";
|
||||
import { RequestCoordinator } from "./request-coordinator.js";
|
||||
import { registerResponseRoutes } from "./responses.js";
|
||||
import {
|
||||
parseToolDecision,
|
||||
ToolDecisionError,
|
||||
validateToolSchemas,
|
||||
type ToolDecision,
|
||||
} from "./tool-calling.js";
|
||||
|
||||
export interface BridgeServer {
|
||||
app: FastifyInstance;
|
||||
@@ -31,7 +43,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
bodyLimit: config.max_request_bytes,
|
||||
requestTimeout: 0,
|
||||
});
|
||||
const coordinator = new RequestCoordinator();
|
||||
const coordinator = new RequestCoordinator(config.max_queue_size);
|
||||
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
const requestedHeaders = request.headers["access-control-request-headers"];
|
||||
@@ -78,6 +90,8 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
status: installation.installed && installation.authenticated ? "ok" : "degraded",
|
||||
command_code: installation,
|
||||
busy: coordinator.busy,
|
||||
queue_length: coordinator.queueLength,
|
||||
queue_capacity: config.max_queue_size,
|
||||
}));
|
||||
|
||||
app.get("/v1/models", async () => ({
|
||||
@@ -91,14 +105,6 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
}));
|
||||
|
||||
app.post("/v1/chat/completions", async (request, reply) => {
|
||||
if (coordinator.busy) {
|
||||
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",
|
||||
@@ -146,6 +152,16 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
));
|
||||
}
|
||||
|
||||
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(",");
|
||||
@@ -155,18 +171,20 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
);
|
||||
}
|
||||
|
||||
const abortController = coordinator.begin();
|
||||
if (!abortController) {
|
||||
const lease = coordinator.begin();
|
||||
if (!lease) {
|
||||
return reply.status(429).send(openAIError(
|
||||
"Another Command Code request is already running",
|
||||
"busy",
|
||||
"Command Code request queue is full",
|
||||
"queue_full",
|
||||
"server_error",
|
||||
));
|
||||
}
|
||||
const { controller: abortController } = lease;
|
||||
let responseCompleted = false;
|
||||
let acquired = false;
|
||||
|
||||
const cancelOnDisconnect = () => {
|
||||
if (!responseCompleted) abortController.abort("client disconnected");
|
||||
if (!responseCompleted) coordinator.cancel(abortController, "client disconnected");
|
||||
};
|
||||
request.raw.once("aborted", cancelOnDisconnect);
|
||||
reply.raw.once("close", cancelOnDisconnect);
|
||||
@@ -174,24 +192,52 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
let writer: ChatCompletionSseWriter | undefined;
|
||||
try {
|
||||
if (parsed.stream) writer = startChatCompletionSse(reply, parsed.model, config.stream_thinking);
|
||||
const result = await runCommandCode(
|
||||
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,
|
||||
model.cli_model,
|
||||
model.effort,
|
||||
buildCommandPrompt(parsed),
|
||||
abortController.signal,
|
||||
writer ? { onEvent: (event) => writer?.commandEvent(event) } : {},
|
||||
);
|
||||
cliModel: model.cli_model,
|
||||
effort: model.effort,
|
||||
request: parsed,
|
||||
signal: abortController.signal,
|
||||
deadline: Date.now() + config.timeout_seconds * 1000,
|
||||
toolMode,
|
||||
...(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(
|
||||
result.finalText,
|
||||
finalText,
|
||||
result.usage,
|
||||
parsed.stream_options?.include_usage,
|
||||
);
|
||||
return reply;
|
||||
}
|
||||
return reply.send(completionResponse(parsed.model, result.finalText, result.usage));
|
||||
return reply.send(completionResponse(parsed.model, finalText, result.usage));
|
||||
} catch (error) {
|
||||
responseCompleted = true;
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -213,6 +259,23 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
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",
|
||||
));
|
||||
}
|
||||
|
||||
const code = error instanceof CommandCodeError ? error.exitCode : undefined;
|
||||
const status = code === 5 ? 429 : code === 10 ? 402 : 502;
|
||||
const errorCode = code === 5
|
||||
@@ -236,6 +299,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
} finally {
|
||||
request.raw.off("aborted", cancelOnDisconnect);
|
||||
reply.raw.off("close", cancelOnDisconnect);
|
||||
if (!acquired) coordinator.cancel(abortController, "request ended before execution");
|
||||
coordinator.finish(abortController);
|
||||
}
|
||||
});
|
||||
@@ -248,3 +312,77 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
||||
abortActive: () => coordinator.abortActive(),
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatExecution {
|
||||
result: CommandResult;
|
||||
decision?: ToolDecision;
|
||||
}
|
||||
|
||||
async function executeChatTurn(options: {
|
||||
config: BridgeConfig;
|
||||
cliModel: string;
|
||||
effort: string;
|
||||
request: ChatCompletionRequest;
|
||||
signal: AbortSignal;
|
||||
deadline: number;
|
||||
toolMode: boolean;
|
||||
writer?: ChatCompletionSseWriter;
|
||||
}): Promise<ChatExecution> {
|
||||
const originalPrompt = buildCommandPrompt(options.request);
|
||||
const first = await runCommandCode(
|
||||
options.config,
|
||||
options.cliModel,
|
||||
options.effort,
|
||||
originalPrompt,
|
||||
options.signal,
|
||||
{
|
||||
timeoutMs: options.deadline - Date.now(),
|
||||
...(!options.toolMode && options.writer
|
||||
? { onEvent: (event: Record<string, unknown>) => options.writer?.commandEvent(event) }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
if (!options.toolMode) return { result: first };
|
||||
|
||||
try {
|
||||
return {
|
||||
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() },
|
||||
);
|
||||
const usage = addUsage(first.usage, second.usage);
|
||||
const result: CommandResult = {
|
||||
finalText: second.finalText,
|
||||
durationMs: first.durationMs + second.durationMs,
|
||||
...(usage ? { usage } : {}),
|
||||
};
|
||||
return {
|
||||
result,
|
||||
decision: parseToolDecision(second.finalText, options.request),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined {
|
||||
if (!left && !right) return undefined;
|
||||
return {
|
||||
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
|
||||
outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Ajv, type ErrorObject, type ValidateFunction } from "ajv";
|
||||
import type {
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionToolCall,
|
||||
} from "./openai.js";
|
||||
|
||||
export type ToolDecision =
|
||||
| { type: "final"; content: string }
|
||||
| { type: "tool_calls"; toolCalls: ChatCompletionToolCall[] };
|
||||
|
||||
export class ToolDecisionError extends Error {
|
||||
constructor(readonly errors: string[]) {
|
||||
super(errors.join("; "));
|
||||
this.name = "ToolDecisionError";
|
||||
}
|
||||
}
|
||||
|
||||
export function validateToolSchemas(request: ChatCompletionRequest): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const [index, tool] of (request.tools ?? []).entries()) {
|
||||
try {
|
||||
createValidator(tool.function.parameters);
|
||||
} catch (error) {
|
||||
errors.push(`tools.${index}.function.parameters: ${formatUnknownError(error)}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function parseToolDecision(
|
||||
output: string,
|
||||
request: ChatCompletionRequest,
|
||||
): ToolDecision {
|
||||
const value = parseJsonValue(output);
|
||||
if (!isRecord(value)) {
|
||||
throw new ToolDecisionError(["Output must be a JSON object"]);
|
||||
}
|
||||
|
||||
if (value.type === "final") return parseFinalDecision(value, request);
|
||||
if (value.type === "tool_calls") return parseCallsDecision(value, request);
|
||||
throw new ToolDecisionError(["type must be 'final' or 'tool_calls'"]);
|
||||
}
|
||||
|
||||
function parseFinalDecision(
|
||||
value: Record<string, unknown>,
|
||||
request: ChatCompletionRequest,
|
||||
): ToolDecision {
|
||||
const errors: string[] = [];
|
||||
const extraKeys = Object.keys(value).filter((key) => key !== "type" && key !== "content");
|
||||
if (extraKeys.length > 0) errors.push(`Unexpected final fields: ${extraKeys.join(", ")}`);
|
||||
if (typeof value.content !== "string") errors.push("final.content must be a string");
|
||||
|
||||
const toolChoice = effectiveToolChoice(request);
|
||||
if (toolChoice === "required" || typeof toolChoice === "object") {
|
||||
errors.push("tool_choice requires a tool call");
|
||||
}
|
||||
if (errors.length > 0) throw new ToolDecisionError(errors);
|
||||
return { type: "final", content: value.content as string };
|
||||
}
|
||||
|
||||
function parseCallsDecision(
|
||||
value: Record<string, unknown>,
|
||||
request: ChatCompletionRequest,
|
||||
): ToolDecision {
|
||||
const errors: string[] = [];
|
||||
const extraKeys = Object.keys(value).filter((key) => key !== "type" && key !== "calls");
|
||||
if (extraKeys.length > 0) errors.push(`Unexpected tool_calls fields: ${extraKeys.join(", ")}`);
|
||||
if (!Array.isArray(value.calls) || value.calls.length === 0) {
|
||||
errors.push("tool_calls.calls must be a non-empty array");
|
||||
throw new ToolDecisionError(errors);
|
||||
}
|
||||
|
||||
const toolChoice = effectiveToolChoice(request);
|
||||
if (toolChoice === "none") errors.push("tool_choice is none");
|
||||
if (request.parallel_tool_calls === false && value.calls.length > 1) {
|
||||
errors.push("parallel_tool_calls is false, so calls may contain only one item");
|
||||
}
|
||||
|
||||
const tools = new Map((request.tools ?? []).map((tool) => [tool.function.name, tool]));
|
||||
const toolCalls: ChatCompletionToolCall[] = [];
|
||||
for (const [index, rawCall] of value.calls.entries()) {
|
||||
if (!isRecord(rawCall)) {
|
||||
errors.push(`calls.${index} must be an object`);
|
||||
continue;
|
||||
}
|
||||
const callExtraKeys = Object.keys(rawCall).filter((key) => key !== "name" && key !== "arguments");
|
||||
if (callExtraKeys.length > 0) {
|
||||
errors.push(`calls.${index} has unexpected fields: ${callExtraKeys.join(", ")}`);
|
||||
}
|
||||
if (typeof rawCall.name !== "string") {
|
||||
errors.push(`calls.${index}.name must be a string`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tool = tools.get(rawCall.name);
|
||||
if (!tool) {
|
||||
errors.push(`calls.${index}.name references unknown tool '${rawCall.name}'`);
|
||||
continue;
|
||||
}
|
||||
if (typeof toolChoice === "object" && rawCall.name !== toolChoice.function.name) {
|
||||
errors.push(`calls.${index}.name must be forced tool '${toolChoice.function.name}'`);
|
||||
}
|
||||
|
||||
const args = normalizeArguments(rawCall.arguments, index, errors);
|
||||
if (args === undefined) continue;
|
||||
|
||||
let validator: ValidateFunction;
|
||||
try {
|
||||
validator = createValidator(tool.function.parameters);
|
||||
} catch (error) {
|
||||
errors.push(`Invalid schema for tool '${rawCall.name}': ${formatUnknownError(error)}`);
|
||||
continue;
|
||||
}
|
||||
if (!validator(args)) {
|
||||
errors.push(...formatAjvErrors(rawCall.name, validator.errors));
|
||||
continue;
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: `call_${randomUUID().replaceAll("-", "")}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: rawCall.name,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.length > 0) throw new ToolDecisionError(errors);
|
||||
return { type: "tool_calls", toolCalls };
|
||||
}
|
||||
|
||||
function parseJsonValue(output: string): unknown {
|
||||
const trimmed = output.trim();
|
||||
const candidates = [trimmed];
|
||||
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
|
||||
if (fenced?.[1]) candidates.push(fenced[1]);
|
||||
const firstBrace = trimmed.indexOf("{");
|
||||
const lastBrace = trimmed.lastIndexOf("}");
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
for (const candidate of [...new Set(candidates)]) {
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw new ToolDecisionError([`Invalid JSON: ${formatUnknownError(lastError)}`]);
|
||||
}
|
||||
|
||||
function normalizeArguments(
|
||||
value: unknown,
|
||||
index: number,
|
||||
errors: string[],
|
||||
): Record<string, unknown> | undefined {
|
||||
let parsed = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (error) {
|
||||
errors.push(`calls.${index}.arguments is invalid JSON: ${formatUnknownError(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
errors.push(`calls.${index}.arguments must be an object`);
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function createValidator(schema: Record<string, unknown>): ValidateFunction {
|
||||
return new Ajv({ allErrors: true, strict: false }).compile(schema);
|
||||
}
|
||||
|
||||
function effectiveToolChoice(request: ChatCompletionRequest) {
|
||||
return request.tool_choice ?? (request.tools ? "auto" as const : "none" as const);
|
||||
}
|
||||
|
||||
function formatAjvErrors(toolName: string, errors: ErrorObject[] | null | undefined): string[] {
|
||||
if (!errors || errors.length === 0) return [`Arguments for '${toolName}' do not match its schema`];
|
||||
return errors.map((error) => (
|
||||
`Arguments for '${toolName}'${error.instancePath || "/"}: ${error.message ?? error.keyword}`
|
||||
));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatUnknownError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
Reference in New Issue
Block a user