支持responses
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Command Code OpenAI Bridge
|
||||
|
||||
这是一个只绑定本机地址的 OpenAI 兼容转发服务。它实现 Chat Completions 文本与 function calling,并提供 Responses API 文本子集。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。
|
||||
这是一个只绑定本机地址的 OpenAI 兼容转发服务。它实现 Chat Completions 与 Responses API 的文本对话和外部 function calling。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。
|
||||
|
||||
默认地址:`http://127.0.0.1:18000/v1`
|
||||
|
||||
@@ -162,7 +162,7 @@ Chat 接口接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`
|
||||
|
||||
为兼容 Obsidian Copilot 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature`、`max_tokens`、`max_completion_tokens`、`top_p`、`frequency_penalty`、`presence_penalty` 和 `n: 1`。这些采样和输出限制参数会按 OpenAI 取值范围严格校验。Command Code CLI 1.12.0 没有对应的 headless 参数,因此 Bridge 不会把它们伪装成已生效:服务终端会输出警告,HTTP 响应带 `X-Command-Code-Ignored-Parameters`。其他未实现字段仍返回 `400 unsupported_parameter`。
|
||||
|
||||
Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 模式会缓存 `finalText`,避免内部 JSON 决策进入客户端正文。
|
||||
Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 模式会缓存 `finalText`,避免内部 JSON 决策进入客户端正文。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。
|
||||
|
||||
### Responses
|
||||
|
||||
@@ -177,32 +177,43 @@ Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终
|
||||
- `metadata`
|
||||
- `reasoning.effort`
|
||||
- `text.format`
|
||||
- `tools`
|
||||
- `tool_choice`
|
||||
- `parallel_tool_calls`
|
||||
|
||||
`input` 可以是字符串,也可以是 message item 数组。message 支持 `system`、`developer`、`user`、`assistant` 角色、字符串 content,以及 `input_text`、`output_text` part。
|
||||
`input` 可以是字符串,也可以是 item 数组。message 支持 `system`、`developer`、`user`、`assistant` 角色、字符串 content,以及 `input_text`、`output_text` part。工具轮次还支持 `function_call` 和 `function_call_output` item,分别携带 `call_id`、工具名、`arguments` 和工具执行结果 `output`。
|
||||
|
||||
普通响应包含 `id`、`object`、时间、状态、错误、instructions、model、标准 assistant message output item、`output_text`、响应链 ID、effective reasoning effort、store、metadata、text format 和 usage。usage 只使用 Command Code 实际提供的 input/output token;没有 usage 时返回 `null`,不生成 token 明细。
|
||||
Responses 接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`。Bridge 把工具定义、完整历史(含先前 response 的完整 output items)和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不会让 Command Code 用自身文件、终端、网络等工具替代外部 tools。该流程适用于 OpenCode 等使用 `@ai-sdk/openai` 或 OpenAI Node.js SDK 的 Responses 客户端。
|
||||
|
||||
`stream: true` 会立即建立 SSE 连接并发送:
|
||||
普通文本响应包含标准 assistant message output item 和 `output_text`。工具调用轮次返回 `function_call` output item,包含 `id`、`call_id`、`name`、`arguments` 和 `status`,`output_text` 为空。最终文本轮次在有足够 `function_call_output` 后返回 message。响应还包含 `id`、`object`、时间、状态、错误、instructions、model、响应链 ID、effective reasoning effort、store、metadata、text format、tools、tool_choice、parallel_tool_calls 和 usage。usage 只使用 Command Code 实际提供的 input/output token;没有 usage 时返回 `null`,不生成 token 明细。
|
||||
|
||||
1. `response.created`
|
||||
2. `response.in_progress`
|
||||
3. `response.output_item.added`
|
||||
4. `response.content_part.added`
|
||||
5. 一个或多个 `response.output_text.delta`
|
||||
6. `response.output_text.done`
|
||||
7. `response.content_part.done`
|
||||
8. `response.output_item.done`
|
||||
9. `response.completed` 或 `response.incomplete`
|
||||
`stream: true` 会立即建立 SSE 连接并发送 `response.created` 和 `response.in_progress`。文本响应随后发送:
|
||||
|
||||
1. `response.output_item.added`
|
||||
2. `response.content_part.added`
|
||||
3. 一个或多个 `response.output_text.delta`
|
||||
4. `response.output_text.done`
|
||||
5. `response.content_part.done`
|
||||
6. `response.output_item.done`
|
||||
7. `response.completed` 或 `response.incomplete`
|
||||
|
||||
工具调用轮次发送:
|
||||
|
||||
1. `response.output_item.added`
|
||||
2. `response.function_call_arguments.delta`
|
||||
3. `response.function_call_arguments.done`
|
||||
4. `response.output_item.done`
|
||||
5. `response.completed`
|
||||
|
||||
所有事件都包含递增的 `sequence_number`。连接使用 `no-cache, no-transform`,收到客户端断开后会终止 Command Code 子进程组。
|
||||
|
||||
Responses API 仍按 turn 缓冲文本,丢弃 `hadToolCalls: true` 的中间轮次,在 `hadToolCalls: false` 时按原 delta 边界发送。文本 delta 会延迟到最终 turn 边界。结构化输出还会延迟到 JSON 校验完成,避免把随后需要修复的无效 JSON 发给客户端。
|
||||
Responses API 仍按 turn 缓冲文本,丢弃 `hadToolCalls: true` 的中间轮次,在 `hadToolCalls: false` 时按原 delta 边界发送。文本 delta 会延迟到最终 turn 边界。外部 function calling 和结构化输出还会延迟到 JSON 校验完成,避免把随后需要修复的无效 JSON 或内部决策发给客户端。
|
||||
|
||||
### 本地存储和响应链
|
||||
|
||||
`store` 默认是 `true`。完成、incomplete、failed 和 cancelled Response 会写入配置的本地目录;`store: false` 不写入当前 Response,因此它不能在后续作为 `previous_response_id` 使用。
|
||||
|
||||
收到 `previous_response_id` 时,Bridge 从本地文件沿链向前读取每个 Response,按原顺序重放历史 input 和 assistant output,再追加当前 input。可以从任意仍存在的旧 Response 创建分支。删除某个祖先后,依赖该祖先的链会返回 404。Response ID 使用固定本地格式并在拼接文件路径前校验。
|
||||
收到 `previous_response_id` 时,Bridge 从本地文件沿链向前读取每个 Response,按原顺序重放历史 input 和完整 assistant output items(含 `function_call` 与 message,不仅重放 `output_text`),再追加当前 input。可以从任意仍存在的旧 Response 创建分支。删除某个祖先后,依赖该祖先的链会返回 404。Response ID 使用固定本地格式并在拼接文件路径前校验。
|
||||
|
||||
input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_more }`,支持 `after`、`limit` 和 `order`。删除成功返回 `{ id, object: "response", deleted: true }`。
|
||||
|
||||
@@ -304,7 +315,7 @@ command-code \
|
||||
|
||||
## 本机实际检查结果
|
||||
|
||||
检查日期:Chat Completions 原有检查为 2026-08-04;Responses 新增检查为 2026-08-05;function calling 协议检查为 2026-08-06。没有编写测试用例。原有条目来自真实 CLI 和真实 HTTP/SDK 客户端;有限队列改动完成了类型检查、生产构建和协调器运行时冒烟检查,尚未重新运行真实 CLI 并发检查。function calling 完成了内部协议冒烟检查、Obsidian Copilot 当前依赖 `@langchain/openai 1.2.2` 的双轮 wire compatibility 检查,以及真实 Command Code 与 OpenAI Node.js SDK 的双轮 HTTP/SSE 调用;尚未在 Obsidian UI 中运行完整检查。
|
||||
检查日期:Chat Completions 原有检查为 2026-08-04;Responses 文本子集检查为 2026-08-05;Chat function calling 协议检查为 2026-08-06;Responses function calling 检查为 2026-08-12。没有编写测试用例。原有条目来自真实 CLI 和真实 HTTP/SDK 客户端;有限队列改动完成了类型检查、生产构建和协调器运行时冒烟检查,尚未重新运行真实 CLI 并发检查。Chat function calling 完成了内部协议冒烟检查、Obsidian Copilot 当前依赖 `@langchain/openai 1.2.2` 的双轮 wire compatibility 检查,以及真实 Command Code 与 OpenAI Node.js SDK 的双轮 HTTP/SSE 调用;尚未在 Obsidian UI 中运行完整检查。Responses function calling 完成了类型检查、生产构建、真实 HTTP 双轮检查、流式 function call SSE 检查和 OpenAI Node.js SDK 双轮检查。
|
||||
|
||||
- TypeScript 严格类型检查和生产构建通过。
|
||||
- npm 生产依赖审计:0 个已知漏洞。
|
||||
@@ -337,6 +348,10 @@ command-code \
|
||||
- `input_image` 返回 400 `unsupported_parameter`,并包含准确的参数路径。
|
||||
- 真实 `max_turns: 1` 工具请求返回 200 `incomplete/max_turns`;1 秒总超时返回 200 `incomplete/timeout`;主动取消返回 499 `cancelled`。
|
||||
- 取消后紧接着的旧 Chat Completions 请求返回 `Chat恢复成功`,证明共享执行状态和错误恢复正常。
|
||||
- Responses 第一轮 `tool_choice: required` 返回 `function_call`,`output_text` 为空;第二轮提交 `function_call_output` 和 `previous_response_id` 后返回最终 message 文本。
|
||||
- Responses 流式 function call 发送 `output_item.added`、`function_call_arguments.delta/done`、`output_item.done` 和 `completed`,无 `output_text.delta`。
|
||||
- OpenAI Node.js SDK 的 Responses 双轮检查:第一轮返回 `multiply` function call,第二轮消费 `function_call_output` 后返回最终文本。
|
||||
- Responses 无效工具名、无效 arguments JSON、孤立 `function_call_output` 和 hosted tool 类型分别返回带 `param` 的 400 错误。
|
||||
|
||||
## 已知限制
|
||||
|
||||
@@ -344,8 +359,8 @@ command-code \
|
||||
- headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。
|
||||
- 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。
|
||||
- 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 和 Responses 结构化输出需要等待 Bridge 校验完成。
|
||||
- Chat Completions 只实现 function tools,不实现图片、音频、文件输入、Computer Use 或托管工具。Responses 仍是文本子集,不实现 function calling、原生 reasoning item、加密 reasoning 或隐藏思维过程。
|
||||
- Chat function calling 是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision`。
|
||||
- Chat Completions 和 Responses 都只实现外部 function tools,不实现图片、音频、文件输入、Computer Use、web search 等 hosted tools 或 MCP hosted tool。非 function 工具类型返回 400 `unsupported_parameter`。Responses 不实现原生 reasoning item、加密 reasoning 或隐藏思维过程。
|
||||
- Chat 与 Responses 的外部 function calling 都是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision`。
|
||||
- Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁、队列或多实例一致性保证。项目本身仍严格单并发。
|
||||
- Responses Structured Outputs 是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;此时状态为 incomplete。
|
||||
- `usage` 使用 Command Code 最终结果提供的真实 input/output token;Responses 缺失时返回 `null`,Chat Completions 为兼容旧行为返回 0。
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ dangerously_skip_permissions: true
|
||||
|
||||
models:
|
||||
command-default:
|
||||
cli_model: gpt-5.6-luna
|
||||
cli_model: deepseek/deepseek-v4-flash
|
||||
effort: max
|
||||
command-flash:
|
||||
cli_model: deepseek/deepseek-v4-flash
|
||||
|
||||
+507
-39
@@ -15,10 +15,23 @@ import type { BridgeConfig } from "./config.js";
|
||||
import { FinalTurnAccumulator } from "./final-turn.js";
|
||||
import { openAIError, type CommandUsage } from "./openai.js";
|
||||
import type { RequestCoordinator } from "./request-coordinator.js";
|
||||
import {
|
||||
parseFunctionToolDecision,
|
||||
parseFunctionToolArguments,
|
||||
ToolDecisionError,
|
||||
validateFunctionToolArguments,
|
||||
validateFunctionToolSchemas,
|
||||
type FunctionToolCall,
|
||||
type FunctionToolChoice,
|
||||
type FunctionToolDecisionRequest,
|
||||
type FunctionToolDefinition,
|
||||
} from "./tool-calling.js";
|
||||
|
||||
const responseIdPattern = /^resp_[A-Za-z0-9_-]{1,128}$/;
|
||||
const responseIdSchema = z.string().regex(responseIdPattern, "Invalid response ID");
|
||||
const roleSchema = z.enum(["system", "developer", "user", "assistant"]);
|
||||
const functionNameSchema = z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/);
|
||||
const responseItemIdSchema = z.string().min(1).max(128);
|
||||
|
||||
const inputTextPartSchema = z.object({
|
||||
type: z.literal("input_text"),
|
||||
@@ -39,6 +52,42 @@ const inputMessageSchema = z.object({
|
||||
]),
|
||||
}).strict();
|
||||
|
||||
const inputFunctionCallSchema = z.object({
|
||||
id: responseItemIdSchema.optional(),
|
||||
type: z.literal("function_call"),
|
||||
call_id: responseItemIdSchema,
|
||||
name: functionNameSchema,
|
||||
arguments: z.string(),
|
||||
status: z.enum(["in_progress", "completed", "incomplete"]).optional(),
|
||||
}).strict();
|
||||
|
||||
const inputFunctionCallOutputSchema = z.object({
|
||||
id: responseItemIdSchema.optional(),
|
||||
type: z.literal("function_call_output"),
|
||||
call_id: responseItemIdSchema,
|
||||
output: z.string(),
|
||||
status: z.enum(["in_progress", "completed", "incomplete"]).optional(),
|
||||
}).strict();
|
||||
|
||||
const responseInputItemSchema = z.union([
|
||||
inputMessageSchema,
|
||||
inputFunctionCallSchema,
|
||||
inputFunctionCallOutputSchema,
|
||||
]);
|
||||
|
||||
const responseFunctionToolSchema = z.object({
|
||||
type: z.literal("function"),
|
||||
name: functionNameSchema,
|
||||
description: z.string().nullable().optional(),
|
||||
parameters: z.union([z.record(z.unknown()), z.null()]).optional(),
|
||||
strict: z.boolean().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const responseToolChoiceSchema = z.union([
|
||||
z.enum(["none", "auto", "required"]),
|
||||
z.object({ type: z.literal("function"), name: functionNameSchema }).strict(),
|
||||
]);
|
||||
|
||||
const textFormatSchema = z.object({
|
||||
type: z.literal("text"),
|
||||
}).strict();
|
||||
@@ -74,7 +123,7 @@ const metadataSchema = z.record(z.string().max(512)).superRefine((metadata, cont
|
||||
|
||||
export const responseRequestSchema = z.object({
|
||||
model: z.string().min(1),
|
||||
input: z.union([z.string(), z.array(inputMessageSchema).min(1)]),
|
||||
input: z.union([z.string(), z.array(responseInputItemSchema).min(1)]),
|
||||
instructions: z.string().nullable().optional().default(null),
|
||||
stream: z.boolean().optional().default(false),
|
||||
store: z.boolean().optional().default(true),
|
||||
@@ -86,7 +135,38 @@ export const responseRequestSchema = z.object({
|
||||
text: z.object({
|
||||
format: responseTextFormatSchema,
|
||||
}).strict().optional().default({ format: { type: "text" } }),
|
||||
}).strict();
|
||||
tools: z.array(responseFunctionToolSchema).min(1).max(128).optional(),
|
||||
tool_choice: responseToolChoiceSchema.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.name)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tools", index, "name"],
|
||||
message: `Duplicate tool name '${tool.name}'`,
|
||||
});
|
||||
}
|
||||
toolNames.add(tool.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" && !toolNames.has(request.tool_choice.name)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tool_choice", "name"],
|
||||
message: `Unknown forced tool '${request.tool_choice.name}'`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type ResponseRequest = z.infer<typeof responseRequestSchema>;
|
||||
export type ResponseTextFormat = z.infer<typeof responseTextFormatSchema>;
|
||||
@@ -98,13 +178,32 @@ interface ResponseInputPart {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ResponseInputItem {
|
||||
interface ResponseInputMessage {
|
||||
id: string;
|
||||
type: "message";
|
||||
role: ResponseRole;
|
||||
content: ResponseInputPart[];
|
||||
}
|
||||
|
||||
interface ResponseInputFunctionCall {
|
||||
id: string;
|
||||
type: "function_call";
|
||||
call_id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
interface ResponseInputFunctionCallOutput {
|
||||
id: string;
|
||||
type: "function_call_output";
|
||||
call_id: string;
|
||||
output: string;
|
||||
status: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
type ResponseInputItem = ResponseInputMessage | ResponseInputFunctionCall | ResponseInputFunctionCallOutput;
|
||||
|
||||
interface ResponseOutputText {
|
||||
type: "output_text";
|
||||
text: string;
|
||||
@@ -119,6 +218,17 @@ interface ResponseOutputMessage {
|
||||
content: ResponseOutputText[];
|
||||
}
|
||||
|
||||
interface ResponseOutputFunctionCall {
|
||||
id: string;
|
||||
type: "function_call";
|
||||
call_id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
status: "completed" | "incomplete";
|
||||
}
|
||||
|
||||
type ResponseOutputItem = ResponseOutputMessage | ResponseOutputFunctionCall;
|
||||
|
||||
interface ResponseUsage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -140,26 +250,34 @@ interface LocalResponse {
|
||||
incomplete_details: { reason: string } | null;
|
||||
instructions: string | null;
|
||||
model: string;
|
||||
output: ResponseOutputMessage[];
|
||||
output: ResponseOutputItem[];
|
||||
output_text: string;
|
||||
previous_response_id: string | null;
|
||||
reasoning: { effort: string | null; summary: null };
|
||||
store: boolean;
|
||||
metadata: Record<string, string>;
|
||||
text: { format: ResponseTextFormat };
|
||||
tools: ResponseFunctionTool[];
|
||||
tool_choice: ResponseToolChoice;
|
||||
parallel_tool_calls: boolean;
|
||||
usage: ResponseUsage | null;
|
||||
}
|
||||
|
||||
interface ResponseFunctionTool {
|
||||
type: "function";
|
||||
name: string;
|
||||
description?: string | null;
|
||||
parameters: Record<string, unknown>;
|
||||
strict: boolean | null;
|
||||
}
|
||||
|
||||
type ResponseToolChoice = "none" | "auto" | "required" | { type: "function"; name: string };
|
||||
|
||||
interface StoredResponse {
|
||||
response: LocalResponse;
|
||||
input_items: ResponseInputItem[];
|
||||
}
|
||||
|
||||
interface PromptMessage {
|
||||
role: ResponseRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface StructuredValidation {
|
||||
validate: (text: string) => { valid: boolean; errors: string[] };
|
||||
}
|
||||
@@ -174,6 +292,7 @@ interface ExecutionResult {
|
||||
chunks: string[];
|
||||
usage?: CommandUsage;
|
||||
validationErrors?: string[];
|
||||
toolCalls?: FunctionToolCall[];
|
||||
}
|
||||
|
||||
class ResponseApiError extends Error {
|
||||
@@ -347,10 +466,17 @@ class ResponsesSseWriter {
|
||||
}
|
||||
|
||||
finish(response: LocalResponse, terminalEvent: "response.completed" | "response.incomplete"): void {
|
||||
if (response.output.some((item) => item.type === "function_call")) {
|
||||
this.addToolCalls(response);
|
||||
this.event(terminalEvent, { response });
|
||||
return;
|
||||
}
|
||||
this.ensureFinalText(response.output_text);
|
||||
if (this.outputStarted) {
|
||||
const outputMessage = response.output[0];
|
||||
if (!outputMessage) throw new Error("Response output message is missing");
|
||||
if (!outputMessage || outputMessage.type !== "message") {
|
||||
throw new Error("Response output message is missing");
|
||||
}
|
||||
const part = outputMessage.content[0];
|
||||
if (!part) throw new Error("Response output text part is missing");
|
||||
|
||||
@@ -371,6 +497,27 @@ class ResponsesSseWriter {
|
||||
this.event(terminalEvent, { response });
|
||||
}
|
||||
|
||||
private addToolCalls(response: LocalResponse): void {
|
||||
for (const [outputIndex, item] of response.output.entries()) {
|
||||
if (item.type !== "function_call") continue;
|
||||
const addedItem = { ...item, arguments: "", status: "in_progress" as const };
|
||||
this.event("response.output_item.added", { output_index: outputIndex, item: addedItem });
|
||||
if (item.arguments !== "") {
|
||||
this.event("response.function_call_arguments.delta", {
|
||||
item_id: item.id,
|
||||
output_index: outputIndex,
|
||||
delta: item.arguments,
|
||||
});
|
||||
}
|
||||
this.event("response.function_call_arguments.done", {
|
||||
item_id: item.id,
|
||||
output_index: outputIndex,
|
||||
arguments: item.arguments,
|
||||
});
|
||||
this.event("response.output_item.done", { output_index: outputIndex, item });
|
||||
}
|
||||
}
|
||||
|
||||
failed(response: LocalResponse): void {
|
||||
this.event("response.failed", { response });
|
||||
}
|
||||
@@ -479,6 +626,7 @@ export async function registerResponseRoutes(
|
||||
try {
|
||||
parsed = parseResponseRequest(request.body);
|
||||
validation = createStructuredValidation(parsed.text.format);
|
||||
validateResponseToolRequest(parsed);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
@@ -535,8 +683,10 @@ export async function registerResponseRoutes(
|
||||
const chain = parsed.previous_response_id
|
||||
? await store.loadChain(parsed.previous_response_id)
|
||||
: [];
|
||||
validateResponseFunctionHistory(parsed, chain, inputItems);
|
||||
const prompt = buildResponsesPrompt(parsed, chain, inputItems);
|
||||
const deadline = Date.now() + config.timeout_seconds * 1000;
|
||||
const toolRequest = responseFunctionToolDecisionRequest(parsed);
|
||||
|
||||
const execution = await executeResponse({
|
||||
config,
|
||||
@@ -544,6 +694,7 @@ export async function registerResponseRoutes(
|
||||
effort: effectiveEffort,
|
||||
prompt,
|
||||
format: parsed.text.format,
|
||||
...(toolRequest ? { toolRequest } : {}),
|
||||
signal: abortController.signal,
|
||||
deadline,
|
||||
...(validation ? { validation } : {}),
|
||||
@@ -558,6 +709,7 @@ export async function registerResponseRoutes(
|
||||
effort: effectiveEffort,
|
||||
status: execution.validationErrors ? "incomplete" : "completed",
|
||||
finalText: execution.finalText,
|
||||
...(execution.toolCalls ? { toolCalls: execution.toolCalls } : {}),
|
||||
...(execution.usage ? { usage: execution.usage } : {}),
|
||||
...(execution.validationErrors ? { incompleteReason: "structured_output_validation_failed" } : {}),
|
||||
});
|
||||
@@ -668,6 +820,7 @@ function findUnsupportedParameter(body: unknown): string | undefined {
|
||||
const supported = new Set([
|
||||
"model", "input", "instructions", "stream", "store",
|
||||
"previous_response_id", "metadata", "reasoning", "text",
|
||||
"tools", "tool_choice", "parallel_tool_calls",
|
||||
]);
|
||||
const unknownTopLevel = Object.keys(body).find((key) => !supported.has(key));
|
||||
if (unknownTopLevel) return unknownTopLevel;
|
||||
@@ -679,10 +832,27 @@ function findUnsupportedParameter(body: unknown): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body.tools)) {
|
||||
for (const [toolIndex, tool] of body.tools.entries()) {
|
||||
if (!isRecord(tool)) continue;
|
||||
if (tool.type !== "function") return `tools.${toolIndex}.type`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(body.tool_choice) && body.tool_choice.type !== "function") {
|
||||
return "tool_choice.type";
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.input)) return undefined;
|
||||
for (const [itemIndex, item] of body.input.entries()) {
|
||||
if (!isRecord(item)) continue;
|
||||
if (item.type !== undefined && item.type !== "message") return `input.${itemIndex}.type`;
|
||||
if (
|
||||
item.type !== undefined
|
||||
&& item.type !== "message"
|
||||
&& item.type !== "function_call"
|
||||
&& item.type !== "function_call_output"
|
||||
) return `input.${itemIndex}.type`;
|
||||
if (item.type === "function_call" || item.type === "function_call_output") continue;
|
||||
if (!Array.isArray(item.content)) continue;
|
||||
for (const [partIndex, part] of item.content.entries()) {
|
||||
if (!isRecord(part)) continue;
|
||||
@@ -704,14 +874,35 @@ function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] {
|
||||
}];
|
||||
}
|
||||
|
||||
return input.map((message) => ({
|
||||
id: newMessageId(),
|
||||
type: "message",
|
||||
role: message.role,
|
||||
content: typeof message.content === "string"
|
||||
? [{ type: message.role === "assistant" ? "output_text" : "input_text", text: message.content }]
|
||||
: message.content.map((part) => ({ type: part.type, text: part.text })),
|
||||
}));
|
||||
return input.map((item) => {
|
||||
if (item.type === "function_call") {
|
||||
return {
|
||||
id: item.id ?? newFunctionCallItemId(),
|
||||
type: "function_call",
|
||||
call_id: item.call_id,
|
||||
name: item.name,
|
||||
arguments: item.arguments,
|
||||
status: item.status ?? "completed",
|
||||
};
|
||||
}
|
||||
if (item.type === "function_call_output") {
|
||||
return {
|
||||
id: item.id ?? newFunctionCallOutputItemId(),
|
||||
type: "function_call_output",
|
||||
call_id: item.call_id,
|
||||
output: item.output,
|
||||
status: item.status ?? "completed",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: newMessageId(),
|
||||
type: "message",
|
||||
role: item.role,
|
||||
content: typeof item.content === "string"
|
||||
? [{ type: item.role === "assistant" ? "output_text" : "input_text", text: item.content }]
|
||||
: item.content.map((part) => ({ type: part.type, text: part.text })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildResponsesPrompt(
|
||||
@@ -719,24 +910,44 @@ function buildResponsesPrompt(
|
||||
chain: StoredResponse[],
|
||||
currentInput: ResponseInputItem[],
|
||||
): string {
|
||||
const messages: PromptMessage[] = [];
|
||||
const history: Array<ResponseInputItem | ResponseOutputItem> = [];
|
||||
for (const stored of chain) {
|
||||
messages.push(...stored.input_items.map(inputItemToPromptMessage));
|
||||
if (stored.response.output_text !== "") {
|
||||
messages.push({ role: "assistant", content: stored.response.output_text });
|
||||
}
|
||||
history.push(...stored.input_items, ...stored.response.output);
|
||||
}
|
||||
messages.push(...currentInput.map(inputItemToPromptMessage));
|
||||
history.push(...currentInput);
|
||||
|
||||
const envelope = {
|
||||
protocol: "openai-responses-text-history-v1",
|
||||
protocol: usesResponseToolCalling(request, history)
|
||||
? "openai-responses-tools-history-v1"
|
||||
: "openai-responses-text-history-v1",
|
||||
instructions: request.instructions,
|
||||
messages,
|
||||
input: history,
|
||||
tools: normalizeResponseTools(request.tools),
|
||||
tool_choice: effectiveResponseToolChoice(request),
|
||||
parallel_tool_calls: request.parallel_tool_calls ?? true,
|
||||
};
|
||||
|
||||
if (usesResponseToolCalling(request, history)) {
|
||||
return [
|
||||
"下面 JSON 对象是外部客户端提交的一次 OpenAI Responses 工具调用任务,input 包含完整历史。",
|
||||
"你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。",
|
||||
"不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 input、tools 和 function_call_output 作出决定。",
|
||||
"严格执行 input 中的 system、developer、user 指令和当前 instructions,并结合 message、function_call、function_call_output 及其他已有 output item 理解完整历史。",
|
||||
"只返回以下两个 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 并立即结束;已有 function_call_output 且信息足够时返回 final。工具调用轮次禁止生成面向用户的正文。",
|
||||
formatDirective(request.text.format),
|
||||
"JSON 数据开始:",
|
||||
JSON.stringify(envelope),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"下面 JSON 对象是外部客户端提交的完整 Responses 文本任务和本地重建的响应链。",
|
||||
"按 system、developer、user、assistant 的角色与顺序理解上下文;当前 instructions 是本次请求的高优先级开发者指令。",
|
||||
"按 input 中的 system、developer、user、assistant 角色与顺序理解上下文;当前 instructions 是本次请求的高优先级开发者指令。",
|
||||
"严格执行最后一个用户任务。不要复述 JSON,不要输出角色标签,不要暴露内部思考。",
|
||||
formatDirective(request.text.format),
|
||||
"JSON 数据开始:",
|
||||
@@ -780,12 +991,17 @@ async function executeResponse(options: {
|
||||
effort: string;
|
||||
prompt: string;
|
||||
format: ResponseTextFormat;
|
||||
toolRequest?: FunctionToolDecisionRequest;
|
||||
validation?: StructuredValidation;
|
||||
signal: AbortSignal;
|
||||
deadline: number;
|
||||
writer?: ResponsesSseWriter;
|
||||
}): Promise<ExecutionResult> {
|
||||
const first = await executeAttempt(options, options.prompt, options.validation ? undefined : options.writer);
|
||||
const constrained = options.validation !== undefined || options.toolRequest !== undefined;
|
||||
const first = await executeAttempt(options, options.prompt, constrained ? undefined : options.writer);
|
||||
if (options.toolRequest) {
|
||||
return executeToolDecision({ ...options, toolRequest: options.toolRequest }, first);
|
||||
}
|
||||
if (!options.validation) {
|
||||
return {
|
||||
finalText: first.result.finalText,
|
||||
@@ -821,6 +1037,91 @@ async function executeResponse(options: {
|
||||
};
|
||||
}
|
||||
|
||||
async function executeToolDecision(
|
||||
options: {
|
||||
config: BridgeConfig;
|
||||
cliModel: string;
|
||||
effort: string;
|
||||
prompt: string;
|
||||
format: ResponseTextFormat;
|
||||
toolRequest: FunctionToolDecisionRequest;
|
||||
validation?: StructuredValidation;
|
||||
signal: AbortSignal;
|
||||
deadline: number;
|
||||
},
|
||||
first: AttemptResult,
|
||||
): Promise<ExecutionResult> {
|
||||
try {
|
||||
return executionFromToolDecision(
|
||||
parseFunctionToolDecision(first.result.finalText, options.toolRequest),
|
||||
first,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ToolDecisionError)) throw error;
|
||||
process.stderr.write(`\nResponses 工具调用决策校验失败,正在修复:${error.errors.join("; ")}\n`);
|
||||
|
||||
const repairPrompt = buildToolDecisionRepairPrompt(
|
||||
options.prompt,
|
||||
first.result.finalText,
|
||||
error.errors,
|
||||
);
|
||||
const second = await executeAttempt(options, repairPrompt);
|
||||
let decision;
|
||||
try {
|
||||
decision = parseFunctionToolDecision(second.result.finalText, options.toolRequest);
|
||||
} catch (repairError) {
|
||||
if (repairError instanceof ToolDecisionError) {
|
||||
throw new ResponseApiError(
|
||||
`Invalid tool decision after repair: ${repairError.errors.join("; ")}`,
|
||||
502,
|
||||
"invalid_tool_decision",
|
||||
"server_error",
|
||||
);
|
||||
}
|
||||
throw repairError;
|
||||
}
|
||||
const result = executionFromToolDecision(decision, second);
|
||||
const usage = addUsage(first.result.usage, second.result.usage);
|
||||
return { ...result, ...(usage ? { usage } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
function executionFromToolDecision(
|
||||
decision: ReturnType<typeof parseFunctionToolDecision>,
|
||||
attempt: AttemptResult,
|
||||
): ExecutionResult {
|
||||
if (decision.type === "tool_calls") {
|
||||
return {
|
||||
finalText: "",
|
||||
chunks: [],
|
||||
toolCalls: decision.toolCalls,
|
||||
...(attempt.result.usage ? { usage: attempt.result.usage } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
finalText: decision.content,
|
||||
chunks: [decision.content],
|
||||
...(attempt.result.usage ? { usage: attempt.result.usage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolDecisionRepairPrompt(
|
||||
originalPrompt: string,
|
||||
invalidOutput: string,
|
||||
errors: string[],
|
||||
): string {
|
||||
return [
|
||||
"上一次输出不符合 Responses 外部工具调用传输协议。保持原来的决策意图,只修复 JSON 结构、工具名称或参数。",
|
||||
"只返回原任务要求的 final 或 tool_calls JSON 对象,禁止 Markdown 代码围栏、前后说明和额外字段。",
|
||||
"校验错误:",
|
||||
errors.join("\n"),
|
||||
"上一次输出:",
|
||||
invalidOutput,
|
||||
"原始任务:",
|
||||
originalPrompt,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
async function executeAttempt(
|
||||
options: {
|
||||
config: BridgeConfig;
|
||||
@@ -910,14 +1211,32 @@ function createResponse(options: {
|
||||
effort: string;
|
||||
status: ResponseStatus;
|
||||
finalText?: string;
|
||||
toolCalls?: FunctionToolCall[];
|
||||
usage?: CommandUsage;
|
||||
error?: ResponseError;
|
||||
incompleteReason?: string;
|
||||
}): LocalResponse {
|
||||
const finalText = options.finalText ?? "";
|
||||
const terminal = options.status !== "in_progress";
|
||||
const hasOutput = options.finalText !== undefined;
|
||||
const outputStatus = options.status === "completed" ? "completed" : "incomplete";
|
||||
const output: ResponseOutputItem[] = options.toolCalls
|
||||
? options.toolCalls.map((call) => ({
|
||||
id: newFunctionCallItemId(),
|
||||
type: "function_call",
|
||||
call_id: call.callId,
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
status: outputStatus,
|
||||
}))
|
||||
: options.finalText !== undefined
|
||||
? [{
|
||||
id: options.messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: outputStatus,
|
||||
content: [{ type: "output_text", text: finalText, annotations: [] }],
|
||||
}]
|
||||
: [];
|
||||
return {
|
||||
id: options.id,
|
||||
object: "response",
|
||||
@@ -928,19 +1247,16 @@ function createResponse(options: {
|
||||
incomplete_details: options.incompleteReason ? { reason: options.incompleteReason } : null,
|
||||
instructions: options.request.instructions,
|
||||
model: options.request.model,
|
||||
output: hasOutput ? [{
|
||||
id: options.messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: outputStatus,
|
||||
content: [{ type: "output_text", text: finalText, annotations: [] }],
|
||||
}] : [],
|
||||
output,
|
||||
output_text: finalText,
|
||||
previous_response_id: options.request.previous_response_id,
|
||||
reasoning: { effort: options.effort, summary: null },
|
||||
store: options.request.store,
|
||||
metadata: options.request.metadata,
|
||||
text: { format: options.request.text.format },
|
||||
tools: normalizeResponseTools(options.request.tools),
|
||||
tool_choice: effectiveResponseToolChoice(options.request),
|
||||
parallel_tool_calls: options.request.parallel_tool_calls ?? true,
|
||||
usage: toResponseUsage(options.usage),
|
||||
};
|
||||
}
|
||||
@@ -1115,8 +1431,152 @@ function responseNotFound(id: string): ResponseApiError {
|
||||
);
|
||||
}
|
||||
|
||||
function inputItemToPromptMessage(item: ResponseInputItem): PromptMessage {
|
||||
return { role: item.role, content: item.content.map((part) => part.text).join("") };
|
||||
function validateResponseToolRequest(request: ResponseRequest): void {
|
||||
const errors = validateFunctionToolSchemas(responseFunctionTools(request));
|
||||
if (errors.length > 0) {
|
||||
throw new ResponseApiError(
|
||||
errors.join("; "),
|
||||
400,
|
||||
"invalid_tool_schema",
|
||||
"invalid_request_error",
|
||||
"tools",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateResponseFunctionHistory(
|
||||
request: ResponseRequest,
|
||||
chain: StoredResponse[],
|
||||
currentInput: ResponseInputItem[],
|
||||
): void {
|
||||
const tools = new Map(responseFunctionTools(request).map((tool) => [tool.name, tool]));
|
||||
const callIds = new Set<string>();
|
||||
const outputCallIds = new Set<string>();
|
||||
const items: Array<{ item: ResponseInputItem | ResponseOutputItem; param: string }> = [];
|
||||
|
||||
for (const stored of chain) {
|
||||
for (const item of stored.input_items) items.push({ item, param: "previous_response_id" });
|
||||
for (const item of stored.response.output) items.push({ item, param: "previous_response_id" });
|
||||
}
|
||||
for (const [index, item] of currentInput.entries()) items.push({ item, param: `input.${index}` });
|
||||
|
||||
for (const { item, param } of items) {
|
||||
if (item.type === "function_call") {
|
||||
if (callIds.has(item.call_id)) {
|
||||
throw new ResponseApiError(
|
||||
`Duplicate function call_id '${item.call_id}'`,
|
||||
400,
|
||||
"invalid_function_call",
|
||||
"invalid_request_error",
|
||||
`${param}.call_id`,
|
||||
);
|
||||
}
|
||||
callIds.add(item.call_id);
|
||||
const tool = tools.get(item.name);
|
||||
if (!tool) {
|
||||
throw new ResponseApiError(
|
||||
`Function call references unknown tool '${item.name}'`,
|
||||
400,
|
||||
"invalid_tool_name",
|
||||
"invalid_request_error",
|
||||
`${param}.name`,
|
||||
);
|
||||
}
|
||||
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = parseFunctionToolArguments(item.arguments, `${param}.arguments`);
|
||||
} catch (error) {
|
||||
if (error instanceof ToolDecisionError) {
|
||||
throw new ResponseApiError(
|
||||
error.errors.join("; "),
|
||||
400,
|
||||
"invalid_tool_arguments",
|
||||
"invalid_request_error",
|
||||
`${param}.arguments`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const errors = validateFunctionToolArguments(tool, item.name, args);
|
||||
if (errors.length > 0) {
|
||||
throw new ResponseApiError(
|
||||
errors.join("; "),
|
||||
400,
|
||||
"invalid_tool_arguments",
|
||||
"invalid_request_error",
|
||||
`${param}.arguments`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === "function_call_output") {
|
||||
if (!callIds.has(item.call_id)) {
|
||||
throw new ResponseApiError(
|
||||
`No preceding function_call found for call_id '${item.call_id}'`,
|
||||
400,
|
||||
"invalid_function_call_output",
|
||||
"invalid_request_error",
|
||||
`${param}.call_id`,
|
||||
);
|
||||
}
|
||||
if (outputCallIds.has(item.call_id)) {
|
||||
throw new ResponseApiError(
|
||||
`Duplicate function_call_output for call_id '${item.call_id}'`,
|
||||
400,
|
||||
"invalid_function_call_output",
|
||||
"invalid_request_error",
|
||||
`${param}.call_id`,
|
||||
);
|
||||
}
|
||||
outputCallIds.add(item.call_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function responseFunctionTools(request: ResponseRequest): FunctionToolDefinition[] {
|
||||
return normalizeResponseTools(request.tools).map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description !== undefined ? { description: tool.description } : {}),
|
||||
parameters: tool.parameters,
|
||||
strict: tool.strict,
|
||||
}));
|
||||
}
|
||||
|
||||
function responseFunctionToolDecisionRequest(
|
||||
request: ResponseRequest,
|
||||
): FunctionToolDecisionRequest | undefined {
|
||||
if (!request.tools) return undefined;
|
||||
const toolChoice = effectiveResponseToolChoice(request);
|
||||
return {
|
||||
tools: responseFunctionTools(request),
|
||||
toolChoice: typeof toolChoice === "string" ? toolChoice : { name: toolChoice.name },
|
||||
parallelToolCalls: request.parallel_tool_calls ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponseTools(tools: ResponseRequest["tools"]): ResponseFunctionTool[] {
|
||||
return (tools ?? []).map((tool) => ({
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
...(tool.description !== undefined ? { description: tool.description } : {}),
|
||||
parameters: tool.parameters ?? { type: "object", properties: {} },
|
||||
strict: tool.strict ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function effectiveResponseToolChoice(request: ResponseRequest): ResponseToolChoice {
|
||||
return request.tool_choice ?? (request.tools ? "auto" : "none");
|
||||
}
|
||||
|
||||
function usesResponseToolCalling(
|
||||
request: ResponseRequest,
|
||||
history: Array<ResponseInputItem | ResponseOutputItem>,
|
||||
): boolean {
|
||||
return request.tools !== undefined || history.some((item) => (
|
||||
item.type === "function_call" || item.type === "function_call_output"
|
||||
));
|
||||
}
|
||||
|
||||
function toResponseUsage(usage?: CommandUsage): ResponseUsage | null {
|
||||
@@ -1155,6 +1615,14 @@ function newMessageId(): string {
|
||||
return `msg_local_${randomHexId()}`;
|
||||
}
|
||||
|
||||
function newFunctionCallItemId(): string {
|
||||
return `fc_local_${randomHexId()}`;
|
||||
}
|
||||
|
||||
function newFunctionCallOutputItemId(): string {
|
||||
return `fco_local_${randomHexId()}`;
|
||||
}
|
||||
|
||||
function randomHexId(): string {
|
||||
return randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
+120
-30
@@ -9,6 +9,31 @@ export type ToolDecision =
|
||||
| { type: "final"; content: string }
|
||||
| { type: "tool_calls"; toolCalls: ChatCompletionToolCall[] };
|
||||
|
||||
export interface FunctionToolDefinition {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
parameters: Record<string, unknown>;
|
||||
strict?: boolean | null;
|
||||
}
|
||||
|
||||
export type FunctionToolChoice = "none" | "auto" | "required" | { name: string };
|
||||
|
||||
export interface FunctionToolDecisionRequest {
|
||||
tools?: FunctionToolDefinition[];
|
||||
toolChoice?: FunctionToolChoice;
|
||||
parallelToolCalls?: boolean;
|
||||
}
|
||||
|
||||
export interface FunctionToolCall {
|
||||
callId: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
export type FunctionToolDecision =
|
||||
| { type: "final"; content: string }
|
||||
| { type: "tool_calls"; toolCalls: FunctionToolCall[] };
|
||||
|
||||
export class ToolDecisionError extends Error {
|
||||
constructor(readonly errors: string[]) {
|
||||
super(errors.join("; "));
|
||||
@@ -17,12 +42,18 @@ export class ToolDecisionError extends Error {
|
||||
}
|
||||
|
||||
export function validateToolSchemas(request: ChatCompletionRequest): string[] {
|
||||
return validateFunctionToolSchemas(toFunctionTools(request)).map((error) => (
|
||||
error.replace(/^(tools\.\d+)\.parameters:/, "$1.function.parameters:")
|
||||
));
|
||||
}
|
||||
|
||||
export function validateFunctionToolSchemas(tools: FunctionToolDefinition[] | undefined): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const [index, tool] of (request.tools ?? []).entries()) {
|
||||
for (const [index, tool] of (tools ?? []).entries()) {
|
||||
try {
|
||||
createValidator(tool.function.parameters);
|
||||
createValidator(tool.parameters);
|
||||
} catch (error) {
|
||||
errors.push(`tools.${index}.function.parameters: ${formatUnknownError(error)}`);
|
||||
errors.push(`tools.${index}.parameters: ${formatUnknownError(error)}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
@@ -32,6 +63,30 @@ export function parseToolDecision(
|
||||
output: string,
|
||||
request: ChatCompletionRequest,
|
||||
): ToolDecision {
|
||||
const tools = toFunctionTools(request);
|
||||
const toolChoice = toFunctionToolChoice(request);
|
||||
const decision = parseFunctionToolDecision(output, {
|
||||
...(tools ? { tools } : {}),
|
||||
...(toolChoice ? { toolChoice } : {}),
|
||||
...(request.parallel_tool_calls !== undefined
|
||||
? { parallelToolCalls: request.parallel_tool_calls }
|
||||
: {}),
|
||||
});
|
||||
if (decision.type === "final") return decision;
|
||||
return {
|
||||
type: "tool_calls",
|
||||
toolCalls: decision.toolCalls.map((call) => ({
|
||||
id: call.callId,
|
||||
type: "function",
|
||||
function: { name: call.name, arguments: call.arguments },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFunctionToolDecision(
|
||||
output: string,
|
||||
request: FunctionToolDecisionRequest,
|
||||
): FunctionToolDecision {
|
||||
const value = parseJsonValue(output);
|
||||
if (!isRecord(value)) {
|
||||
throw new ToolDecisionError(["Output must be a JSON object"]);
|
||||
@@ -44,8 +99,8 @@ export function parseToolDecision(
|
||||
|
||||
function parseFinalDecision(
|
||||
value: Record<string, unknown>,
|
||||
request: ChatCompletionRequest,
|
||||
): ToolDecision {
|
||||
request: FunctionToolDecisionRequest,
|
||||
): FunctionToolDecision {
|
||||
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(", ")}`);
|
||||
@@ -61,8 +116,8 @@ function parseFinalDecision(
|
||||
|
||||
function parseCallsDecision(
|
||||
value: Record<string, unknown>,
|
||||
request: ChatCompletionRequest,
|
||||
): ToolDecision {
|
||||
request: FunctionToolDecisionRequest,
|
||||
): FunctionToolDecision {
|
||||
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(", ")}`);
|
||||
@@ -73,12 +128,12 @@ function parseCallsDecision(
|
||||
|
||||
const toolChoice = effectiveToolChoice(request);
|
||||
if (toolChoice === "none") errors.push("tool_choice is none");
|
||||
if (request.parallel_tool_calls === false && value.calls.length > 1) {
|
||||
if (request.parallelToolCalls === 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[] = [];
|
||||
const tools = new Map((request.tools ?? []).map((tool) => [tool.name, tool]));
|
||||
const toolCalls: FunctionToolCall[] = [];
|
||||
for (const [index, rawCall] of value.calls.entries()) {
|
||||
if (!isRecord(rawCall)) {
|
||||
errors.push(`calls.${index} must be an object`);
|
||||
@@ -98,32 +153,22 @@ function parseCallsDecision(
|
||||
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}'`);
|
||||
if (typeof toolChoice === "object" && rawCall.name !== toolChoice.name) {
|
||||
errors.push(`calls.${index}.name must be forced tool '${toolChoice.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)}`);
|
||||
const validationErrors = validateFunctionToolArguments(tool, rawCall.name, args);
|
||||
if (validationErrors.length > 0) {
|
||||
errors.push(...validationErrors);
|
||||
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),
|
||||
},
|
||||
callId: `call_${randomUUID().replaceAll("-", "")}`,
|
||||
name: rawCall.name,
|
||||
arguments: JSON.stringify(args),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,6 +176,36 @@ function parseCallsDecision(
|
||||
return { type: "tool_calls", toolCalls };
|
||||
}
|
||||
|
||||
export function validateFunctionToolArguments(
|
||||
tool: FunctionToolDefinition,
|
||||
toolName: string,
|
||||
argumentsValue: unknown,
|
||||
): string[] {
|
||||
let validator: ValidateFunction;
|
||||
try {
|
||||
validator = createValidator(tool.parameters);
|
||||
} catch (error) {
|
||||
return [`Invalid schema for tool '${toolName}': ${formatUnknownError(error)}`];
|
||||
}
|
||||
return validator(argumentsValue) ? [] : formatAjvErrors(toolName, validator.errors);
|
||||
}
|
||||
|
||||
export function parseFunctionToolArguments(
|
||||
value: string,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (error) {
|
||||
throw new ToolDecisionError([`${label} is invalid JSON: ${formatUnknownError(error)}`]);
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw new ToolDecisionError([`${label} must be a JSON object`]);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJsonValue(output: string): unknown {
|
||||
const trimmed = output.trim();
|
||||
const candidates = [trimmed];
|
||||
@@ -178,8 +253,8 @@ 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 effectiveToolChoice(request: FunctionToolDecisionRequest): FunctionToolChoice {
|
||||
return request.toolChoice ?? ((request.tools?.length ?? 0) > 0 ? "auto" : "none");
|
||||
}
|
||||
|
||||
function formatAjvErrors(toolName: string, errors: ErrorObject[] | null | undefined): string[] {
|
||||
@@ -196,3 +271,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
function formatUnknownError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function toFunctionTools(request: ChatCompletionRequest): FunctionToolDefinition[] | undefined {
|
||||
return request.tools?.map((tool) => ({
|
||||
name: tool.function.name,
|
||||
...(tool.function.description ? { description: tool.function.description } : {}),
|
||||
parameters: tool.function.parameters,
|
||||
...(tool.function.strict !== undefined ? { strict: tool.function.strict } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function toFunctionToolChoice(request: ChatCompletionRequest): FunctionToolChoice | undefined {
|
||||
if (request.tool_choice === undefined) return undefined;
|
||||
if (typeof request.tool_choice === "string") return request.tool_choice;
|
||||
return { name: request.tool_choice.function.name };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user