适配chat结构
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ export OPENCODE_CONFIG="/path/to/command-code-openai-bridge/examples/opencode.js
|
||||
3. OpenCode 仍需配置任意非空 API key(Bridge 忽略 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 token;Responses 缺失时返回 `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
@@ -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
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user