diff --git a/docs/reference/api.mdx b/docs/reference/api.mdx index a3e2ddc85..648a032b2 100644 --- a/docs/reference/api.mdx +++ b/docs/reference/api.mdx @@ -86,6 +86,8 @@ Every media endpoint on this page, plus web search, web fetch, and embeddings be Image generation accepts JSON `{ model, prompt }`, optional `Accept: text/event-stream`, and `?response_format=binary`. Image edits accept multipart or provider-native JSON on supported models. xAI accepts `image: { url }` or `image: { file_id }`, or up to five sources in `images`; the adapter preserves `storage_options` and converts multipart `n` to a number. `sdwebui` and `comfyui` need no stored key. +Responses `input_image` accepts `image_url` (URL or `data:` base64). A bare `file_id` is not resolved when either the registered Responses translator or worker converter converts a request to Chat Completions; it returns HTTP 400 instead of being sent as an image URL. This applies to message images and images inside `function_call_output`, with either stream setting. When both references are present, `image_url` takes precedence. The file reference is preserved on native-compatible, same-format Responses transports, while gateway normalizations still apply. + TTS is JSON `{ model, input }` plus optional `language`. `?response_format=mp3` (default) or `json`. Voices: `GET /v1/audio/voices?provider={p}&lang=xx` with `p` in `elevenlabs`, `deepgram`, `inworld`, `minimax`, `minimax-cn`, `edge-tts`, `local-device`. Each voice row includes `model` ready for `/v1/audio/speech`. STT and translation are multipart: `model`, `file`. Translation forwards to the provider `/audio/translations` path. Only OpenAI-format STT providers (OpenAI, Groq, Local Whisper) have one; the others return 400. The transcription and translation handlers declare a maximum route duration of 300 seconds. This is a server execution setting, not a 300-second audio-file limit. diff --git a/open-sse/handlers/responsesHandler.js b/open-sse/handlers/responsesHandler.js index 322855fcf..ee67b970d 100644 --- a/open-sse/handlers/responsesHandler.js +++ b/open-sse/handlers/responsesHandler.js @@ -50,8 +50,14 @@ export async function handleResponsesCore({ body, modelInfo, credentials, log, o } } - // Convert Responses API format to Chat Completions format - const convertedBody = convertResponsesApiFormat(body); + // Keep converter validation failures inside the same HTTP result contract as chatCore. + let convertedBody; + try { + convertedBody = convertResponsesApiFormat(body); + } catch (error) { + if (error?.statusCode !== HTTP_STATUS.BAD_REQUEST) throw error; + return createErrorResult(HTTP_STATUS.BAD_REQUEST, error.message); + } // Preserve client's stream preference (matches OpenClaw behavior) // Default to false if omitted: Boolean(undefined) = false diff --git a/open-sse/translator/formats/responsesApi.js b/open-sse/translator/formats/responsesApi.js index 8a088cbc2..c41d02c9a 100644 --- a/open-sse/translator/formats/responsesApi.js +++ b/open-sse/translator/formats/responsesApi.js @@ -1,5 +1,17 @@ import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js"; import { isObject, isString } from "../../../src/shared/utils/typeChecks.js"; +import { HTTP_STATUS } from "../../config/runtimeConfig.js"; + +/** Lower image references only when a URL is available; scoped file IDs need native Responses. */ +export function responsesImageUrl(part) { + if (part.image_url) return part.image_url; + if (part.file_id) { + const error = new Error("input_image.file_id is not supported for this provider; send image_url (URL or data: base64) instead"); + error.statusCode = HTTP_STATUS.BAD_REQUEST; + throw error; + } + return ""; +} const STORED_ITEM_REFERENCE_PATTERN = /^(?:at|msg|amsg|rs|lsh|fc|tsc|fco|ctc|ctco|tso|ws|ig|cmp|resp)_/; const STATELESS_CALL_ITEM_TYPES = new Set([ @@ -298,7 +310,7 @@ export function convertResponsesApiFormat(body) { if (c.type === RESPONSES_ITEM.INPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; if (c.type === RESPONSES_ITEM.OUTPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; if (c.type === RESPONSES_ITEM.INPUT_IMAGE) { - const url = c.image_url || c.file_id || ""; + const url = responsesImageUrl(c); return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url, detail: c.detail || "auto" } }; } return c; @@ -337,6 +349,13 @@ export function convertResponsesApiFormat(body) { // a genuine orphan (no call to answer) and is dropped rather than emitting // an unpairable tool message. if (item.call_id) { + // This worker converter keeps its existing tool-output serialization, + // but scoped image IDs must fail before their block type becomes text. + if (Array.isArray(item.output)) { + for (const part of item.output) { + if (part?.type === RESPONSES_ITEM.INPUT_IMAGE) responsesImageUrl(part); + } + } pendingToolResults.push({ role: ROLE.TOOL, tool_call_id: item.call_id, diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 90fdad73d..7dfdb9bf8 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -14,6 +14,7 @@ import { repairMissingResponsesCallIds, buildDeclaredToolTypes, resolveDeclaredCustom, + responsesImageUrl, } from "../formats/responsesApi.js"; import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, VALID_OPENAI_CONTENT_TYPES } from "../schema/index.js"; import { collapseTextParts } from "../concerns/message.js"; @@ -88,7 +89,7 @@ function splitToolOutputImages(output) { } const images = output. filter((c) => c?.type === RESPONSES_ITEM.INPUT_IMAGE). - map((c) => ({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url: c.image_url || c.file_id || "", detail: c.detail || "auto" } })); + map((c) => ({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url: responsesImageUrl(c), detail: c.detail || "auto" } })); const text = coerceResponsesOutput(output.filter((c) => c?.type !== RESPONSES_ITEM.INPUT_IMAGE)); const note = "[tool returned an image; see attached]"; return { content: text ? `${text}\n${note}` : note, images }; @@ -260,8 +261,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) if (c.type === RESPONSES_ITEM.INPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; if (c.type === RESPONSES_ITEM.OUTPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; if (c.type === RESPONSES_ITEM.INPUT_IMAGE) { - const url = c.image_url || c.file_id || ""; - return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url, detail: c.detail || "auto" } }; + return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url: responsesImageUrl(c), detail: c.detail || "auto" } }; } return c; }) : diff --git a/tests/translator/bugs-codexCli-responses.test.js b/tests/translator/bugs-codexCli-responses.test.js index 8e96d7292..84ff35718 100644 --- a/tests/translator/bugs-codexCli-responses.test.js +++ b/tests/translator/bugs-codexCli-responses.test.js @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest"; import "./registerAll.js"; import { translateRequest } from "../../open-sse/translator/index.js"; import { FORMATS } from "../../open-sse/translator/formats.js"; +import { convertResponsesApiFormat } from "../../open-sse/translator/formats/responsesApi.js"; const R2O = (body) => translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", body, true, null, null); const O2R = (body) => translateRequest(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, "m", body, true, null, null); @@ -32,18 +33,83 @@ describe("Codex CLI Responses → OpenAI", () => { expect(typeof asst.tool_calls[0].function.arguments).toBe("string"); }); - // openai-responses.js:75-77 — input_image uses file_id as raw url - // KNOWN BUG - it.fails("input_image with file_id is not used as a raw url", () => { - const out = R2O({ - input: [{ type: "message", role: "user", content: [ - { type: "input_image", file_id: "file-abc" }, - ] }], - }); - const userMsg = out.messages.find((m) => m.role === "user"); - const img = Array.isArray(userMsg?.content) ? userMsg.content.find((c) => c.type === "image_url") : null; - // A bare file_id is not a valid image URL - expect(img?.image_url?.url === "file-abc").toBe(false); + // input_image file_id cannot be resolved here: reject with 400, never emit it as a URL. + it.each([true, false])("input_image with file_id is rejected with statusCode 400 (stream=%s)", (stream) => { + const body = { stream, input: [{ type: "message", role: "user", content: [ + { type: "input_image", file_id: "file-abc" }, + ] }] }; + expect(() => translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", body, stream, null, null)) + .toThrowError(expect.objectContaining({ + statusCode: 400, + message: expect.stringMatching(/file_id is not supported/), + })); + }); + + it.each([true, false])("function_call_output input_image with file_id is rejected with statusCode 400 (stream=%s)", (stream) => { + const body = { stream, input: [ + { type: "function_call", call_id: "c1", name: "f", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: [{ type: "input_image", file_id: "file-abc" }] }, + ] }; + expect(() => translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", body, stream, null, null)) + .toThrowError(expect.objectContaining({ + statusCode: 400, + message: expect.stringMatching(/file_id is not supported/), + })); + }); + + it.each([true, false])("same-format Responses preserves input_image file references (stream=%s)", (stream) => { + const body = { stream, input: [ + { type: "message", role: "user", content: [{ type: "input_image", file_id: "file-user" }] }, + { type: "function_call", call_id: "c1", name: "f", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: [{ type: "input_image", file_id: "file-tool" }] }, + ] }; + const out = translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI_RESPONSES, "m", body, stream, null, null); + expect(out.input[0].content).toEqual([{ type: "input_image", file_id: "file-user" }]); + expect(out.input[2].output).toEqual([{ type: "input_image", file_id: "file-tool" }]); + }); + + it.each([true, false])("input_image with image_url (URL and base64) is preserved; image_url wins over file_id (stream=%s)", (stream) => { + for (const url of ["https://example.com/a.png", "data:image/png;base64,AAAA"]) { + const body = { stream, input: [{ type: "message", role: "user", content: [ + { type: "input_image", image_url: url, file_id: "file-abc" }, + ] }] }; + const out = translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", body, stream, null, null); + const img = out.messages.find((m) => m.role === "user").content.find((c) => c.type === "image_url"); + expect(img.image_url.url).toBe(url); + } + }); + + it.each([true, false])("converter rejects bare file IDs and preserves URL priority (stream=%s)", (stream) => { + const body = (reference) => ({ stream, input: [{ type: "message", role: "user", content: [ + { type: "input_image", ...reference }, + ] }] }); + expect(() => convertResponsesApiFormat(body({ file_id: "file-abc" }))) + .toThrowError(expect.objectContaining({ statusCode: 400, message: expect.stringContaining("send image_url") })); + for (const url of ["https://example.com/a.png", "data:image/png;base64,AAAA"]) { + expect(convertResponsesApiFormat(body({ image_url: url, file_id: "file-abc" })).messages[0].content) + .toEqual([{ type: "image_url", image_url: { url, detail: "auto" } }]); + } + }); + + it.each([true, false])("preserves tool-output text and image URLs without splitting parallel results (stream=%s)", (stream) => { + for (const url of ["https://example.com/a.png", "data:image/png;base64,AAAA"]) { + const body = { stream, input: [ + { type: "function_call", call_id: "c1", name: "capture", arguments: "{}" }, + { type: "function_call", call_id: "c2", name: "inspect", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: [ + { type: "input_text", text: "captured" }, + { type: "input_image", image_url: url, file_id: "file-abc", detail: "high" }, + ] }, + { type: "function_call_output", call_id: "c2", output: "inspected" }, + ] }; + const out = translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", body, stream, null, null); + expect(out.messages.map((message) => message.role)).toEqual(["assistant", "tool", "tool", "user"]); + expect(out.messages[1]).toMatchObject({ tool_call_id: "c1", content: "captured\n[tool returned an image; see attached]" }); + expect(out.messages[2]).toMatchObject({ tool_call_id: "c2", content: "inspected" }); + expect(out.messages[3].content).toEqual([ + { type: "image_url", image_url: { url, detail: "high" } }, + ]); + } }); // The unit-level openaiResponsesToOpenAIRequest test only checks the merged diff --git a/tests/unit/responses-file-id-chatcore.test.js b/tests/unit/responses-file-id-chatcore.test.js new file mode 100644 index 000000000..8b07479ae --- /dev/null +++ b/tests/unit/responses-file-id-chatcore.test.js @@ -0,0 +1,205 @@ +/** + * Registered Responses translation must return a client HTTP 400 for scoped + * image file IDs before provider dispatch. Keep the real HTTP error builder. + */ +import "../translator/registerAll.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })); + +vi.mock("../../open-sse/executors/index.js", () => ({ + getExecutor: vi.fn(() => ({ noAuth: true, execute: mocks.execute })), +})); + +vi.mock("../../open-sse/utils/requestLogger.js", () => ({ + createRequestLogger: vi.fn(async () => ({ + logClientRawRequest: vi.fn(), + logRawRequest: vi.fn(), + logTargetRequest: vi.fn(), + logProviderResponse: vi.fn(), + logConvertedResponse: vi.fn(), + logError: vi.fn(), + })), +})); + +vi.mock("../../open-sse/utils/clientDetector.js", () => ({ + detectClientTool: vi.fn(() => null), + isNativePassthrough: vi.fn(() => false), + isCodexOriginatedHeaders: vi.fn(() => false), +})); + +vi.mock("../../open-sse/utils/bypassHandler.js", () => ({ + handleBypassRequest: vi.fn(() => null), +})); + +vi.mock("../../open-sse/services/tokenRefresh.js", () => ({ + refreshWithRetry: vi.fn(), +})); + +// Spread the real module so main's enforceCursorCapabilities (and future +// exports) remain available after chatCore starts importing them. +vi.mock("../../open-sse/providers/capabilities.js", async (importOriginal) => ({ + ...(await importOriginal()), + getCapabilitiesForModel: vi.fn(() => ({})), + resolveModelLimits: vi.fn(() => ({ contextWindow: 0, maxOutput: 0, known: false, source: "default" })), +})); + +vi.mock("../../open-sse/translator/concerns/modality.js", () => ({ + stripUnsupportedModalities: vi.fn(() => false), + hasMediaBlocks: vi.fn(() => false), +})); + +vi.mock("../../open-sse/translator/concerns/prefetch.js", () => ({ + prefetchRemoteImages: vi.fn(async () => 0), +})); + +vi.mock("../../open-sse/handlers/chatCore/requestDetail.js", async (importOriginal) => ({ + ...(await importOriginal()), + buildRequestDetail: vi.fn((detail) => detail), + extractRequestConfig: vi.fn((body, stream) => ({ body, stream })), + extractUsageFromResponse: vi.fn(() => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 })), + saveUsageStats: vi.fn(), +})); + +vi.mock("@/lib/usageDb.js", () => ({ + trackPendingRequest: vi.fn(), + appendRequestLog: vi.fn(() => Promise.resolve()), + saveRequestDetail: vi.fn(() => Promise.resolve()), + finishActiveSession: vi.fn(), + recordTokenSaverEvent: vi.fn(async () => {}), +})); + +vi.mock("@/lib/localDb", () => ({ + getSettings: vi.fn(async () => ({ requireApiKey: false })), + getApiKeyByKey: vi.fn(async () => null), + getApiKeyUsageLimitStatus: vi.fn(async () => ({ exceeded: false })), + getProviderConnections: vi.fn(async () => []), + getQuotaReservationPressure: vi.fn(async () => ({})), + getApiKeyProviderConnectionIds: vi.fn(async () => null), + getComboForModel: vi.fn(async () => null), +})); +vi.mock("../../src/sse/services/model.js", async (importOriginal) => ({ + ...(await importOriginal()), + getComboModels: vi.fn(async () => null), + getModelInfo: vi.fn(async (id) => id.startsWith("openai/") + ? { provider: "openai", model: "gpt-4o" } + : { provider: "groq", model: "llama-3.3-70b-versatile" }), + createRoutableModelIdChecker: vi.fn(() => async () => true), + loadCustomCapabilities: vi.fn(async () => null), +})); +vi.mock("../../src/sse/services/auth.js", () => ({ + resolveClientApiKey: vi.fn(async () => ({ apiKey: null, auth: { ok: true, stored: false } })), + getProviderCredentialsWithQuotaPreflight: vi.fn(async () => ({ apiKey: "test-key", connectionId: "test-connection" })), + projectProviderCredentials: vi.fn((credentials) => credentials), + isProviderConnectionModelLocked: vi.fn(() => false), + providerAllowsPublicNoAuthFallback: vi.fn(() => false), + markAccountUnavailable: vi.fn(async () => ({ shouldFallback: false })), + clearAccountError: vi.fn(), +})); +vi.mock("../../src/sse/services/apiKeyPolicy.js", () => ({ enforceApiKeyModelPolicy: vi.fn(async () => null) })); +vi.mock("../../src/sse/services/tokenRefresh.js", () => ({ updateProviderCredentials: vi.fn() })); +vi.mock("@/shared/services/providerCredentials", () => ({ refreshAndUpdateCredentials: vi.fn(async (_provider, credentials) => credentials) })); +vi.mock("../../open-sse/services/liveModelLimits.js", () => ({ warmLiveModelLimits: vi.fn(), getCachedLiveLimits: vi.fn(() => null) })); + +const { POST } = await import("../../src/app/api/v1/responses/route.js"); + +describe("public Responses file-ID rejection", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([true, false])("rejects before executor dispatch through POST and handleChat (stream=%s)", async (stream) => { + const response = await POST(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "groq/llama-3.3-70b-versatile", stream, input: [ + { type: "message", role: "user", content: [{ type: "input_image", file_id: "file-abc" }] }, + ] }), + })); + const payload = await response.text(); + expect(payload).toContain("input_image.file_id is not supported"); + if (response.headers.get("content-type")?.includes("text/event-stream")) { + expect(payload).toContain("400"); + } else { + expect(response.status).toBe(400); + } + expect(mocks.execute).not.toHaveBeenCalled(); + }); + + it.each([true, false])("dispatches URL/data images and preserves native file IDs (stream=%s)", async (stream) => { + for (const reference of [ + { image_url: "https://example.com/a.png", file_id: "file-abc" }, + { image_url: "data:image/png;base64,AAAA", file_id: "file-abc" }, + { file_id: "file-native" }, + ]) { + mocks.execute.mockClear(); + const native = !reference.image_url; + mocks.execute.mockImplementation(async ({ stream: upstreamStream }) => ({ + response: upstreamStream ? new Response(native + ? `event: response.created\ndata: ${JSON.stringify({ type: "response.created", response: { id: "resp-test", object: "response", model: "gpt-4o" } })}\n\nevent: response.output_item.added\ndata: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { id: "msg-test", type: "message", role: "assistant", content: [] } })}\n\nevent: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", item_id: "msg-test", output_index: 0, content_index: 0, delta: "image accepted" })}\n\nevent: response.output_item.done\ndata: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item: { id: "msg-test", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "image accepted", annotations: [] }] } })}\n\nevent: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { id: "resp-test", object: "response", status: "completed", output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "image accepted" }] }], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } })}\n\n` + : `data: ${JSON.stringify({ id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: "image accepted" }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`, + { headers: { "Content-Type": "text/event-stream" } }) : new Response(JSON.stringify(native ? { + id: "resp-test", object: "response", status: "completed", model: "gpt-4o", + output: [{ id: "msg-test", type: "message", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "image accepted", annotations: [] }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + } : { + id: "chatcmpl-test", object: "chat.completion", model: "test", + choices: [{ index: 0, message: { role: "assistant", content: "image accepted" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { headers: { "Content-Type": "application/json" } }), + url: "https://provider.invalid/v1/chat/completions", headers: {}, transformedBody: null, + })); + const response = await POST(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: native ? "openai/gpt-4o" : "groq/llama-3.3-70b-versatile", stream, + input: [{ type: "message", role: "user", content: [{ type: "input_image", ...reference }] }], + }), + })); + const payload = await response.text(); + expect(response.status, payload).toBe(200); + expect(payload).toContain("image accepted"); + expect(mocks.execute).toHaveBeenCalledTimes(1); + const dispatched = mocks.execute.mock.calls[0][0].body; + if (native) { + expect(dispatched.input[0].content).toEqual([{ type: "input_image", file_id: "file-native" }]); + } else { + expect(dispatched.messages[0].content).toEqual([{ type: "image_url", image_url: { url: reference.image_url, detail: "auto" } }]); + } + } + }); +}); + +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); + +const image = { type: "input_image", file_id: "file-abc" }; +const inputs = [ + [{ type: "message", role: "user", content: [image] }], + [ + { type: "function_call", call_id: "c1", name: "f", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: [image] }, + ], +]; + +describe("Responses image file IDs through chatCore", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([true, false])("returns HTTP 400 without upstream dispatch (stream=%s)", async (stream) => { + for (const input of inputs) { + const body = { model: "openai-compatible-test/vision-model", stream, input: structuredClone(input) }; + const result = await handleChatCore({ + body, + modelInfo: { provider: "openai-compatible-test", model: "vision-model" }, + credentials: { apiKey: "test-key", providerSpecificData: { baseUrl: "https://provider.invalid/v1" } }, + clientRawRequest: { endpoint: "/v1/responses", body, headers: {} }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + expect(result).toMatchObject({ success: false, status: 400 }); + expect(result.response.status).toBe(400); + expect(result.response.headers.get("content-type")).toContain("application/json"); + const payload = await result.response.json(); + expect(payload.error.message).toContain("input_image.file_id is not supported"); + expect(payload.error.message).toContain("send image_url"); + expect(mocks.execute).not.toHaveBeenCalled(); + } + }); +}); diff --git a/tests/unit/responses-file-id-handler.test.js b/tests/unit/responses-file-id-handler.test.js new file mode 100644 index 000000000..35512e430 --- /dev/null +++ b/tests/unit/responses-file-id-handler.test.js @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ handleChatCore: vi.fn() })); +vi.mock("../../open-sse/handlers/chatCore.js", () => mocks); +vi.mock("@/lib/usageDb.js", () => ({ recordTokenSaverEvent: vi.fn() })); +vi.mock("../../open-sse/utils/tokenSaverBridge.js", () => ({ + handlePonytailCommands: vi.fn(async () => null), DEFAULT_PONYTAIL_HELP: "help", +})); +const { handleResponsesCore } = await import("../../open-sse/handlers/responsesHandler.js"); + +describe("Responses worker handler file-reference validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.handleChatCore.mockResolvedValue({ + success: true, + response: new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" } }), + }); + }); + const scopedImage = { type: "input_image", file_id: "file-scoped" }; + const inputs = [ + [{ role: "user", content: [scopedImage] }], + [{ type: "function_call", call_id: "c1", name: "capture", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: [scopedImage] }], + ]; + it.each([false, true])("returns HTTP 400 before chat dispatch (stream=%s)", async (stream) => { + for (const input of inputs) { + const result = await handleResponsesCore({ + body: { model: "test", stream, input: structuredClone(input) }, + modelInfo: { provider: "groq", model: "test" }, credentials: {}, + }); + expect(result.success).toBe(false); + expect(result.status).toBe(400); + expect(result.response.status).toBe(400); + expect((await result.response.json()).error.message).toContain("file_id is not supported"); + expect(mocks.handleChatCore).not.toHaveBeenCalled(); + } + }); + + it.each([false, true])("preserves existing tool-output serialization and URL precedence (stream=%s)", async (stream) => { + const response = new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" } }); + mocks.handleChatCore.mockResolvedValue({ success: true, response }); + for (const output of ["plain tool output", [{ type: "input_text", text: "captured" }], + [{ type: "input_image", image_url: "https://example.com/image.png", file_id: "file-scoped" }]]) { + const result = await handleResponsesCore({ + body: { model: "test", stream, input: [ + { type: "function_call", call_id: "c1", name: "capture", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output }, + ] }, modelInfo: { provider: "groq", model: "test" }, credentials: {}, + }); + expect(result.response).toBe(response); + const dispatched = mocks.handleChatCore.mock.lastCall[0].body; + expect(dispatched.messages.find((message) => message.role === "tool").content) + .toBe(Array.isArray(output) ? JSON.stringify(output) : output); + } + }); +});