适配图片

This commit is contained in:
Sirius
2026-08-12 16:23:52 +08:00
parent f65ac095bb
commit 995392e526
12 changed files with 821 additions and 116 deletions
+158 -5
View File
@@ -5,6 +5,17 @@ import type { ServerResponse } from "node:http";
import { Ajv, type ErrorObject, type ValidateFunction } from "ajv";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z, ZodError } from "zod";
import {
attachmentPromptBlock,
AttachmentInputError,
decodeImageDataUrl,
decodeTextFileData,
materializeAttachments,
validateAttachmentBatch,
type AttachmentReference,
type MaterializedAttachments,
type PendingAttachment,
} from "./attachments.js";
import {
CommandCodeError,
runCommandCode,
@@ -43,12 +54,55 @@ const outputTextPartSchema = z.object({
text: z.string(),
}).strict();
const inputImagePartSchema = z.object({
type: z.literal("input_image"),
image_url: z.string().min(1).optional(),
file_id: z.string().min(1).optional(),
detail: z.enum(["auto", "low", "high"]).optional(),
}).strict().superRefine((part, context) => {
if ((part.image_url === undefined) === (part.file_id === undefined)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "input_image requires exactly one of image_url or file_id",
});
}
});
const inputFilePartSchema = z.object({
type: z.literal("input_file"),
file_data: z.string().min(1).optional(),
file_url: z.string().min(1).optional(),
file_id: z.string().min(1).optional(),
filename: z.string().min(1).max(255).optional(),
}).strict().superRefine((part, context) => {
const sourceCount = [part.file_data, part.file_url, part.file_id]
.filter((source) => source !== undefined).length;
if (sourceCount !== 1) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "input_file requires exactly one of file_data, file_url, or file_id",
});
}
if (part.file_data !== undefined && part.filename === undefined) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["filename"],
message: "filename is required with file_data",
});
}
});
const inputMessageSchema = z.object({
type: z.literal("message").optional(),
role: roleSchema,
content: z.union([
z.string(),
z.array(z.union([inputTextPartSchema, outputTextPartSchema])).min(1),
z.array(z.union([
inputTextPartSchema,
outputTextPartSchema,
inputImagePartSchema,
inputFilePartSchema,
])).min(1),
]),
}).strict();
@@ -771,10 +825,12 @@ export async function registerResponseRoutes(
let parsed: ResponseRequest;
let validation: StructuredValidation | undefined;
let pendingAttachments: PendingAttachment[];
try {
parsed = parseResponseRequest(request.body);
validation = createStructuredValidation(parsed.text.format);
validateResponseToolRequest(parsed);
pendingAttachments = responseInputAttachments(parsed);
} catch (error) {
return sendRouteError(reply, error);
}
@@ -788,6 +844,14 @@ export async function registerResponseRoutes(
"model",
));
}
const firstImage = pendingAttachments.find((attachment) => attachment.kind === "image");
if (firstImage && !model.supports_image_input) {
return sendRouteError(reply, new AttachmentInputError(
`Model '${parsed.model}' is not configured for image input`,
"unsupported_parameter",
firstImage.param,
));
}
const lease = coordinator.begin();
if (!lease) return sendBusy(reply);
@@ -795,6 +859,7 @@ export async function registerResponseRoutes(
let responseCompleted = false;
let acquired = false;
let materialized: MaterializedAttachments | undefined;
const cancelOnDisconnect = () => {
if (!responseCompleted) coordinator.cancel(abortController, "client disconnected");
};
@@ -827,12 +892,16 @@ export async function registerResponseRoutes(
responseCompleted = true;
return reply;
}
materialized = await materializeAttachments(
config.resolvedWorkingDirectory,
pendingAttachments,
);
const chain = parsed.previous_response_id
? await store.loadChain(parsed.previous_response_id)
: [];
validateResponseFunctionHistory(parsed, chain, inputItems);
const prompt = buildResponsesPrompt(parsed, chain, inputItems);
const prompt = buildResponsesPrompt(parsed, chain, inputItems, materialized.references);
const deadline = Date.now() + config.timeout_seconds * 1000;
const toolRequest = responseFunctionToolDecisionRequest(parsed);
@@ -918,6 +987,11 @@ export async function registerResponseRoutes(
} finally {
request.raw.off("aborted", cancelOnDisconnect);
reply.raw.off("close", cancelOnDisconnect);
try {
await materialized?.cleanup();
} catch {
process.stderr.write("警告:无法清理本次请求的临时附件目录。\n");
}
if (!acquired) coordinator.cancel(abortController, "request ended before execution");
coordinator.finish(abortController);
}
@@ -1006,7 +1080,12 @@ function findUnsupportedParameter(body: unknown): string | undefined {
if (!Array.isArray(item.content)) continue;
for (const [partIndex, part] of item.content.entries()) {
if (!isRecord(part)) continue;
if (part.type !== "input_text" && part.type !== "output_text") {
if (
part.type !== "input_text"
&& part.type !== "output_text"
&& part.type !== "input_image"
&& part.type !== "input_file"
) {
return `input.${itemIndex}.content.${partIndex}.type`;
}
}
@@ -1014,6 +1093,61 @@ function findUnsupportedParameter(body: unknown): string | undefined {
return undefined;
}
function responseInputAttachments(request: ResponseRequest): PendingAttachment[] {
if (typeof request.input === "string") return [];
const attachments: PendingAttachment[] = [];
for (const [itemIndex, item] of request.input.entries()) {
if (item.type === "function_call" || item.type === "function_call_output") continue;
if (typeof item.content === "string") continue;
for (const [partIndex, part] of item.content.entries()) {
const baseParam = `input.${itemIndex}.content.${partIndex}`;
if (part.type === "input_image") {
if (part.file_id !== undefined) {
throw new AttachmentInputError(
"file_id image inputs require an OpenAI Files service, which this bridge does not implement",
"unsupported_parameter",
`${baseParam}.file_id`,
);
}
if (part.detail !== undefined && part.detail !== "auto") {
throw new AttachmentInputError(
"Command Code does not expose image detail controls; omit detail or use 'auto'",
"unsupported_parameter",
`${baseParam}.detail`,
);
}
attachments.push(decodeImageDataUrl(
part.image_url!,
`${baseParam}.image_url`,
));
continue;
}
if (part.type !== "input_file") continue;
if (part.file_url !== undefined) {
throw new AttachmentInputError(
"Remote file URLs are not fetched; provide UTF-8 text through file_data",
"unsupported_parameter",
`${baseParam}.file_url`,
);
}
if (part.file_id !== undefined) {
throw new AttachmentInputError(
"file_id inputs require an OpenAI Files service, which this bridge does not implement",
"unsupported_parameter",
`${baseParam}.file_id`,
);
}
attachments.push(decodeTextFileData(
part.file_data!,
part.filename!,
`${baseParam}.file_data`,
));
}
}
validateAttachmentBatch(attachments);
return attachments;
}
function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] {
if (typeof input === "string") {
return [{
@@ -1050,7 +1184,11 @@ function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] {
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 })),
: item.content.flatMap((part) => (
part.type === "input_text" || part.type === "output_text"
? [{ type: part.type, text: part.text }]
: []
)),
};
});
}
@@ -1059,6 +1197,7 @@ function buildResponsesPrompt(
request: ResponseRequest,
chain: StoredResponse[],
currentInput: ResponseInputItem[],
attachmentReferences: ReadonlyMap<string, AttachmentReference>,
): string {
const history: Array<ResponseInputItem | ResponseOutputItem> = [];
for (const stored of chain) {
@@ -1076,12 +1215,16 @@ function buildResponsesPrompt(
tool_choice: effectiveResponseToolChoice(request),
parallel_tool_calls: request.parallel_tool_calls ?? true,
};
const attachments = attachmentPromptBlock(attachmentReferences);
if (usesResponseToolCalling(request, history)) {
const toolBoundary = attachments
? "除读取下方 API 附件临时路径所必需的 read_file 外,不要使用 Command Code 自身工具替代外部 tools;附件读取只是在消费用户输入。"
: "不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 input、tools 和 function_call_output 作出决定。";
return [
"下面 JSON 对象是外部客户端提交的一次 OpenAI Responses 工具调用任务,input 包含完整历史。",
"你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。",
"不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 input、tools 和 function_call_output 作出决定。",
toolBoundary,
"严格执行 input 中的 system、developer、user 指令和当前 instructions,并结合 message、function_call、function_call_output 及其他已有 output item 理解完整历史。",
"历史 reasoning item 只包含此前公开的 summary 文本记录,不是可恢复的内部推理状态;仅把 summary 当作普通历史上下文。",
"只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:",
@@ -1091,6 +1234,7 @@ function buildResponsesPrompt(
"tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。",
"需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有 function_call_output 且信息足够时返回 final。工具调用轮次禁止生成面向用户的正文。",
formatDirective(request.text.format),
...(attachments ? [attachments] : []),
"JSON 数据开始:",
JSON.stringify(envelope),
].join("\n\n");
@@ -1102,6 +1246,7 @@ function buildResponsesPrompt(
"历史 reasoning item 只包含此前公开的 summary 文本记录,不是可恢复的内部推理状态;仅把 summary 当作普通历史上下文。",
"严格执行最后一个用户任务。不要复述 JSON,不要输出角色标签,不要暴露内部思考。",
formatDirective(request.text.format),
...(attachments ? [attachments] : []),
"JSON 数据开始:",
JSON.stringify(envelope),
].join("\n\n");
@@ -1553,6 +1698,14 @@ function startSse(reply: FastifyReply, messageId: string): ResponsesSseWriter {
}
function sendRouteError(reply: FastifyReply, error: unknown) {
if (error instanceof AttachmentInputError) {
return reply.status(400).send(openAIError(
error.message,
error.code,
"invalid_request_error",
error.param,
));
}
if (error instanceof ResponseApiError) {
return reply.status(error.statusCode).send(openAIError(error.message, error.code, error.type, error.param));
}