适配图片
This commit is contained in:
+158
-5
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user