mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(openai): preserve hosted search in Code Mode (#121812)
Keep provider-hosted web_search alongside Code Mode's constrained function surface when OpenAI policy authorizes it. Enforce the same authorization at Responses transport egress. Refs #121803.
This commit is contained in:
committed by
GitHub
parent
9ae3583375
commit
00fb85e48f
@@ -92,7 +92,10 @@ export function createOpenAINativeWebSearchWrapper(
|
||||
): StreamFn {
|
||||
return createPayloadPatchStreamWrapper(
|
||||
baseStreamFn,
|
||||
({ payload }) => {
|
||||
({ payload, options }) => {
|
||||
(
|
||||
options as { openclawCodeModeAllowedHostedToolTypes?: Set<string> } | undefined
|
||||
)?.openclawCodeModeAllowedHostedToolTypes?.add(OPENAI_WEB_SEARCH_TOOL.type);
|
||||
patchOpenAINativeWebSearchPayload(payload);
|
||||
},
|
||||
{
|
||||
|
||||
@@ -192,6 +192,11 @@ function runWrappedPayloadCase(params: {
|
||||
agentId?: string;
|
||||
nativeWebSearchAllowedByToolPolicy?: boolean;
|
||||
payload?: Record<string, unknown>;
|
||||
context?: Context;
|
||||
streamOptions?: SimpleStreamOptions & {
|
||||
openclawCodeModeToolSurface?: boolean;
|
||||
openclawCodeModeAllowedHostedToolTypes?: Set<string>;
|
||||
};
|
||||
}) {
|
||||
const payload = params.payload ?? { store: false };
|
||||
let capturedOptions: SimpleStreamOptions | undefined;
|
||||
@@ -212,8 +217,7 @@ function runWrappedPayloadCase(params: {
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
const context: Context = { messages: [] };
|
||||
void streamFn?.(params.model, context, {});
|
||||
void streamFn?.(params.model, params.context ?? { messages: [] }, params.streamOptions ?? {});
|
||||
|
||||
return {
|
||||
payload,
|
||||
@@ -2338,6 +2342,53 @@ describe("buildOpenAIProvider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("authorizes native OpenAI web search through the code mode wrapper chain", () => {
|
||||
const provider = buildOpenAIProvider();
|
||||
const wrap = provider.wrapStreamFn;
|
||||
if (!wrap) {
|
||||
throw new Error("expected OpenAI wrapper");
|
||||
}
|
||||
const allowedHostedToolTypes = new Set<string>();
|
||||
|
||||
const result = runWrappedPayloadCase({
|
||||
wrap,
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
} as Model<"openai-responses">,
|
||||
context: {
|
||||
messages: [],
|
||||
tools: [
|
||||
{ name: "exec", description: "", parameters: {} },
|
||||
{ name: "wait", description: "", parameters: {} },
|
||||
],
|
||||
},
|
||||
streamOptions: {
|
||||
openclawCodeModeToolSurface: true,
|
||||
openclawCodeModeAllowedHostedToolTypes: allowedHostedToolTypes,
|
||||
},
|
||||
payload: {
|
||||
tools: [
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "function", name: "wait" },
|
||||
{ type: "function", name: "rogue" },
|
||||
{ type: "function", name: "web_search" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.payload.tools).toEqual([
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "function", name: "wait" },
|
||||
{ type: "web_search" },
|
||||
]);
|
||||
expect(allowedHostedToolTypes).toEqual(new Set(["web_search"]));
|
||||
});
|
||||
|
||||
it("keeps one native OpenAI web search tool when the payload is already patched", () => {
|
||||
const provider = buildOpenAIProvider();
|
||||
const wrap = provider.wrapStreamFn;
|
||||
@@ -2373,12 +2424,17 @@ describe("buildOpenAIProvider", () => {
|
||||
throw new Error("expected OpenAI wrapper");
|
||||
}
|
||||
|
||||
const allowedHostedToolTypes = new Set<string>();
|
||||
const result = runWrappedPayloadCase({
|
||||
wrap,
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
agentId: "main",
|
||||
nativeWebSearchAllowedByToolPolicy: false,
|
||||
streamOptions: {
|
||||
openclawCodeModeToolSurface: true,
|
||||
openclawCodeModeAllowedHostedToolTypes: allowedHostedToolTypes,
|
||||
},
|
||||
cfg: {
|
||||
agents: {
|
||||
list: [
|
||||
@@ -2407,6 +2463,7 @@ describe("buildOpenAIProvider", () => {
|
||||
{ type: "function", name: "read" },
|
||||
{ type: "function", name: "web_search" },
|
||||
]);
|
||||
expect(allowedHostedToolTypes).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("raises minimal reasoning when native OpenAI web search is injected", () => {
|
||||
@@ -2444,11 +2501,15 @@ describe("buildOpenAIProvider", () => {
|
||||
throw new Error("expected OpenAI wrapper");
|
||||
}
|
||||
|
||||
const disabledAllowedHostedToolTypes = new Set<string>();
|
||||
const disabled = runWrappedPayloadCase({
|
||||
wrap,
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
cfg: { tools: { web: { search: { enabled: false } } } },
|
||||
streamOptions: {
|
||||
openclawCodeModeAllowedHostedToolTypes: disabledAllowedHostedToolTypes,
|
||||
},
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
@@ -2457,6 +2518,7 @@ describe("buildOpenAIProvider", () => {
|
||||
} as Model<"openai-responses">,
|
||||
payload: { tools: [{ type: "function", name: "web_search" }] },
|
||||
});
|
||||
const proxiedAllowedHostedToolTypes = new Set<string>();
|
||||
const proxied = runWrappedPayloadCase({
|
||||
wrap,
|
||||
provider: "openai",
|
||||
@@ -2467,11 +2529,16 @@ describe("buildOpenAIProvider", () => {
|
||||
id: "gpt-5.4",
|
||||
baseUrl: "https://example-proxy.invalid/v1",
|
||||
} as Model<"openai-responses">,
|
||||
streamOptions: {
|
||||
openclawCodeModeAllowedHostedToolTypes: proxiedAllowedHostedToolTypes,
|
||||
},
|
||||
payload: { tools: [{ type: "function", name: "web_search" }] },
|
||||
});
|
||||
|
||||
expect(disabled.payload.tools).toEqual([{ type: "function", name: "web_search" }]);
|
||||
expect(proxied.payload.tools).toEqual([{ type: "function", name: "web_search" }]);
|
||||
expect(disabledAllowedHostedToolTypes).toEqual(new Set());
|
||||
expect(proxiedAllowedHostedToolTypes).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("keeps managed web_search when another search provider is configured", () => {
|
||||
@@ -2482,11 +2549,15 @@ describe("buildOpenAIProvider", () => {
|
||||
throw new Error("expected OpenAI wrapper");
|
||||
}
|
||||
|
||||
const allowedHostedToolTypes = new Set<string>();
|
||||
const result = runWrappedPayloadCase({
|
||||
wrap,
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
cfg: { tools: { web: { search: { enabled: true, provider: "brave" } } } },
|
||||
streamOptions: {
|
||||
openclawCodeModeAllowedHostedToolTypes: allowedHostedToolTypes,
|
||||
},
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
@@ -2497,6 +2568,7 @@ describe("buildOpenAIProvider", () => {
|
||||
});
|
||||
|
||||
expect(result.payload.tools).toEqual([{ type: "function", name: "web_search" }]);
|
||||
expect(allowedHostedToolTypes).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("preserves explicit OpenAI responses transport overrides", () => {
|
||||
|
||||
@@ -60,6 +60,7 @@ export type BaseOpenAIStreamOptions = StreamOptions & {
|
||||
firstEventTimeoutMs?: number;
|
||||
onFirstEventTimeout?: (reason: Error) => void;
|
||||
openclawCodeModeToolSurface?: boolean;
|
||||
openclawCodeModeAllowedHostedToolTypes?: Set<string>;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
seed?: number;
|
||||
|
||||
@@ -255,8 +255,9 @@ function createResponsesTransportExecutor(config: ResponsesTransportExecutorOpti
|
||||
?.openclawCodeModeToolSurface === true
|
||||
) {
|
||||
const visibleToolNames = resolveCodeModeResponsesVisibleToolNames(context);
|
||||
enforceCodeModeResponsesToolSurface(params, visibleToolNames);
|
||||
assertCodeModeResponsesToolSurface(params, visibleToolNames);
|
||||
const allowedHostedToolTypes = responsesOptions?.openclawCodeModeAllowedHostedToolTypes;
|
||||
enforceCodeModeResponsesToolSurface(params, visibleToolNames, allowedHostedToolTypes);
|
||||
assertCodeModeResponsesToolSurface(params, visibleToolNames, allowedHostedToolTypes);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
@@ -416,6 +416,7 @@ describe("OpenAI Responses provider prompt observer", () => {
|
||||
context: createContext(prompt, { tools: [tool("exec"), tool("wait")] as never }),
|
||||
options: {
|
||||
openclawCodeModeToolSurface: true,
|
||||
openclawCodeModeAllowedHostedToolTypes: new Set(["web_search"]),
|
||||
onPayload: async () => {
|
||||
await Promise.resolve();
|
||||
return {
|
||||
@@ -430,7 +431,13 @@ describe("OpenAI Responses provider prompt observer", () => {
|
||||
content: [{ type: "input_image", image_url: "data:image/png;base64,invalid!" }],
|
||||
},
|
||||
],
|
||||
tools: [tool("exec"), tool("wait"), tool("rogue")],
|
||||
tools: [
|
||||
tool("exec"),
|
||||
tool("wait"),
|
||||
tool("rogue"),
|
||||
{ type: "web_search" },
|
||||
{ type: "file_search" },
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -439,7 +446,7 @@ describe("OpenAI Responses provider prompt observer", () => {
|
||||
expect(run.order).toEqual(["observe", "openai.create"]);
|
||||
expect(run.observations[0]?.matchesAssembledPrompt).toBe(true);
|
||||
expect(run.requests[0]?.metadata).toEqual({ caller: "kept", host: "added" });
|
||||
expect(run.requests[0]?.tools).toEqual([tool("exec"), tool("wait")]);
|
||||
expect(run.requests[0]?.tools).toEqual([tool("exec"), tool("wait"), { type: "web_search" }]);
|
||||
expect(JSON.stringify(run.requests[0]?.input)).toContain("omitted image payload");
|
||||
});
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("OpenAI Code Mode payload tool filtering", () => {
|
||||
|
||||
it("keeps the existing Responses and Completions enforcement contract", () => {
|
||||
const payload = {
|
||||
tools: [{ name: "exec" }, { name: "web_search" }, { name: "wait" }],
|
||||
tools: [{ name: "exec" }, { name: "web_search" }, { type: "web_search" }, { name: "wait" }],
|
||||
};
|
||||
|
||||
enforceCodeModeResponsesToolSurface(payload, visibleToolNames);
|
||||
@@ -107,6 +107,34 @@ describe("OpenAI Code Mode payload tool filtering", () => {
|
||||
expect(() => assertCodeModeResponsesToolSurface(payload, visibleToolNames)).not.toThrow();
|
||||
});
|
||||
|
||||
it("keeps only explicitly allowed hosted tools alongside visible client functions", () => {
|
||||
const allowedHostedToolTypes = new Set(["web_search"]);
|
||||
const payload = {
|
||||
tools: [
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "function", name: "rogue" },
|
||||
{ type: "web_search" },
|
||||
{ type: "file_search" },
|
||||
{ type: "web_search", name: "exec" },
|
||||
{ type: "web_search", function: { name: "wait" } },
|
||||
{ type: "web_search", functionDeclarations: [{ name: "exec" }] },
|
||||
{ type: "function", name: "wait" },
|
||||
],
|
||||
};
|
||||
|
||||
filterCodeModePayloadTools(payload, visibleToolNames, allowedHostedToolTypes);
|
||||
enforceCodeModeResponsesToolSurface(payload, visibleToolNames, allowedHostedToolTypes);
|
||||
|
||||
expect(payload.tools).toEqual([
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "web_search" },
|
||||
{ type: "function", name: "wait" },
|
||||
]);
|
||||
expect(() =>
|
||||
assertCodeModeResponsesToolSurface(payload, visibleToolNames, allowedHostedToolTypes),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(["functionDeclarations", "function_declarations"] as const)(
|
||||
"rejects and removes grouped %s from final OpenAI payloads",
|
||||
(field) => {
|
||||
@@ -139,6 +167,24 @@ describe("OpenAI Code Mode payload tool filtering", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "function", name: "wait" },
|
||||
{ type: "web_search" },
|
||||
{ type: "web_search" },
|
||||
],
|
||||
[
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "function", name: "wait" },
|
||||
{ type: "file_search" },
|
||||
],
|
||||
])("rejects duplicate or undeclared hosted tools", (...tools) => {
|
||||
expect(() =>
|
||||
assertCodeModeResponsesToolSurface({ tools }, visibleToolNames, new Set(["web_search"])),
|
||||
).toThrow(/tool surface violation/);
|
||||
});
|
||||
|
||||
it("fails closed when the asserted payload tools getter throws", () => {
|
||||
expect(() =>
|
||||
assertCodeModeResponsesToolSurface(withThrowingGetter("tools"), visibleToolNames),
|
||||
|
||||
@@ -42,9 +42,40 @@ export function readCodeModePayloadToolName(tool: unknown): string | undefined {
|
||||
return typeof fnName === "string" ? fnName : undefined;
|
||||
}
|
||||
|
||||
function readCodeModePayloadToolIdentity(
|
||||
tool: unknown,
|
||||
visibleToolNames: ReadonlySet<string>,
|
||||
allowedHostedToolTypes?: ReadonlySet<string>,
|
||||
): string | false | undefined {
|
||||
if (!isRecord(tool)) {
|
||||
return undefined;
|
||||
}
|
||||
const type = readToolPayloadField(tool, "type");
|
||||
if (typeof type === "string" && allowedHostedToolTypes?.has(type)) {
|
||||
try {
|
||||
if (
|
||||
Object.hasOwn(tool, "name") ||
|
||||
Object.hasOwn(tool, "function") ||
|
||||
Object.hasOwn(tool, "functionDeclarations") ||
|
||||
Object.hasOwn(tool, "function_declarations")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return `hosted:${type}`;
|
||||
}
|
||||
const name = readCodeModePayloadToolName(tool);
|
||||
return typeof name === "string" && isCodeModeModelVisibleToolName(name, visibleToolNames)
|
||||
? `client:${name}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function filterCodeModePayloadTools(
|
||||
payload: unknown,
|
||||
visibleToolNames: ReadonlySet<string>,
|
||||
allowedHostedToolTypes?: ReadonlySet<string>,
|
||||
): void {
|
||||
if (!isRecord(payload)) {
|
||||
return;
|
||||
@@ -54,10 +85,17 @@ export function filterCodeModePayloadTools(
|
||||
return;
|
||||
}
|
||||
payload.tools = tools.flatMap((tool) => {
|
||||
const name = readCodeModePayloadToolName(tool);
|
||||
if (typeof name === "string" && isCodeModeModelVisibleToolName(name, visibleToolNames)) {
|
||||
const identity = readCodeModePayloadToolIdentity(
|
||||
tool,
|
||||
visibleToolNames,
|
||||
allowedHostedToolTypes,
|
||||
);
|
||||
if (identity) {
|
||||
return [tool];
|
||||
}
|
||||
if (identity === false) {
|
||||
return [];
|
||||
}
|
||||
if (!isRecord(tool)) {
|
||||
return [];
|
||||
}
|
||||
@@ -95,6 +133,7 @@ export function resolveCodeModeResponsesVisibleToolNames(
|
||||
export function enforceCodeModeResponsesToolSurface(
|
||||
payload: unknown,
|
||||
visibleToolNames: ReadonlySet<string>,
|
||||
allowedHostedToolTypes?: ReadonlySet<string>,
|
||||
): void {
|
||||
if (!isRecord(payload)) {
|
||||
return;
|
||||
@@ -103,31 +142,36 @@ export function enforceCodeModeResponsesToolSurface(
|
||||
if (!Array.isArray(tools)) {
|
||||
return;
|
||||
}
|
||||
payload.tools = tools.filter((tool) => {
|
||||
const name = readCodeModePayloadToolName(tool);
|
||||
return typeof name === "string" && isCodeModeModelVisibleToolName(name, visibleToolNames);
|
||||
});
|
||||
payload.tools = tools.filter((tool) =>
|
||||
Boolean(readCodeModePayloadToolIdentity(tool, visibleToolNames, allowedHostedToolTypes)),
|
||||
);
|
||||
}
|
||||
|
||||
export function assertCodeModeResponsesToolSurface(
|
||||
payload: unknown,
|
||||
visibleToolNames: ReadonlySet<string>,
|
||||
allowedHostedToolTypes?: ReadonlySet<string>,
|
||||
): void {
|
||||
const tools = isRecord(payload) ? readToolPayloadField(payload, "tools") : undefined;
|
||||
if (!Array.isArray(tools)) {
|
||||
throw new Error("Code mode payload tool surface violation: expected exec,wait; got no tools");
|
||||
}
|
||||
const names = tools
|
||||
.map(readCodeModePayloadToolName)
|
||||
.filter((name): name is string => typeof name === "string" && name.length > 0)
|
||||
const identities = tools.map((tool) =>
|
||||
readCodeModePayloadToolIdentity(tool, visibleToolNames, allowedHostedToolTypes),
|
||||
);
|
||||
const names = identities
|
||||
.flatMap((identity) =>
|
||||
typeof identity === "string" && identity.startsWith("client:")
|
||||
? [identity.slice("client:".length)]
|
||||
: [],
|
||||
)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
if (
|
||||
names.length >= 2 &&
|
||||
names.length === tools.length &&
|
||||
new Set(names).size === names.length &&
|
||||
identities.every((identity): identity is string => typeof identity === "string") &&
|
||||
new Set(identities).size === identities.length &&
|
||||
names.includes("exec") &&
|
||||
names.includes("wait") &&
|
||||
names.every((name) => isCodeModeModelVisibleToolName(name, visibleToolNames))
|
||||
names.includes("wait")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,9 +116,11 @@ describe("createOpenAICompletionsToolsCompatWrapper", () => {
|
||||
});
|
||||
|
||||
describe("createCodexNativeWebSearchWrapper", () => {
|
||||
it("does not inject native web_search when code mode owns the tool surface", () => {
|
||||
it("keeps native_active web_search alongside the code mode tool surface", () => {
|
||||
let observedOptions: Parameters<StreamFn>[2];
|
||||
const payloads: Array<Record<string, unknown>> = [];
|
||||
const baseStreamFn: StreamFn = (model, context, options) => {
|
||||
observedOptions = options;
|
||||
const payload: Record<string, unknown> = {
|
||||
model: model.id,
|
||||
tools: [
|
||||
@@ -178,7 +180,12 @@ describe("createCodexNativeWebSearchWrapper", () => {
|
||||
expect(payloads[0]?.tools).toEqual([
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "function", name: "wait" },
|
||||
{ type: "web_search" },
|
||||
]);
|
||||
expect(
|
||||
(observedOptions as { openclawCodeModeAllowedHostedToolTypes?: Set<string> } | undefined)
|
||||
?.openclawCodeModeAllowedHostedToolTypes,
|
||||
).toEqual(new Set(["web_search"]));
|
||||
});
|
||||
|
||||
it("filters async replacement payloads when code mode owns the tool surface", async () => {
|
||||
@@ -189,12 +196,22 @@ describe("createCodexNativeWebSearchWrapper", () => {
|
||||
};
|
||||
const wrapped = createCodexNativeWebSearchWrapper(baseStreamFn, {
|
||||
codeModeToolSurfaceEnabled: true,
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: { enabled: true, mode: "cached" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const model = {
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
api: "openai-chatgpt-responses",
|
||||
provider: "gateway",
|
||||
id: "gpt-5.5",
|
||||
} as Model<"openai-responses">;
|
||||
} as Model<"openai-chatgpt-responses">;
|
||||
|
||||
void wrapped(
|
||||
model,
|
||||
@@ -224,6 +241,7 @@ describe("createCodexNativeWebSearchWrapper", () => {
|
||||
},
|
||||
{ type: "function", name: "wait" },
|
||||
{ type: "web_search" },
|
||||
{ type: "file_search" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -236,8 +254,66 @@ describe("createCodexNativeWebSearchWrapper", () => {
|
||||
{ type: "function", name: "sessions_yield" },
|
||||
{ type: "function", name: "structured_output" },
|
||||
{ type: "function", name: "wait" },
|
||||
{ type: "web_search" },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
(observedOptions as { openclawCodeModeAllowedHostedToolTypes?: Set<string> } | undefined)
|
||||
?.openclawCodeModeAllowedHostedToolTypes,
|
||||
).toEqual(new Set(["web_search"]));
|
||||
});
|
||||
|
||||
it("does not authorize hosted search when runtime tool policy denies it in code mode", () => {
|
||||
let observedOptions: Parameters<StreamFn>[2];
|
||||
const payloads: Array<Record<string, unknown>> = [];
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
observedOptions = options;
|
||||
const payload = {
|
||||
tools: [
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "function", name: "wait" },
|
||||
{ type: "web_search" },
|
||||
],
|
||||
};
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(structuredClone(payload));
|
||||
return createAssistantMessageEventStream();
|
||||
};
|
||||
const wrapped = createCodexNativeWebSearchWrapper(baseStreamFn, {
|
||||
codeModeToolSurfaceEnabled: true,
|
||||
nativeWebSearchAllowedByToolPolicy: false,
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
openaiCodex: { enabled: true, mode: "cached" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
void wrapped(
|
||||
codexModel,
|
||||
{
|
||||
messages: [],
|
||||
tools: [
|
||||
{ name: "exec", description: "", parameters: {} },
|
||||
{ name: "wait", description: "", parameters: {} },
|
||||
],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(payloads[0]?.tools).toEqual([
|
||||
{ type: "function", name: "exec" },
|
||||
{ type: "function", name: "wait" },
|
||||
]);
|
||||
expect(
|
||||
(observedOptions as { openclawCodeModeAllowedHostedToolTypes?: Set<string> } | undefined)
|
||||
?.openclawCodeModeAllowedHostedToolTypes,
|
||||
).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("does not enable code-mode transport enforcement when config is on but controls are inactive", () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
supportsOpenAIReasoningEffort,
|
||||
} from "@openclaw/ai/internal/openai";
|
||||
import {
|
||||
emitModelTransportDebug,
|
||||
filterCodeModePayloadTools,
|
||||
isCodeModeModelVisibleToolName,
|
||||
readCodeModePayloadToolName,
|
||||
@@ -49,6 +48,7 @@ type OpenAIServiceTier = "auto" | "default" | "flex" | "priority";
|
||||
type DynamicFastMode = boolean | (() => boolean | undefined);
|
||||
type OpenClawSimpleStreamOptions = SimpleStreamOptions & {
|
||||
openclawCodeModeToolSurface?: boolean;
|
||||
openclawCodeModeAllowedHostedToolTypes?: Set<string>;
|
||||
};
|
||||
type OpenAIResponsesReplayOptions = Parameters<StreamFn>[2] & {
|
||||
replayResponsesItemIds?: boolean;
|
||||
@@ -146,9 +146,10 @@ function filterCodeModePayloadHookResult(
|
||||
payload: unknown,
|
||||
nextPayload: unknown,
|
||||
visibleToolNames: ReadonlySet<string>,
|
||||
allowedHostedToolTypes: ReadonlySet<string>,
|
||||
): unknown {
|
||||
const finalPayload = nextPayload === undefined ? payload : nextPayload;
|
||||
filterCodeModePayloadTools(finalPayload, visibleToolNames);
|
||||
filterCodeModePayloadTools(finalPayload, visibleToolNames, allowedHostedToolTypes);
|
||||
return nextPayload === undefined ? undefined : finalPayload;
|
||||
}
|
||||
|
||||
@@ -648,31 +649,81 @@ export function createCodexNativeWebSearchWrapper(
|
||||
const codeModeSurfaceFromOptions =
|
||||
(options as OpenClawSimpleStreamOptions | undefined)?.openclawCodeModeToolSurface === true;
|
||||
const codeModeVisibleToolNames = resolveCodeModeVisibleToolNames(context);
|
||||
const resolveNativeSearchActivation = () =>
|
||||
resolveCodexNativeSearchActivation({
|
||||
config: params.config,
|
||||
modelProvider: readStringValue(model.provider),
|
||||
modelApi: readStringValue(model.api),
|
||||
modelId: readStringValue(model.id),
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sandboxToolPolicy: params.sandboxToolPolicy,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (
|
||||
(params.codeModeToolSurfaceEnabled === true ||
|
||||
codeModeSurfaceFromOptions ||
|
||||
isCodeModeEnabled(params.config)) &&
|
||||
codeModeVisibleToolNames
|
||||
) {
|
||||
emitModelTransportDebug(
|
||||
log,
|
||||
`skipping Codex native web search because code mode owns the model tool surface for ${
|
||||
model.provider ?? "unknown"
|
||||
}/${model.id ?? "unknown"}`,
|
||||
);
|
||||
// Every spread below must retain this request-scoped Set so the provider policy owner
|
||||
// and final Responses egress agree on the same hosted-tool authorization fact.
|
||||
const allowedHostedToolTypes =
|
||||
(options as OpenClawSimpleStreamOptions | undefined)
|
||||
?.openclawCodeModeAllowedHostedToolTypes ?? new Set<string>();
|
||||
const activation =
|
||||
params.nativeWebSearchAllowedByToolPolicy === false
|
||||
? undefined
|
||||
: resolveNativeSearchActivation();
|
||||
if (activation?.state === "native_active") {
|
||||
allowedHostedToolTypes.add("web_search");
|
||||
}
|
||||
if (activation?.state === "native_active" || activation?.codexNativeEnabled) {
|
||||
const outcome =
|
||||
activation.state === "native_active"
|
||||
? `activating (${activation.codexMode})`
|
||||
: `skipping (${activation.inactiveReason ?? "inactive"})`;
|
||||
log.debug(
|
||||
`${outcome} Codex native web search alongside code mode for ${model.provider ?? "unknown"}/${model.id ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
const originalOnPayload = options?.onPayload;
|
||||
const codeModeOptions: OpenClawSimpleStreamOptions = {
|
||||
...options,
|
||||
openclawCodeModeToolSurface: true,
|
||||
openclawCodeModeAllowedHostedToolTypes: allowedHostedToolTypes,
|
||||
onPayload: (payload) => {
|
||||
filterCodeModePayloadTools(payload, codeModeVisibleToolNames);
|
||||
if (activation?.state === "native_active") {
|
||||
patchCodexNativeWebSearchPayload({ payload, config: params.config });
|
||||
}
|
||||
filterCodeModePayloadTools(payload, codeModeVisibleToolNames, allowedHostedToolTypes);
|
||||
const nextPayload = originalOnPayload?.(payload, model);
|
||||
if (isPromiseLike(nextPayload)) {
|
||||
return Promise.resolve(nextPayload).then((resolvedPayload) =>
|
||||
filterCodeModePayloadHookResult(payload, resolvedPayload, codeModeVisibleToolNames),
|
||||
filterCodeModePayloadHookResult(
|
||||
payload,
|
||||
resolvedPayload,
|
||||
codeModeVisibleToolNames,
|
||||
allowedHostedToolTypes,
|
||||
),
|
||||
);
|
||||
}
|
||||
return filterCodeModePayloadHookResult(payload, nextPayload, codeModeVisibleToolNames);
|
||||
return filterCodeModePayloadHookResult(
|
||||
payload,
|
||||
nextPayload,
|
||||
codeModeVisibleToolNames,
|
||||
allowedHostedToolTypes,
|
||||
);
|
||||
},
|
||||
};
|
||||
return underlying(model, context, codeModeOptions);
|
||||
@@ -687,26 +738,7 @@ export function createCodexNativeWebSearchWrapper(
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
const activation = resolveCodexNativeSearchActivation({
|
||||
config: params.config,
|
||||
modelProvider: readStringValue(model.provider),
|
||||
modelApi: readStringValue(model.api),
|
||||
modelId: readStringValue(model.id),
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sandboxToolPolicy: params.sandboxToolPolicy,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
const activation = resolveNativeSearchActivation();
|
||||
|
||||
if (activation.state !== "native_active") {
|
||||
if (activation.codexNativeEnabled) {
|
||||
|
||||
Reference in New Issue
Block a user