Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 78 additions & 17 deletions lib/server/proxy/web-search-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,19 +312,29 @@ const replaceServerTools = ({
searchPassthrough: boolean;
searchProvider: WebSearchProvider | null;
tools: unknown;
}): { executes: boolean; tools: unknown[] } | null => {
}): {
executes: boolean;
/** Canonical names the proxy took over, so call classification can tell its own calls from a client's. */
ownedNames: Set<string>;
tools: unknown[];
} | null => {
if (!Array.isArray(tools) || !tools.length) {
return null;
}

let matched = false;
let executes = false;
// Names the proxy is executing itself. A client may declare its own tool
// under the same name, and the loop must not answer those calls: matching
// the name is not enough to own it.
const ownedNames = new Set<string>();

const rewritten = tools.flatMap((tool): unknown[] => {
if (isWebSearchTool(tool)) {
if (searchEnabled && searchProvider) {
matched = true;
executes = true;
ownedNames.add(normalizeToolName(WEB_SEARCH_TOOL_NAME));

return [{ type: 'function', function: buildWebSearchToolDefinition() }];
}
Expand All @@ -338,17 +348,23 @@ const replaceServerTools = ({
}

if (isWebFetchTool(tool)) {
// A client-owned function of the same name wins over the backend, exactly
// as it does for search. The backend setting chooses who runs the *proxy's*
// tool; it is not a licence to take over a tool the client declared and
// resolves itself. Without this, a client that ships its own `web_fetch`
// loses it the moment a deployment picks a backend.
if (!isServerDeclaredFetchTool(tool)) {
return [tool];
Comment thread
orangeboyChen marked this conversation as resolved.
}

if (fetchEnabled && fetchProvider) {
matched = true;
executes = true;
ownedNames.add(normalizeToolName(WEB_FETCH_TOOL_NAME));

return [{ type: 'function', function: buildWebFetchToolDefinition() }];
}

if (!isServerDeclaredFetchTool(tool)) {
return [tool];
}

matched = true;
return [stripServerToolMarker(tool)];
}
Expand All @@ -357,7 +373,7 @@ const replaceServerTools = ({
return [stripServerToolMarker(tool)];
});

return matched ? { executes, tools: rewritten } : null;
return matched ? { executes, ownedNames, tools: rewritten } : null;
};

/**
Expand Down Expand Up @@ -835,22 +851,44 @@ interface ServerToolProbe {
*/
const isLocalServerToolCall = ({
fetchProvider,
ownedNames,
toolCall,
searchProvider,
}: {
fetchProvider: WebFetchProvider | null;
/**
* Canonical names the proxy took over. Without it a client's own tool that
* happens to share a name — `web_fetch`, which is not a server tool in the
* Responses API — gets executed by the loop instead of handed back.
*/
ownedNames?: Set<string>;
toolCall: ChatCompletionToolCall;
searchProvider: WebSearchProvider | null;
}): boolean =>
(Boolean(searchProvider) && isWebSearchToolCall(toolCall)) ||
(Boolean(fetchProvider) && isWebFetchToolCall(toolCall));
(Boolean(searchProvider) &&
isWebSearchToolCall(toolCall) &&
isOwned(ownedNames, WEB_SEARCH_TOOL_NAME)) ||
(Boolean(fetchProvider) &&
isWebFetchToolCall(toolCall) &&
isOwned(ownedNames, WEB_FETCH_TOOL_NAME));

/**
* Whether the proxy owns calls to `name`.
*
* `undefined` means the caller predates ownership tracking; those callers only
* ever run the proxy's own declarations, so they are unaffected by client tools
* of the same name.
*/
const isOwned = (ownedNames: Set<string> | undefined, name: string): boolean =>
!ownedNames || ownedNames.has(normalizeToolName(name));

const probeServerToolStream = async ({
canContinue,
context,
emitRaw,
fetchProvider,
onReader,
ownedNames,
response,
searchProvider,
}: {
Expand All @@ -865,6 +903,7 @@ const probeServerToolStream = async ({
};
emitRaw: (frame: string) => void;
fetchProvider: WebFetchProvider | null;
ownedNames?: Set<string>;
/**
* Hands the active reader to the caller's cancellation path. Without it a
* disconnect cannot interrupt a read that is already parked: the loop only
Expand Down Expand Up @@ -978,7 +1017,12 @@ const probeServerToolStream = async ({

const toolCalls = aggregateStreamingToolCalls(toolCallDeltas);
const isLocalCall = (toolCall: ChatCompletionToolCall): boolean =>
isLocalServerToolCall({ fetchProvider, searchProvider, toolCall });
isLocalServerToolCall({
fetchProvider,
ownedNames,
searchProvider,
toolCall,
});

return {
content,
Expand All @@ -997,6 +1041,7 @@ const createInlineServerToolStream = async ({
callbacks,
callUpstream,
fetchProvider,
ownedNames,
searchProvider,
}: {
body: ChatRequestBody;
Expand All @@ -1006,6 +1051,7 @@ const createInlineServerToolStream = async ({
mode: ServerToolUpstreamMode,
) => Promise<Response>;
fetchProvider: WebFetchProvider | null;
ownedNames?: Set<string>;
searchProvider: WebSearchProvider | null;
}): Promise<ServerToolLoopResult> => {
const firstResponse = await callUpstream(body, 'stream');
Expand All @@ -1023,7 +1069,12 @@ const createInlineServerToolStream = async ({
// A call is locally executable only when its backend is available; anything
// else stays the client's to answer.
const isLocalCall = (toolCall: ChatCompletionToolCall): boolean =>
isLocalServerToolCall({ fetchProvider, searchProvider, toolCall });
isLocalServerToolCall({
fetchProvider,
ownedNames,
searchProvider,
toolCall,
});

const emitJson = (
controller: ReadableStreamDefaultController<Uint8Array>,
Expand Down Expand Up @@ -1078,6 +1129,7 @@ const createInlineServerToolStream = async ({
onReader: (reader) => {
activeReader = reader;
},
ownedNames,
response: firstResponse,
searchProvider,
});
Expand Down Expand Up @@ -1251,6 +1303,7 @@ const createInlineServerToolStream = async ({
onReader: (reader) => {
activeReader = reader;
},
ownedNames,
response,
searchProvider,
});
Expand Down Expand Up @@ -1400,6 +1453,7 @@ const createInlineServerToolStream = async ({
onReader: (reader) => {
activeReader = reader;
},
ownedNames,
response,
searchProvider,
});
Expand Down Expand Up @@ -1524,7 +1578,7 @@ export const executeWebSearchLoop = async ({
return null;
}

const { executes, tools } = replacement;
const { executes, ownedNames, tools } = replacement;

// Nothing can be executed, so there is nothing to loop for. The rewritten
// `tools` still have to reach the caller: it forwards them upstream, and the
Expand Down Expand Up @@ -1557,6 +1611,7 @@ export const executeWebSearchLoop = async ({
callbacks,
callUpstream,
fetchProvider,
ownedNames,
searchProvider,
});
}
Expand Down Expand Up @@ -1594,13 +1649,19 @@ export const executeWebSearchLoop = async ({

const message = payload.choices?.[0]?.message;
const toolCalls = message?.tool_calls ?? [];
const localCalls = toolCalls.filter(
(toolCall) =>
(Boolean(searchProvider) && isWebSearchToolCall(toolCall)) ||
(Boolean(fetchProvider) && isWebFetchToolCall(toolCall)),
);
// The same ownership test the streaming paths use. Matching the name alone
// would execute a client's own `web_fetch` whenever a backend is
// configured, instead of handing the call back.
const isLocalCall = (toolCall: ChatCompletionToolCall): boolean =>
isLocalServerToolCall({
fetchProvider,
ownedNames,
searchProvider,
toolCall,
});
const localCalls = toolCalls.filter(isLocalCall);
const remainingCalls = toolCalls.filter(
(toolCall) => !localCalls.includes(toolCall),
(toolCall) => !isLocalCall(toolCall),
);

if (!localCalls.length) {
Expand Down
Loading
Loading