支持responses

This commit is contained in:
Sirius
2026-08-12 15:01:43 +08:00
parent 1e19973243
commit 3b9454c787
4 changed files with 662 additions and 89 deletions
+34 -19
View File
@@ -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-04Responses 新增检查为 2026-08-05function 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-04Responses 文本子集检查为 2026-08-05Chat function calling 协议检查为 2026-08-06Responses 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 tokenResponses 缺失时返回 `null`Chat Completions 为兼容旧行为返回 0。
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}