From a084e465369ca5e9231c2ab64c4a8d6557430649 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 07:18:27 +0100 Subject: [PATCH] fix: use undici runtime fetch for dispatcher flows --- src/agents/openai-transport-stream.test.ts | 66 +++++++++++++++++++++- src/agents/openai-transport-stream.ts | 41 +++++++++++++- src/infra/net/fetch-guard.ssrf.test.ts | 41 ++++++++++++++ src/infra/net/fetch-guard.ts | 8 ++- src/infra/net/ssrf.dispatcher.test.ts | 1 + src/infra/net/undici-runtime.ts | 5 +- 6 files changed, 155 insertions(+), 7 deletions(-) diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index bee07b42a28..eb41a586535 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -580,7 +580,7 @@ describe("openai transport stream", () => { { name: "lookup_weather", description: "Get forecast", - parameters: { type: "object", properties: {} }, + parameters: { type: "object", properties: {}, additionalProperties: false }, }, ], } as never, @@ -590,6 +590,37 @@ describe("openai transport stream", () => { expect(params.tools?.[0]?.strict).toBe(true); }); + it("omits responses strict tool shaping when a native OpenAI tool schema is not strict-compatible", () => { + const params = buildOpenAIResponsesParams( + { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-responses">, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "read", + description: "Read file", + parameters: { type: "object", properties: {} }, + }, + ], + } as never, + undefined, + ) as { tools?: Array<{ strict?: boolean }> }; + + expect(params.tools?.[0]).not.toHaveProperty("strict"); + }); + it("omits responses strict tool shaping for proxy-like OpenAI routes", () => { const params = buildOpenAIResponsesParams( { @@ -1012,7 +1043,7 @@ describe("openai transport stream", () => { { name: "lookup_weather", description: "Get forecast", - parameters: { type: "object", properties: {} }, + parameters: { type: "object", properties: {}, additionalProperties: false }, }, ], } as never, @@ -1022,6 +1053,37 @@ describe("openai transport stream", () => { expect(params.tools?.[0]?.function?.strict).toBe(true); }); + it("omits completions strict tool shaping when a native OpenAI tool schema is not strict-compatible", () => { + const params = buildOpenAICompletionsParams( + { + id: "gpt-5", + name: "GPT-5", + api: "openai-completions", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "read", + description: "Read file", + parameters: { type: "object", properties: {} }, + }, + ], + } as never, + undefined, + ) as { tools?: Array<{ function?: { strict?: boolean } }> }; + + expect(params.tools?.[0]?.function).not.toHaveProperty("strict"); + }); + it("uses Mistral compat defaults for direct Mistral completions providers", () => { const params = buildOpenAICompletionsParams( { diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 3f5e08d5f0e..5a77b1fb053 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -332,7 +332,7 @@ function convertResponsesTools( tools: NonNullable, options?: { strict?: boolean | null }, ): FunctionTool[] { - const strict = options?.strict; + const strict = resolveStrictToolFlagForInventory(tools, options?.strict); if (strict === undefined) { return tools.map((tool) => ({ type: "function", @@ -350,6 +350,40 @@ function convertResponsesTools( })); } +function isStrictOpenAIJsonSchemaCompatible(schema: unknown): boolean { + if (Array.isArray(schema)) { + return schema.every((entry) => isStrictOpenAIJsonSchemaCompatible(entry)); + } + if (!schema || typeof schema !== "object") { + return true; + } + + const record = schema as Record; + if ("anyOf" in record || "oneOf" in record || "allOf" in record) { + return false; + } + if (Array.isArray(record.type)) { + return false; + } + if (record.type === "object" && record.additionalProperties !== false) { + return false; + } + + return Object.values(record).every((entry) => isStrictOpenAIJsonSchemaCompatible(entry)); +} + +function resolveStrictToolFlagForInventory( + tools: NonNullable, + strict: boolean | null | undefined, +): boolean | undefined { + if (strict !== true) { + return strict === false ? false : undefined; + } + return tools.every((tool) => isStrictOpenAIJsonSchemaCompatible(tool.parameters)) + ? true + : undefined; +} + async function processResponsesStream( openaiStream: AsyncIterable, output: MutableAssistantOutput, @@ -1262,7 +1296,10 @@ function convertTools( compat: ReturnType, model: OpenAIModeModel, ) { - const strict = resolveOpenAIStrictToolSetting(model, compat); + const strict = resolveStrictToolFlagForInventory( + tools, + resolveOpenAIStrictToolSetting(model, compat), + ); return tools.map((tool) => ({ type: "function", function: { diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index a63d2c69d80..b64f2d4a4c4 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -4,6 +4,7 @@ import { GUARDED_FETCH_MODE, retainSafeHeadersForCrossOriginRedirectHeaders, } from "./fetch-guard.js"; +import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js"; function redirectResponse(location: string): Response { return new Response(null, { @@ -107,6 +108,7 @@ describe("fetchWithSsrFGuard hardening", () => { afterEach(() => { vi.unstubAllEnvs(); + Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); }); it("blocks private and legacy loopback literals before fetch", async () => { @@ -210,6 +212,45 @@ describe("fetchWithSsrFGuard hardening", () => { await result.release(); }); + it("uses runtime undici fetch when attaching a dispatcher", async () => { + const runtimeFetch = vi.fn(async () => okResponse()); + const originalGlobalFetch = globalThis.fetch; + const globalFetch = vi.fn(async () => { + throw new Error("global fetch should not be used when a dispatcher is attached"); + }); + + class MockAgent { + constructor(readonly options: unknown) {} + } + class MockEnvHttpProxyAgent { + constructor(readonly options: unknown) {} + } + class MockProxyAgent { + constructor(readonly options: unknown) {} + } + + (globalThis as Record).fetch = globalFetch as typeof fetch; + (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { + Agent: MockAgent, + EnvHttpProxyAgent: MockEnvHttpProxyAgent, + ProxyAgent: MockProxyAgent, + fetch: runtimeFetch, + }; + + try { + const result = await fetchWithSsrFGuard({ + url: "https://public.example/resource", + lookupFn: createPublicLookup(), + }); + + expect(runtimeFetch).toHaveBeenCalledTimes(1); + expect(globalFetch).not.toHaveBeenCalled(); + await result.release(); + } finally { + (globalThis as Record).fetch = originalGlobalFetch; + } + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = createPublicLookup(); const fetchImpl = await expectRedirectFailure({ diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 520c7f361c3..66b8030f78f 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -181,8 +181,8 @@ function rewriteRedirectInitForMethod(params: { } export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { - const fetcher: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch; - if (!fetcher) { + const defaultFetch: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch; + if (!defaultFetch) { throw new Error("fetch is not available"); } @@ -249,6 +249,10 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { Agent: agentCtor, EnvHttpProxyAgent: envHttpProxyAgentCtor, ProxyAgent: proxyAgentCtor, + fetch: vi.fn(), }; }); diff --git a/src/infra/net/undici-runtime.ts b/src/infra/net/undici-runtime.ts index e3000293fce..8d567d714bf 100644 --- a/src/infra/net/undici-runtime.ts +++ b/src/infra/net/undici-runtime.ts @@ -6,6 +6,7 @@ export type UndiciRuntimeDeps = { Agent: typeof import("undici").Agent; EnvHttpProxyAgent: typeof import("undici").EnvHttpProxyAgent; ProxyAgent: typeof import("undici").ProxyAgent; + fetch: typeof import("undici").fetch; }; function isUndiciRuntimeDeps(value: unknown): value is UndiciRuntimeDeps { @@ -14,7 +15,8 @@ function isUndiciRuntimeDeps(value: unknown): value is UndiciRuntimeDeps { value !== null && typeof (value as UndiciRuntimeDeps).Agent === "function" && typeof (value as UndiciRuntimeDeps).EnvHttpProxyAgent === "function" && - typeof (value as UndiciRuntimeDeps).ProxyAgent === "function" + typeof (value as UndiciRuntimeDeps).ProxyAgent === "function" && + typeof (value as UndiciRuntimeDeps).fetch === "function" ); } @@ -30,5 +32,6 @@ export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps { Agent: undici.Agent, EnvHttpProxyAgent: undici.EnvHttpProxyAgent, ProxyAgent: undici.ProxyAgent, + fetch: undici.fetch, }; }