diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c23359689..9865a2cc8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Telegram: route approval button callback queries onto a separate sequentializer lane so plugin approval clicks can resolve immediately instead of deadlocking behind the blocked agent turn. (#64979) Thanks @nk3750. - Agents/Anthropic replay: preserve immutable signed-thinking replay safety across stored and live reruns, keep non-thinking embedded `tool_result` user blocks intact, and drop conflicting preserved tool IDs before validation so retries stop degrading into omitted tool calls. (#65126) Thanks @shakkernerd. - Telegram/direct sessions: keep commentary-only assistant fallback payloads out of visible direct delivery, so Codex planning chatter cannot leak into Telegram DMs when a run has no `final_answer` text. (#65112) Thanks @vincentkoc. +- Infra/net: fix multipart FormData fields (including `model`) being silently dropped when a guarded runtime fetch body crosses a FormData implementation boundary, restoring OpenAI audio transcription requests that failed with HTTP 400. (#64349) Thanks @petr-sloup. ## 2026.4.11 diff --git a/src/infra/net/runtime-fetch.test.ts b/src/infra/net/runtime-fetch.test.ts new file mode 100644 index 00000000000..4736b976a22 --- /dev/null +++ b/src/infra/net/runtime-fetch.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchWithRuntimeDispatcher } from "./runtime-fetch.js"; +import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js"; + +class RuntimeFormData { + readonly records: Array<{ + name: string; + value: unknown; + filename?: string; + }> = []; + + append(name: string, value: unknown, filename?: string): void { + this.records.push({ + name, + value, + ...(typeof filename === "string" ? { filename } : {}), + }); + } + + *entries(): IterableIterator<[string, unknown]> { + for (const record of this.records) { + yield [record.name, record.value]; + } + } + + get [Symbol.toStringTag](): string { + return "FormData"; + } +} + +afterEach(() => { + Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); +}); + +describe("fetchWithRuntimeDispatcher", () => { + it("normalizes global FormData bodies into the runtime FormData implementation", async () => { + const runtimeFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + // init.body was rebuilt as RuntimeFormData by normalizeRuntimeFormData; + // BodyInit and RuntimeFormData live in separate type namespaces so a double cast is needed. + const body = init?.body as unknown as RuntimeFormData; + expect(body).toBeInstanceOf(RuntimeFormData); + expect(body.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "model", + value: "gpt-4o-transcribe", + }), + expect.objectContaining({ + name: "file", + filename: "clip.ogg", + }), + ]), + ); + return new Response("ok", { status: 200 }); + }); + + (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { + Agent: class MockAgent {}, + EnvHttpProxyAgent: class MockEnvHttpProxyAgent {}, + FormData: RuntimeFormData, + ProxyAgent: class MockProxyAgent {}, + fetch: runtimeFetch, + }; + + const form = new FormData(); + form.append("file", new Blob([new Uint8Array([1, 2, 3])], { type: "audio/ogg" }), "clip.ogg"); + form.append("model", "gpt-4o-transcribe"); + + const response = await fetchWithRuntimeDispatcher("https://example.com/upload", { + method: "POST", + headers: { + "content-length": "999", + "content-type": "multipart/form-data; boundary=stale", + }, + body: form, + }); + + expect(response.status).toBe(200); + expect(runtimeFetch).toHaveBeenCalledTimes(1); + const sentInit = runtimeFetch.mock.calls[0]?.[1] as RequestInit; + const sentHeaders = new Headers(sentInit.headers); + expect(sentHeaders.has("content-length")).toBe(false); + expect(sentHeaders.has("content-type")).toBe(false); + }); +}); diff --git a/src/infra/net/runtime-fetch.ts b/src/infra/net/runtime-fetch.ts index 2cc41ea720e..aea93c595ee 100644 --- a/src/infra/net/runtime-fetch.ts +++ b/src/infra/net/runtime-fetch.ts @@ -1,10 +1,74 @@ import type { Dispatcher } from "undici"; -import { loadUndiciRuntimeDeps } from "./undici-runtime.js"; +import { loadUndiciRuntimeDeps, type UndiciRuntimeDeps } from "./undici-runtime.js"; export type DispatcherAwareRequestInit = RequestInit & { dispatcher?: Dispatcher }; type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; +type RuntimeFormDataCtor = NonNullable; + +type FormDataEntryValueWithOptionalName = FormDataEntryValue & { name?: string }; + +function isFormDataLike(value: unknown): value is FormData { + return ( + typeof value === "object" && + value !== null && + typeof (value as FormData).entries === "function" && + (value as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "FormData" + ); +} + +function normalizeRuntimeFormData( + body: unknown, + RuntimeFormData: RuntimeFormDataCtor | undefined, +): BodyInit | null | undefined { + if (!isFormDataLike(body) || typeof RuntimeFormData !== "function") { + return body as BodyInit | null | undefined; + } + if (body instanceof RuntimeFormData) { + return body; + } + + const next = new RuntimeFormData(); + for (const [key, value] of body.entries()) { + const namedValue = value as FormDataEntryValueWithOptionalName; + // File.name is the standard filename property; skip empty/whitespace-only values + const fileName = + typeof namedValue.name === "string" && namedValue.name.trim() ? namedValue.name : undefined; + if (fileName) { + next.append(key, value, fileName); + } else { + next.append(key, value); + } + } + // undici.FormData is structurally compatible with BodyInit but lives in a separate + // type namespace; the cast avoids a cross-implementation assignability error. + return next as unknown as BodyInit; +} + +function normalizeRuntimeRequestInit( + init: DispatcherAwareRequestInit | undefined, + RuntimeFormData: RuntimeFormDataCtor | undefined, +): DispatcherAwareRequestInit | undefined { + if (!init?.body) { + return init; + } + + const body = normalizeRuntimeFormData(init.body, RuntimeFormData); + if (body === init.body) { + return init; + } + + const headers = new Headers(init.headers); + headers.delete("content-length"); + headers.delete("content-type"); + return { + ...init, + headers, + body, + }; +} + export function isMockedFetch(fetchImpl: FetchLike | undefined): boolean { if (typeof fetchImpl !== "function") { return false; @@ -16,9 +80,13 @@ export async function fetchWithRuntimeDispatcher( input: RequestInfo | URL, init?: DispatcherAwareRequestInit, ): Promise { - const runtimeFetch = loadUndiciRuntimeDeps().fetch as unknown as ( + const runtimeDeps = loadUndiciRuntimeDeps(); + const runtimeFetch = runtimeDeps.fetch as unknown as ( input: RequestInfo | URL, init?: DispatcherAwareRequestInit, ) => Promise; - return (await runtimeFetch(input, init)) as Response; + return (await runtimeFetch( + input, + normalizeRuntimeRequestInit(init, runtimeDeps.FormData), + )) as Response; } diff --git a/src/infra/net/undici-runtime.ts b/src/infra/net/undici-runtime.ts index 23eac3868de..5cf8434c948 100644 --- a/src/infra/net/undici-runtime.ts +++ b/src/infra/net/undici-runtime.ts @@ -5,6 +5,7 @@ export const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS export type UndiciRuntimeDeps = { Agent: typeof import("undici").Agent; EnvHttpProxyAgent: typeof import("undici").EnvHttpProxyAgent; + FormData?: typeof import("undici").FormData; ProxyAgent: typeof import("undici").ProxyAgent; fetch: typeof import("undici").fetch; }; @@ -44,6 +45,7 @@ export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps { return { Agent: undici.Agent, EnvHttpProxyAgent: undici.EnvHttpProxyAgent, + FormData: undici.FormData, ProxyAgent: undici.ProxyAgent, fetch: undici.fetch, };