mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Level 2 of the staged approach in #7286: wire the existing webTools.ts prompt-emulation shim (already proven across 11 other web-cookie executors) into gemini-web.ts. The client's tools[] array is now serialized into the prompt typed into the Gemini web UI, and <tool>{...}</tool> blocks in the response are parsed back into OpenAI tool_calls -- including for streaming requests, replayed as a single terminal SSE chunk since gemini-web buffers the whole response by construction. Malformed tool JSON degrades to ordinary chat content, never an error, matching the existing behavior of the other 11 executors. The no-tools code path is unchanged (regression guard). Also Level 1: adds a "Tool calling" column (native/emulated/none) to docs/reference/PROVIDER_REFERENCE.md for providers with confirmed ground truth (the 11 already-wired web-cookie executors + gemini-web -> emulated, claude-web -> none pending its own Level 3 decision). Level 3 (claude-web) and Level 4 (supportsTools capability flag) are explicitly out of scope -- claude-web/payload.ts is untouched.
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
// Tool calling for the Gemini Web executor (#7286) — Level 2 of the staged
|
|
// approach in the issue: wire the existing `webTools.ts` prompt-emulation
|
|
// shim (already proven across 11 other web-cookie executors) into
|
|
// gemini-web.ts. claude-web (Level 3) is explicitly out of scope.
|
|
//
|
|
// gemini-web buffers the *entire* Gemini response before returning (it has
|
|
// no true token-by-token streaming — see the executor's docblock), so the
|
|
// tool-call decision is made once on the full text, then replayed either as
|
|
// a buffered JSON completion or a single terminal SSE chunk.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { GeminiWebExecutor, buildGeminiToolResponse, buildGeminiToolPrompt } = await import(
|
|
"../../open-sse/executors/gemini-web.ts"
|
|
);
|
|
|
|
interface ToolCallLike {
|
|
function: { name: string; arguments: string };
|
|
}
|
|
|
|
interface ChatCompletionLike {
|
|
choices: Array<{
|
|
message: { content: string | null; tool_calls?: ToolCallLike[] };
|
|
finish_reason: string;
|
|
}>;
|
|
}
|
|
|
|
interface StreamChunkLike {
|
|
choices: Array<{
|
|
delta: { role?: string; content?: string; tool_calls?: ToolCallLike[] };
|
|
finish_reason: string | null;
|
|
}>;
|
|
}
|
|
|
|
const GET_WEATHER_TOOL = {
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get the current weather for a city",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { city: { type: "string" } },
|
|
required: ["city"],
|
|
},
|
|
},
|
|
};
|
|
|
|
// ─── Pure-function tests: buildGeminiToolResponse ───────────────────────────
|
|
// (the branching/parsing logic extracted out of the tool-mode path so it is
|
|
// directly testable without a full Playwright mock)
|
|
|
|
test("#7286: well-formed tool call → tool_calls, content:null, finish_reason:tool_calls (non-streaming)", async () => {
|
|
const responseText =
|
|
'Sure, let me check.\n<tool>{"name":"get_weather","arguments":{"city":"Paris"}}</tool>';
|
|
|
|
const response = await buildGeminiToolResponse(
|
|
responseText,
|
|
[GET_WEATHER_TOOL],
|
|
false,
|
|
"gemini-3.1-pro",
|
|
"chatcmpl-gwe-test",
|
|
1700000000
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
const json = (await response.json()) as ChatCompletionLike;
|
|
const choice = json.choices[0];
|
|
assert.equal(choice.message.content, null);
|
|
assert.equal(choice.finish_reason, "tool_calls");
|
|
assert.equal(choice.message.tool_calls.length, 1);
|
|
assert.equal(choice.message.tool_calls[0].function.name, "get_weather");
|
|
assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), {
|
|
city: "Paris",
|
|
});
|
|
});
|
|
|
|
test("#7286: malformed <tool> JSON degrades to ordinary chat content — never throws, never a 500", async () => {
|
|
const responseText = "Here's what I found: <tool>{not valid json</tool>";
|
|
|
|
const response = await buildGeminiToolResponse(
|
|
responseText,
|
|
[GET_WEATHER_TOOL],
|
|
false,
|
|
"gemini-3.1-pro",
|
|
"chatcmpl-gwe-test",
|
|
1700000000
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
const json = (await response.json()) as ChatCompletionLike;
|
|
const choice = json.choices[0];
|
|
assert.equal(choice.finish_reason, "stop");
|
|
assert.equal(choice.message.tool_calls, undefined);
|
|
assert.ok(typeof choice.message.content === "string" && choice.message.content.length > 0);
|
|
});
|
|
|
|
test("#7286: streaming assembly — role chunk, then terminal chunk with delta.tool_calls, then [DONE]", async () => {
|
|
const responseText = '<tool>{"name":"get_weather","arguments":{"city":"Tokyo"}}</tool>';
|
|
|
|
const response = await buildGeminiToolResponse(
|
|
responseText,
|
|
[GET_WEATHER_TOOL],
|
|
true,
|
|
"gemini-3.1-pro",
|
|
"chatcmpl-gwe-test",
|
|
1700000000
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("Content-Type"), "text/event-stream");
|
|
const bodyText = await response.text();
|
|
const dataLines = bodyText
|
|
.split("\n\n")
|
|
.map((chunk) => chunk.trim())
|
|
.filter((chunk) => chunk.startsWith("data: "))
|
|
.map((chunk) => chunk.slice("data: ".length));
|
|
|
|
assert.equal(dataLines[dataLines.length - 1], "[DONE]");
|
|
const parsed: StreamChunkLike[] = dataLines
|
|
.slice(0, -1)
|
|
.map((line) => JSON.parse(line) as StreamChunkLike);
|
|
assert.equal(parsed.length, 2, "expected a role chunk + one terminal chunk");
|
|
assert.deepEqual(parsed[0].choices[0].delta, { role: "assistant" });
|
|
assert.equal(parsed[0].choices[0].finish_reason, null);
|
|
|
|
const terminal = parsed[1];
|
|
assert.equal(terminal.choices[0].finish_reason, "tool_calls");
|
|
assert.equal(terminal.choices[0].delta.tool_calls?.length, 1);
|
|
assert.equal(terminal.choices[0].delta.tool_calls?.[0].function.name, "get_weather");
|
|
});
|
|
|
|
test("#7286: no <tool> block and no requested tools → toolCalls untouched (plain content passthrough)", async () => {
|
|
const response = await buildGeminiToolResponse(
|
|
"Just a normal answer, nothing to call.",
|
|
[GET_WEATHER_TOOL],
|
|
false,
|
|
"gemini-3.1-pro",
|
|
"chatcmpl-gwe-test",
|
|
1700000000
|
|
);
|
|
|
|
const json = (await response.json()) as ChatCompletionLike;
|
|
const choice = json.choices[0];
|
|
assert.equal(choice.finish_reason, "stop");
|
|
assert.equal(choice.message.tool_calls, undefined);
|
|
assert.equal(choice.message.content, "Just a normal answer, nothing to call.");
|
|
});
|
|
|
|
// ─── buildGeminiToolPrompt ───────────────────────────────────────────────────
|
|
|
|
test("#7286: buildGeminiToolPrompt concatenates the synthetic tool system prompt + last user message", () => {
|
|
const effectiveMessages = [
|
|
{ role: "system", content: "TOOL CONTRACT HERE" },
|
|
{ role: "user", content: "what's the weather in Paris?" },
|
|
];
|
|
const prompt = buildGeminiToolPrompt(effectiveMessages);
|
|
assert.equal(prompt, "TOOL CONTRACT HERE\n\nwhat's the weather in Paris?");
|
|
});
|
|
|
|
test("#7286: buildGeminiToolPrompt falls back to the user text alone when there is no system message", () => {
|
|
const effectiveMessages = [{ role: "user", content: "hello" }];
|
|
assert.equal(buildGeminiToolPrompt(effectiveMessages), "hello");
|
|
});
|
|
|
|
// ─── Full executor integration (Playwright mocked) ──────────────────────────
|
|
//
|
|
// Mirrors the mocking pattern already used in tests/unit/gemini-web.test.ts
|
|
// ("Normalizes a bare __Secure-1PSID value..."): fake out `chromium.launch`
|
|
// so `execute()` runs the real request/response wiring — including
|
|
// `prepareToolMessages` → prompt construction → `parseStreamResponse` →
|
|
// `buildGeminiToolResponse` — end to end, proving the tool-mode wiring
|
|
// actually reaches the Playwright-driven code path (not just the extracted
|
|
// pure function).
|
|
|
|
function makeStreamGenerateChunk(text: string): string {
|
|
const inner = new Array(80).fill(null);
|
|
inner[4] = [[null, [text]]];
|
|
return `[["wrb.fr", null, ${JSON.stringify(JSON.stringify(inner))}]]`;
|
|
}
|
|
|
|
function makeStreamGenerateRaw(text: string): string {
|
|
return `)]}'\n10\n${makeStreamGenerateChunk(text)}`;
|
|
}
|
|
|
|
interface FakePlaywrightResponse {
|
|
url: () => string;
|
|
text: () => Promise<string>;
|
|
}
|
|
|
|
type FakeResponseHandler = (resp: FakePlaywrightResponse) => Promise<void>;
|
|
|
|
async function withMockedGeminiBrowser<T>(
|
|
responseText: string,
|
|
fn: (typedPrompt: { value: string }) => Promise<T>
|
|
): Promise<T> {
|
|
const playwright = await import("playwright");
|
|
const originalLaunch = playwright.chromium.launch;
|
|
const typedPrompt = { value: "" };
|
|
|
|
playwright.chromium.launch = (async () => ({
|
|
newContext: async () => ({
|
|
addCookies: async () => {},
|
|
newPage: async () => {
|
|
let respHandler: FakeResponseHandler | null = null;
|
|
const page = {
|
|
on: (event: string, cb: FakeResponseHandler) => {
|
|
if (event === "response") respHandler = cb;
|
|
},
|
|
goto: async () => {},
|
|
waitForTimeout: async () => {},
|
|
waitForSelector: async () => ({ click: async () => {} }),
|
|
keyboard: {
|
|
type: async (text: string) => {
|
|
typedPrompt.value = text;
|
|
},
|
|
press: async () => {
|
|
if (respHandler) {
|
|
await respHandler({
|
|
url: () => "https://gemini.google.com/_/BardChatUi/data/.../StreamGenerate?x",
|
|
text: async () => makeStreamGenerateRaw(responseText),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
};
|
|
return page;
|
|
},
|
|
}),
|
|
close: async () => {},
|
|
})) as unknown as typeof playwright.chromium.launch;
|
|
|
|
try {
|
|
return await fn(typedPrompt);
|
|
} finally {
|
|
playwright.chromium.launch = originalLaunch;
|
|
}
|
|
}
|
|
|
|
test("#7286: executor integration — tools[] present reaches tool_calls end to end", async () => {
|
|
const responseText =
|
|
'<tool>{"name":"get_weather","arguments":{"city":"Berlin"}}</tool>';
|
|
|
|
await withMockedGeminiBrowser(responseText, async () => {
|
|
const executor = new GeminiWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "gemini-3.1-pro",
|
|
body: {
|
|
messages: [{ role: "user", content: "what's the weather in Berlin?" }],
|
|
stream: false,
|
|
tools: [GET_WEATHER_TOOL],
|
|
},
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 200);
|
|
const json = (await result.response.json()) as ChatCompletionLike;
|
|
const choice = json.choices[0];
|
|
assert.equal(choice.finish_reason, "tool_calls");
|
|
assert.equal(choice.message.tool_calls?.[0].function.name, "get_weather");
|
|
});
|
|
});
|
|
|
|
test("#7286: no-tool passthrough regression — unchanged prompt derivation + response shape when tools absent", async () => {
|
|
await withMockedGeminiBrowser("Just chatting, no tools here.", async (typedPrompt) => {
|
|
const executor = new GeminiWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "gemini-3.1-pro",
|
|
body: {
|
|
messages: [{ role: "user", content: "hello there" }],
|
|
stream: false,
|
|
},
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
// Old behavior: the prompt typed into the UI is exactly the last user
|
|
// message — no tool contract prepended.
|
|
assert.equal(typedPrompt.value, "hello there");
|
|
|
|
assert.equal(result.response.status, 200);
|
|
const json = (await result.response.json()) as ChatCompletionLike;
|
|
const choice = json.choices[0];
|
|
assert.equal(choice.message.content, "Just chatting, no tools here.");
|
|
assert.equal(choice.message.tool_calls, undefined);
|
|
assert.equal(choice.finish_reason, "stop");
|
|
});
|
|
});
|