fix: use undici runtime fetch for dispatcher flows

This commit is contained in:
Peter Steinberger
2026-04-05 07:18:27 +01:00
parent 757fe86309
commit a084e46536
6 changed files with 155 additions and 7 deletions

View File

@@ -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(
{

View File

@@ -332,7 +332,7 @@ function convertResponsesTools(
tools: NonNullable<Context["tools"]>,
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<string, unknown>;
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<Context["tools"]>,
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<unknown>,
output: MutableAssistantOutput,
@@ -1262,7 +1296,10 @@ function convertTools(
compat: ReturnType<typeof getCompat>,
model: OpenAIModeModel,
) {
const strict = resolveOpenAIStrictToolSetting(model, compat);
const strict = resolveStrictToolFlagForInventory(
tools,
resolveOpenAIStrictToolSetting(model, compat),
);
return tools.map((tool) => ({
type: "function",
function: {

View File

@@ -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<string, unknown>).fetch = globalFetch as typeof fetch;
(globalThis as Record<string, unknown>)[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<string, unknown>).fetch = originalGlobalFetch;
}
});
it("blocks redirect chains that hop to private hosts", async () => {
const lookupFn = createPublicLookup();
const fetchImpl = await expectRedirectFailure({

View File

@@ -181,8 +181,8 @@ function rewriteRedirectInitForMethod(params: {
}
export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<GuardedFetchResult> {
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<G
...(signal ? { signal } : {}),
};
const fetcher =
dispatcher && !params.fetchImpl
? (loadUndiciRuntimeDeps().fetch as unknown as FetchLike)
: defaultFetch;
const response = await fetcher(parsedUrl.toString(), init);
if (isRedirectStatus(response.status)) {

View File

@@ -32,6 +32,7 @@ beforeEach(() => {
Agent: agentCtor,
EnvHttpProxyAgent: envHttpProxyAgentCtor,
ProxyAgent: proxyAgentCtor,
fetch: vi.fn(),
};
});

View File

@@ -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,
};
}