mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 09:11:13 +00:00
fix: use undici runtime fetch for dispatcher flows
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -32,6 +32,7 @@ beforeEach(() => {
|
||||
Agent: agentCtor,
|
||||
EnvHttpProxyAgent: envHttpProxyAgentCtor,
|
||||
ProxyAgent: proxyAgentCtor,
|
||||
fetch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user