工具使用

This commit is contained in:
Sirius
2026-08-06 14:14:09 +08:00
parent d5eb882dfe
commit e236721db1
11 changed files with 745 additions and 73 deletions
+46 -1
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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,
+60 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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),
};
}
+198
View File
@@ -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);
}