适配chat结构

This commit is contained in:
Sirius
2026-08-12 16:03:25 +08:00
parent 3e7a4c9540
commit f65ac095bb
5 changed files with 264 additions and 38 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ curl http://127.0.0.1:18000/v1/chat/completions \
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **command-code-openai-bridge** (320 symbols, 917 relationships, 27 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **command-code-openai-bridge** (330 symbols, 941 relationships, 28 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
+32 -9
View File
@@ -135,7 +135,7 @@ export OPENCODE_CONFIG="/path/to/command-code-openai-bridge/examples/opencode.js
3. OpenCode 仍需配置任意非空 API keyBridge 忽略 Authorization,但客户端要求填写)。示例配置使用 `options.apiKey: "local-bridge"`
4. 选择模型 `command-bridge/command-default`(与 `config.yaml` 中的 `models.command-default` 映射一致)。
OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true``stream_options.include_usage: true``max_tokens`(默认约 32000)、`store: false``tools`(约 24 个内置工具)、`tool_choice: auto`。Bridge 接受 `store`采样/输出限制参数但不改变无状态行为;未映射参数通过 `X-Command-Code-Ignored-Parameters` 标出。工具 `parameters` 中的 JSON Schema `$schema`draft 2020-12)会在校验前剥离元数据字段,避免 AJV 拒绝 OpenCode 工具定义。
OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true``stream_options.include_usage: true``max_tokens`(默认约 32000)、`store: false``tools`(约 24 个内置工具)、`tool_choice: auto`。Bridge 接受 `store: false` 并保持 Chat 无状态;采样输出限制参数虽然在协议层接受,但未映射参数通过 `X-Command-Code-Ignored-Parameters` 标出。工具 `parameters` 中的 JSON Schema `$schema`draft 2020-12)会在校验前剥离元数据字段,避免 AJV 拒绝 OpenCode 工具定义。
OpenCode 还会在会话标题生成、子 agent 和后台任务等场景发起独立请求。这些请求与主 agent 请求共用同一 provider;将 `max_concurrent_requests` 设为大于 `1` 可让它们与多个会话并行执行。
@@ -180,11 +180,11 @@ Bearer Token 会被忽略。所有生成接口只接受文本内容。图片、
Chat 接口接受标准 function tools、`tool_choice``parallel_tool_calls`。Bridge 把工具定义、完整消息历史和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不直接访问 Obsidian vault。该流程适用于 Obsidian Copilot 的 `localSearch``readNote``getFileTree``writeFile``editFile`,也适用于其他标准 function tools。协议流程参考 [OpenAI Function calling](https://developers.openai.com/api/docs/guides/function-calling)。
`stream` 省略或设为 `false` 时返回普通 JSON。普通文本请求的 `stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。工具模式需要先解析和校验完整决策:最终文本会在校验后作为 content chunk 发送;工具调用会作为 `delta.tool_calls` 发送,并以 `finish_reason: "tool_calls"` 结束。`stream_thinking: true` 只作用于普通文本请求;该兼容格式不是原生 OpenAI reasoning item。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。
`stream` 省略或设为 `false` 时返回普通 JSON。普通文本请求的 `stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。工具模式需要先解析和校验完整决策:最终文本会在校验后作为 content chunk 发送;工具调用会作为 `delta.tool_calls` 发送,并以 `finish_reason: "tool_calls"` 结束。Chat structured output 会缓存完整正文,只有 JSON 通过校验或单次修复后才发送 content;连续失败会发送 `invalid_structured_output` 错误和 `[DONE]`,不会先发出无法收回的无效 JSON。`stream_thinking: true` 只作用于非结构化普通文本请求;该兼容格式不是原生 OpenAI reasoning item。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。
为兼容 Obsidian Copilot、OpenCode 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature``max_tokens``max_completion_tokens``top_p``frequency_penalty``presence_penalty``n: 1``store`。这些采样、输出限制与会话存储参数会按 OpenAI 取值范围严格校验。Command Code CLI 没有对应的 headless 参数,Chat Completions 也不保存跨请求会话;Bridge 不会把它们伪装成已生效:服务终端会输出警告,HTTP 响应 `X-Command-Code-Ignored-Parameters`。其他未实现字段仍返回 `400 unsupported_parameter`
为兼容 Obsidian Copilot、OpenCode 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature``max_tokens``max_completion_tokens``top_p``frequency_penalty``presence_penalty``n: 1``store: false``n: 1` 是 Bridge 实际支持的单候选协议行为;`store: false` 保持每次请求使用 `--no-session` 的无状态行为,不创建服务端 Chat session。Command Code 1.18.0 官方 `--help` 没有 temperature、top-p、frequency/presence penalty 或输出 token 上限的 headless 参数,因此前六个采样/输出限制字段只在协议层接受和校验,不会传给 CLI,也不会用提示词伪造。只要请求显式提供其中任一字段,服务终端会输出警告,HTTP 响应 `X-Command-Code-Ignored-Parameters` 会准确列出未实际应用的字段;浏览器可通过 CORS exposed header 读取。其他未实现字段仍返回 `400 unsupported_parameter`
Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 模式会缓存 `finalText`,避免内部 JSON 决策进入客户端正文。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。
Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 和 structured output 模式会缓存 `finalText`,避免内部决策或未校验 JSON 进入客户端正文。`response_format` 只约束最终文本:需要调用工具的轮次先按 tools 协议返回,客户端提交工具结果后的最终文本轮次再执行格式校验。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。
### Responses
@@ -241,7 +241,30 @@ input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_m
### 结构化输出
支持
Chat Completions 支持标准 `response_format`
```json
{ "response_format": { "type": "text" } }
```
```json
{ "response_format": { "type": "json_object" } }
```
```json
{
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "result",
"strict": true,
"schema": { "type": "object" }
}
}
}
```
Responses 支持对应的 `text.format`
```json
{ "text": { "format": { "type": "text" } } }
@@ -264,9 +287,9 @@ input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_m
}
```
Bridge 把格式要求作为独立内部指令发送给 Command Code。最终文本必须能解析为 JSON;`json_object` 要求顶层对象,`json_schema` 使用 Ajv 校验。第一次失败后会在同一 AbortSignal 和原请求总截止时间内顺序执行一次修复,usage 是次真实用量之和。第二次仍失败时返回 `status: "incomplete"``incomplete_details.reason: "structured_output_validation_failed"`
Bridge 把格式要求作为独立内部指令发送给 Command Code。最终文本必须能解析为 JSON;`json_object` 要求顶层对象,`json_schema` 使用 Ajv 校验。第一次失败后会在同一 AbortSignal 和原请求总截止时间内顺序执行一次修复,usage 是次真实用量之和。Chat 第二次仍失败时,非流式请求返回 HTTP 502 `invalid_structured_output`,流式请求发送同 code 的 SSE 错误;Responses 则返回 `status: "incomplete"``incomplete_details.reason: "structured_output_validation_failed"`
这是 Bridge 层的提示、解析、校验和单次修复约束,不具备底层模型原生 Responses Structured Outputs 的解码级保证
这是 Bridge 层的提示、JSON 解析、Ajv 校验和单次修复约束,不是 Command Code 或底层模型提供的解码级原生 structured outputs。
官方协议依据:
@@ -382,11 +405,11 @@ command-code \
- 没有原始 Command Code TUI、颜色布局、动画和键盘交互。
- headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。
- 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。
- 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling Responses 结构化输出需要等待 Bridge 校验完成。
- 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 以及 Chat/Responses 结构化输出需要等待 Bridge 校验完成。
- 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 使用,没有跨进程锁或多实例一致性保证;并发请求应避免同时更新同一条 Response 链。
- Responses Structured Outputs 是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;此时状态为 incomplete。
- Chat 和 Responses Structured Outputs 是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;Chat 此时返回 `invalid_structured_output`Responses 状态为 incomplete。
- `usage` 使用 Command Code 最终结果提供的真实 input/output tokenResponses 缺失时返回 `null`Chat Completions 为兼容旧行为返回 0。
- 长文本受 HTTP 请求体上限和 Command Code 模型上下文上限共同限制,不会由桥接服务自行截断。
- v1.10.0 的大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。
+90 -2
View File
@@ -80,6 +80,29 @@ const toolChoiceSchema = z.union([
}).strict(),
]);
const chatTextResponseFormatSchema = z.object({
type: z.literal("text"),
}).strict();
const chatJsonObjectResponseFormatSchema = z.object({
type: z.literal("json_object"),
}).strict();
const chatJsonSchemaResponseFormatSchema = z.object({
type: z.literal("json_schema"),
json_schema: z.object({
name: z.string().min(1).max(64),
strict: z.boolean().optional(),
schema: z.union([z.record(z.unknown()), z.boolean()]),
}).strict(),
}).strict();
const chatResponseFormatSchema = z.discriminatedUnion("type", [
chatTextResponseFormatSchema,
chatJsonObjectResponseFormatSchema,
chatJsonSchemaResponseFormatSchema,
]);
export const chatCompletionRequestSchema = z.object({
model: z.string().min(1),
messages: z.array(messageSchema).min(1),
@@ -97,7 +120,8 @@ export const chatCompletionRequestSchema = z.object({
tools: z.array(functionToolSchema).min(1).max(128).optional(),
tool_choice: toolChoiceSchema.optional(),
parallel_tool_calls: z.boolean().optional(),
store: z.boolean().optional().default(true),
response_format: chatResponseFormatSchema.optional().default({ type: "text" }),
store: z.literal(false).optional(),
}).strict().superRefine((request, context) => {
const toolNames = new Set<string>();
for (const [index, tool] of (request.tools ?? []).entries()) {
@@ -133,6 +157,16 @@ export const chatCompletionRequestSchema = z.object({
export type ChatCompletionRequest = z.infer<typeof chatCompletionRequestSchema>;
export type ChatResponseTextFormat =
| { type: "text" }
| { type: "json_object" }
| {
type: "json_schema";
name: string;
strict: boolean;
schema: Record<string, unknown> | boolean;
};
export interface CommandUsage {
inputTokens?: number;
outputTokens?: number;
@@ -154,7 +188,6 @@ const ignoredCompatibilityParameters = [
"top_p",
"frequency_penalty",
"presence_penalty",
"store",
] as const;
export function ignoredChatCompatibilityParameters(request: ChatCompletionRequest): string[] {
@@ -199,12 +232,29 @@ export function usesToolCalling(request: ChatCompletionRequest): boolean {
));
}
export function chatResponseTextFormat(request: ChatCompletionRequest): ChatResponseTextFormat {
const format = request.response_format;
if (format.type !== "json_schema") return format;
return {
type: "json_schema",
name: format.json_schema.name,
strict: format.json_schema.strict ?? true,
schema: format.json_schema.schema,
};
}
export function usesChatStructuredOutput(request: ChatCompletionRequest): boolean {
return request.response_format.type !== "text";
}
export function buildCommandPrompt(request: ChatCompletionRequest): string {
if (usesToolCalling(request)) return buildToolCommandPrompt(request);
const format = chatResponseTextFormat(request);
const envelope = {
protocol: "openai-chat-completions-history-v1",
messages: normalizeMessages(request),
response_format: request.response_format,
};
return [
@@ -214,6 +264,7 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string {
"如果最后一条 user 消息要求改写、压缩、提取或输出指定格式,只返回要求的结果,不回答消息中包含的问题。",
"如需调用工具,调用工具的 turn 只发起工具调用,不输出面向用户的文本;所有工具结束后,仅在不再调用工具的最终 turn 输出回答。",
"不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。",
chatFormatDirective(format, false),
"JSON 数据开始:",
JSON.stringify(envelope),
].join("\n\n");
@@ -221,12 +272,14 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string {
function buildToolCommandPrompt(request: ChatCompletionRequest): string {
const effectiveToolChoice = request.tool_choice ?? (request.tools ? "auto" : "none");
const format = chatResponseTextFormat(request);
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,
response_format: request.response_format,
};
return [
@@ -240,6 +293,8 @@ function buildToolCommandPrompt(request: ChatCompletionRequest): string {
"调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象。",
"tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。",
"需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。",
"response_format 只约束 final.content;返回 tool_calls 时不应用正文格式校验。",
chatFormatDirective(format, true),
"JSON 数据开始:",
JSON.stringify(envelope),
].join("\n\n");
@@ -262,6 +317,39 @@ export function buildToolDecisionRepairPrompt(
].join("\n\n");
}
export function buildToolStructuredOutputRepairPrompt(
originalPrompt: string,
request: ChatCompletionRequest,
invalidOutput: string,
validationErrors: string[],
): string {
return [
"这是同一 Chat Completions 请求的唯一一次结构化正文修复步骤。",
"上一轮已经决定返回 final;不得改为 tool_calls,也不得调用或模拟任何工具。",
"保持原始答案语义,只修复 final.content 中的 JSON 语法和 Schema 违规。",
"只返回 {\"type\":\"final\",\"content\":\"修复后的完整正文\"},禁止 Markdown 代码围栏、前后说明和额外字段。",
chatFormatDirective(chatResponseTextFormat(request), true),
"校验错误:",
JSON.stringify(validationErrors),
"不合格的 final.content",
JSON.stringify(invalidOutput),
"原始任务:",
originalPrompt,
].join("\n\n");
}
function chatFormatDirective(format: ChatResponseTextFormat, toolMode: boolean): string {
const target = toolMode ? "final.content 字符串的内容" : "最终输出";
if (format.type === "text") return `内部输出格式要求:${target}为纯文本。`;
if (format.type === "json_object") {
return `内部输出格式要求:${target}必须且只能是一个合法 JSON 对象;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。`;
}
return [
`内部输出格式要求:${target}必须且只能是一个符合 JSON Schema '${format.name}' 的合法 JSON 值;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。`,
`JSON Schema${JSON.stringify(format.schema)}`,
].join("\n");
}
export function completionResponse(model: string, content: string, usage?: CommandUsage) {
const promptTokens = usage?.inputTokens ?? 0;
const completionTokens = usage?.outputTokens ?? 0;
+5 -5
View File
@@ -291,7 +291,7 @@ interface StoredResponse {
input_items: ResponseInputItem[];
}
interface StructuredValidation {
export interface StructuredValidation {
validate: (text: string) => { valid: boolean; errors: string[] };
}
@@ -1107,14 +1107,14 @@ function buildResponsesPrompt(
].join("\n\n");
}
function buildRepairPrompt(
export function buildRepairPrompt(
originalPrompt: string,
format: ResponseTextFormat,
invalidOutput: string,
validationErrors: string[],
): string {
return [
"这是同一 Responses 请求的唯一一次结构化输出修复步骤。",
"这是同一请求的唯一一次结构化输出修复步骤。",
"保留原始答案的语义,只修复 JSON 语法和 Schema 违规;只返回修复后的 JSON,禁止代码围栏和解释。",
formatDirective(format),
"校验错误:",
@@ -1320,7 +1320,7 @@ async function executeAttempt(
};
}
function createStructuredValidation(format: ResponseTextFormat): StructuredValidation | undefined {
export function createStructuredValidation(format: ResponseTextFormat): StructuredValidation | undefined {
if (format.type === "text") return undefined;
if (format.type === "json_object") {
return {
@@ -1761,7 +1761,7 @@ function toResponseUsage(usage?: CommandUsage): ResponseUsage | null {
};
}
function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined {
export function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined {
if (!left && !right) return undefined;
return {
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
+136 -21
View File
@@ -12,17 +12,26 @@ import {
import {
buildCommandPrompt,
buildToolDecisionRepairPrompt,
buildToolStructuredOutputRepairPrompt,
chatResponseTextFormat,
chatCompletionRequestSchema,
completionResponse,
ignoredChatCompatibilityParameters,
openAIError,
toolCallsCompletionResponse,
usesChatStructuredOutput,
usesToolCalling,
type ChatCompletionRequest,
type CommandUsage,
} from "./openai.js";
import { RequestCoordinator } from "./request-coordinator.js";
import { registerResponseRoutes } from "./responses.js";
import {
addUsage,
buildRepairPrompt,
createStructuredValidation,
registerResponseRoutes,
type ResponseTextFormat,
type StructuredValidation,
} from "./responses.js";
import {
parseToolDecision,
ToolDecisionError,
@@ -58,6 +67,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
);
reply.raw.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
reply.raw.setHeader("Access-Control-Max-Age", "600");
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Command-Code-Ignored-Parameters");
reply.raw.setHeader(
"Vary",
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
@@ -126,7 +136,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
));
}
let parsed;
let parsed: ChatCompletionRequest;
try {
parsed = chatCompletionRequestSchema.parse(request.body);
} catch (error) {
@@ -149,6 +159,20 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
return reply.status(400).send(openAIError(message, "invalid_request"));
}
const responseFormat: ResponseTextFormat = chatResponseTextFormat(parsed);
let structuredValidation: StructuredValidation | undefined;
try {
structuredValidation = createStructuredValidation(responseFormat);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid JSON Schema";
return reply.status(400).send(openAIError(
message,
"invalid_json_schema",
"invalid_request_error",
"response_format.json_schema.schema",
));
}
const model = config.models[parsed.model];
if (!model) {
return reply.status(404).send(openAIError(
@@ -196,7 +220,13 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
let writer: ChatCompletionSseWriter | undefined;
try {
if (parsed.stream) writer = startChatCompletionSse(reply, parsed.model, config.stream_thinking);
if (parsed.stream) {
writer = startChatCompletionSse(
reply,
parsed.model,
config.stream_thinking && !usesChatStructuredOutput(parsed),
);
}
if (request.raw.aborted || reply.raw.destroyed) cancelOnDisconnect();
acquired = await lease.ready;
if (!acquired) {
@@ -212,6 +242,8 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
signal: abortController.signal,
deadline: Date.now() + config.timeout_seconds * 1000,
toolMode,
format: responseFormat,
...(structuredValidation ? { validation: structuredValidation } : {}),
...(writer ? { writer } : {}),
});
const { result, decision } = execution;
@@ -281,6 +313,21 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
));
}
if (error instanceof ChatStructuredOutputError) {
process.stderr.write(`\nChat 结构化输出连续两次校验失败:${error.errors.join("; ")}\n`);
const payload = openAIError(
"Command Code returned invalid structured output after one repair attempt",
"invalid_structured_output",
"server_error",
"response_format",
);
if (writer) {
writer.error(payload);
return reply;
}
return reply.status(502).send(payload);
}
const code = error instanceof CommandCodeError ? error.exitCode : undefined;
const status = code === 5 ? 429 : code === 10 ? 402 : 502;
const errorCode = code === 5
@@ -323,7 +370,7 @@ interface ChatExecution {
decision?: ToolDecision;
}
async function executeChatTurn(options: {
interface ChatTurnOptions {
config: BridgeConfig;
cliModel: string;
effort: string;
@@ -331,9 +378,21 @@ async function executeChatTurn(options: {
signal: AbortSignal;
deadline: number;
toolMode: boolean;
format: ResponseTextFormat;
validation?: StructuredValidation;
writer?: ChatCompletionSseWriter;
}): Promise<ChatExecution> {
}
class ChatStructuredOutputError extends Error {
constructor(readonly errors: string[]) {
super(errors.join("; "));
this.name = "ChatStructuredOutputError";
}
}
async function executeChatTurn(options: ChatTurnOptions): Promise<ChatExecution> {
const originalPrompt = buildCommandPrompt(options.request);
const constrained = options.toolMode || options.validation !== undefined;
const first = await runCommandCode(
options.config,
options.cliModel,
@@ -342,15 +401,38 @@ async function executeChatTurn(options: {
options.signal,
{
timeoutMs: options.deadline - Date.now(),
...(!options.toolMode && options.writer
...(!constrained && options.writer
? { onEvent: (event: Record<string, unknown>) => options.writer?.commandEvent(event) }
: {}),
},
);
if (!options.toolMode) return { result: first };
if (!options.toolMode) {
if (!options.validation) return { result: first };
const firstValidation = options.validation.validate(first.finalText);
if (firstValidation.valid) return { result: first };
process.stderr.write(`\nChat 结构化输出校验失败,正在修复:${firstValidation.errors.join("; ")}\n`);
const second = await runCommandCode(
options.config,
options.cliModel,
options.effort,
buildRepairPrompt(
originalPrompt,
options.format,
first.finalText,
firstValidation.errors,
),
options.signal,
{ timeoutMs: options.deadline - Date.now() },
);
const secondValidation = options.validation.validate(second.finalText);
if (!secondValidation.valid) throw new ChatStructuredOutputError(secondValidation.errors);
return { result: combineCommandResults(first, second) };
}
let execution: ChatExecution;
try {
return {
execution = {
result: first,
decision: parseToolDecision(first.finalText, options.request),
};
@@ -371,23 +453,56 @@ async function executeChatTurn(options: {
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,
execution = {
result: combineCommandResults(first, second),
decision: parseToolDecision(second.finalText, options.request),
};
}
if (execution.decision?.type !== "final" || !options.validation) return execution;
const firstValidation = options.validation.validate(execution.decision.content);
if (firstValidation.valid) return execution;
process.stderr.write(`\nChat 最终工具正文结构化校验失败,正在修复:${firstValidation.errors.join("; ")}\n`);
const repair = await runCommandCode(
options.config,
options.cliModel,
options.effort,
buildToolStructuredOutputRepairPrompt(
originalPrompt,
options.request,
execution.decision.content,
firstValidation.errors,
),
options.signal,
{ timeoutMs: options.deadline - Date.now() },
);
const result = combineCommandResults(execution.result, repair);
let decision: ToolDecision;
try {
decision = parseToolDecision(repair.finalText, options.request);
} catch (error) {
if (error instanceof ToolDecisionError) {
throw new ChatStructuredOutputError([
"Structured output repair did not return a valid final decision",
...error.errors,
]);
}
throw error;
}
if (decision.type !== "final") {
throw new ChatStructuredOutputError(["Structured output repair returned tool_calls instead of final"]);
}
const repairedValidation = options.validation.validate(decision.content);
if (!repairedValidation.valid) throw new ChatStructuredOutputError(repairedValidation.errors);
return { result, decision };
}
function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined {
if (!left && !right) return undefined;
function combineCommandResults(left: CommandResult, right: CommandResult): CommandResult {
const usage = addUsage(left.usage, right.usage);
return {
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0),
finalText: right.finalText,
durationMs: left.durationMs + right.durationMs,
...(usage ? { usage } : {}),
};
}