mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
Root cause: GeminiWebExecutor.execute() opened the identical fixed https://gemini.google.com/app URL and ran the identical Playwright interaction sequence for every advertised gweb/<model> id. `model` was read only AFTER the response was captured, purely to stamp the OpenAI-shaped response — never to influence what was actually clicked/typed, so two different advertised models produced byte-identical automation and the response `model` field was a caller-supplied label, not an observed fact. Fix (owner decision, Option B): a new model -> Gemini UI mode map (open-sse/executors/gemini-web/modeSelection.ts) drives an in-browser selection step before anything is typed — try the mode control, read back the active-mode indicator, and only proceed on a confirmed match. An unconfirmed model, or a requested Extended Thinking control that cannot be confirmed (#13381 follow-up comment), fails closed with 400 unsupported_control_for_provider instead of silently running the account default under the requested label. The selectors involved are UNVALIDATED (no live Gemini account from this checkout) — see the PR's "Selector set is UNVALIDATED" section and the required live smoke. Regression test: tests/unit/issue-13381-gemini-web-model-selection.test.ts
313 lines
14 KiB
TypeScript
313 lines
14 KiB
TypeScript
// Capability enforcement for the Gemini Web executor (#9356).
|
|
//
|
|
// Reported: gemini-web silently ACCEPTS `reasoning_effort` and
|
|
// `tool_choice: "required"` and answers with ordinary prose — HTTP 200, no
|
|
// `reasoning_content`, `tool_calls: []`, `finish_reason: "stop"`. An
|
|
// AgentChakra/OpenClaw agent then believes its reasoning and tool requirements
|
|
// were honored when they were not.
|
|
//
|
|
// Why neither can be implemented for THIS provider: gemini-web is not an API
|
|
// client. It launches Playwright, types a single flat prompt string into the
|
|
// gemini.google.com `.ql-editor` contenteditable, presses Enter, and captures
|
|
// the first `StreamGenerate` response off the page. There is no request payload
|
|
// to carry a thinking budget, and no function-calling channel to force — the
|
|
// tools support it does have is the prompt-emulation shim (`webTools.ts`, #7286),
|
|
// which ASKS the model to emit `<tool>{...}</tool>` and cannot GUARANTEE it.
|
|
//
|
|
// So this suite pins the issue's option (b) for both controls: reject the
|
|
// requests we cannot honor, and keep honoring the ones we can. The line drawn:
|
|
//
|
|
// reasoning_effort none | minimal → allowed (gemini-web not thinking
|
|
// IS compliance with "spend little")
|
|
// low | medium | high… → 400, a positive request to think
|
|
// tool_choice absent | auto | none → allowed (emulation path, #7286)
|
|
// required | any | {fn} → 400, a guarantee we cannot make
|
|
//
|
|
// The guard must run BEFORE Playwright launches, so every executor assertion
|
|
// here completes without a browser.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts");
|
|
const { checkGeminiWebUnsupportedControls, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE } =
|
|
await import("../../open-sse/executors/gemini-web/capabilities.ts");
|
|
const { gemini_webProvider } =
|
|
await import("../../open-sse/config/providers/registry/gemini/web/index.ts");
|
|
const { supportsReasoning, supportsToolCalling } =
|
|
await import("../../src/lib/modelCapabilities.ts");
|
|
const { providerSupportsEmulatedToolCalling } =
|
|
await import("../../open-sse/services/combo/comboStructure.ts");
|
|
|
|
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"] },
|
|
},
|
|
};
|
|
|
|
interface ErrorBodyLike {
|
|
error: { message: string; type: string; code: string };
|
|
}
|
|
|
|
/**
|
|
* Run the executor with valid-looking credentials. `tool_choice` cases short-
|
|
* circuit on the static capability guard, so Playwright is never reached.
|
|
* `reasoning_effort` cases (#13381) now require a mocked browser to reach the
|
|
* Extended Thinking selection step — `gemini-3.1-pro` is the default model
|
|
* mode (no interaction attempted), so it never interferes with that check.
|
|
*/
|
|
async function run(body: Record<string, unknown>) {
|
|
return new GeminiWebExecutor().execute({
|
|
model: "gemini-3.1-pro",
|
|
body: { messages: [{ role: "user", content: "hi" }], stream: false, ...body },
|
|
stream: false,
|
|
credentials: { apiKey: "__Secure-1PSID=test-cookie" },
|
|
signal: AbortSignal.timeout(10_000),
|
|
log: null,
|
|
});
|
|
}
|
|
|
|
// ─── Pure checker: reasoning_effort ─────────────────────────────────────────
|
|
|
|
test("#9356 reasoning_effort low/medium/high/xhigh are rejected as unsupported", () => {
|
|
for (const effort of ["low", "medium", "high", "xhigh"]) {
|
|
const violation = checkGeminiWebUnsupportedControls({ reasoning_effort: effort });
|
|
assert.equal(
|
|
violation?.param,
|
|
"reasoning_effort",
|
|
`reasoning_effort="${effort}" asks gemini-web to think harder, which a typed browser ` +
|
|
`prompt cannot express — it must be rejected, not silently dropped`
|
|
);
|
|
assert.match(violation!.message, /reasoning_effort/);
|
|
}
|
|
});
|
|
|
|
test("#9356 reasoning_effort none/minimal and absent stay allowed", () => {
|
|
assert.equal(checkGeminiWebUnsupportedControls({}), null);
|
|
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: null }), null);
|
|
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: "none" }), null);
|
|
assert.equal(
|
|
checkGeminiWebUnsupportedControls({ reasoning_effort: "minimal" }),
|
|
null,
|
|
'"minimal" means spend as little reasoning as possible — a non-thinking provider ' +
|
|
"already satisfies it, so rejecting it would be gratuitous"
|
|
);
|
|
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: " NONE " }), null);
|
|
});
|
|
|
|
// ─── Pure checker: tool_choice ──────────────────────────────────────────────
|
|
|
|
test("#9356 tool_choice required/any is rejected as unsupported", () => {
|
|
for (const choice of ["required", "any"]) {
|
|
const violation = checkGeminiWebUnsupportedControls({
|
|
tools: [GET_WEATHER_TOOL],
|
|
tool_choice: choice,
|
|
});
|
|
assert.equal(
|
|
violation?.param,
|
|
"tool_choice",
|
|
`tool_choice="${choice}" is a guarantee the prompt-emulation shim cannot make`
|
|
);
|
|
assert.match(violation!.message, /tool_choice/);
|
|
}
|
|
});
|
|
|
|
test("#9356 a forced-function tool_choice object is rejected as unsupported", () => {
|
|
const violation = checkGeminiWebUnsupportedControls({
|
|
tools: [GET_WEATHER_TOOL],
|
|
tool_choice: { type: "function", function: { name: "get_weather" } },
|
|
});
|
|
assert.equal(violation?.param, "tool_choice");
|
|
|
|
// Anthropic-style forcing, which the translators also emit.
|
|
assert.equal(
|
|
checkGeminiWebUnsupportedControls({
|
|
tools: [GET_WEATHER_TOOL],
|
|
tool_choice: { type: "any" },
|
|
})?.param,
|
|
"tool_choice"
|
|
);
|
|
});
|
|
|
|
test("#9356 tool_choice auto/none and absent keep the #7286 emulation path open", () => {
|
|
assert.equal(checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL] }), null);
|
|
assert.equal(
|
|
checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL], tool_choice: "auto" }),
|
|
null
|
|
);
|
|
assert.equal(
|
|
checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL], tool_choice: "none" }),
|
|
null
|
|
);
|
|
});
|
|
|
|
test("#9356 forcing is rejected on its own terms, even with no tools[] array", () => {
|
|
// An agent that sets tool_choice without tools is already malformed, but the
|
|
// point stands: never report success for a forcing contract we ignore.
|
|
assert.equal(
|
|
checkGeminiWebUnsupportedControls({ tool_choice: "required" })?.param,
|
|
"tool_choice"
|
|
);
|
|
});
|
|
|
|
// ─── Executor wiring ────────────────────────────────────────────────────────
|
|
//
|
|
// #13381 (2026-09-15 owner decision, Option B) changed how `reasoning_effort`
|
|
// is enforced at the executor level: eligible Gemini accounts DO expose a
|
|
// real Extended Thinking toggle in the web UI (reporter follow-up comment,
|
|
// 2026-09-11), so a blanket "we can never do this" — rejecting BEFORE any
|
|
// browser is even launched, regardless of account — would be the same
|
|
// dishonesty #13381 exists to remove. `reasoning_effort` above "minimal" is
|
|
// therefore no longer rejected by the static pre-browser guard below; it is
|
|
// now ATTEMPTED via a genuine in-browser Extended Thinking selection step
|
|
// (open-sse/executors/gemini-web/modeSelection.ts) and only rejected if that
|
|
// attempt cannot confirm the control — see
|
|
// tests/unit/issue-13381-gemini-web-model-selection.test.ts for that full
|
|
// confirmed/unconfirmed contract. The two tests below are updated to match:
|
|
// they still prove the SAME `checkGeminiWebUnsupportedControls` pure-function
|
|
// classification above is honored end-to-end, adapted to the fact that
|
|
// reaching it now requires a (mocked) browser session.
|
|
//
|
|
// `tool_choice` forcing is UNCHANGED: no UI control for it exists on any
|
|
// account, ever, so it still fails fast before Playwright launches and
|
|
// before the credential check.
|
|
|
|
test(
|
|
"#9356/#13381 executor returns 400 for reasoning_effort=high once the Extended Thinking " +
|
|
"control cannot be confirmed (genuinely attempted via a mocked browser, not a blanket reject)",
|
|
async () => {
|
|
const playwright = await import("playwright");
|
|
const originalLaunch = playwright.chromium.launch;
|
|
playwright.chromium.launch = (async () => ({
|
|
newContext: async () => ({
|
|
addCookies: async () => {},
|
|
newPage: async () => ({
|
|
on: () => {},
|
|
goto: async () => {},
|
|
waitForTimeout: async () => {},
|
|
// No Extended Thinking control exists in this fake page — every
|
|
// selector lookup reports "not found", proving the fail-closed path.
|
|
waitForSelector: async () => null,
|
|
keyboard: { type: async () => {}, insertText: async () => {}, press: async () => {} },
|
|
}),
|
|
}),
|
|
close: async () => {},
|
|
})) as unknown as typeof originalLaunch;
|
|
|
|
try {
|
|
const result = await run({ reasoning_effort: "high" });
|
|
|
|
assert.equal(result.response.status, 400);
|
|
const body = (await result.response.json()) as ErrorBodyLike;
|
|
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
|
|
assert.match(body.error.message, /Extended Thinking/);
|
|
assert.equal(
|
|
body.error.message.includes("at /"),
|
|
false,
|
|
"error bodies must stay sanitized — no stack traces"
|
|
);
|
|
} finally {
|
|
playwright.chromium.launch = originalLaunch;
|
|
}
|
|
}
|
|
);
|
|
|
|
test("#9356 executor returns 400 for tool_choice=required before launching a browser", async () => {
|
|
const result = await run({ tools: [GET_WEATHER_TOOL], tool_choice: "required" });
|
|
|
|
assert.equal(result.response.status, 400);
|
|
const body = (await result.response.json()) as ErrorBodyLike;
|
|
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
|
|
assert.match(body.error.message, /tool_choice/);
|
|
});
|
|
|
|
test("#9356 the tool_choice capability guard runs ahead of the credential check", async () => {
|
|
// A request that is BOTH uncredentialed and incompatible must report the
|
|
// incompatibility: adding a cookie would not make it work. `tool_choice` is
|
|
// the control this applies to post-#13381 — it never requires a browser to
|
|
// know it cannot be honored (unlike `reasoning_effort`, see above).
|
|
const result = await new GeminiWebExecutor().execute({
|
|
model: "gemini-3.6-flash",
|
|
body: {
|
|
messages: [{ role: "user", content: "hi" }],
|
|
tools: [GET_WEATHER_TOOL],
|
|
tool_choice: "required",
|
|
},
|
|
stream: false,
|
|
credentials: {},
|
|
signal: AbortSignal.timeout(10_000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 400);
|
|
const body = (await result.response.json()) as ErrorBodyLike;
|
|
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
|
|
});
|
|
|
|
test("#9356 a supported request still falls through the guard untouched", async () => {
|
|
// tool_choice:"auto" + tools[] is the #7286 emulation contract. It must NOT
|
|
// be blocked — reaching the (missing) credential check proves the guard let
|
|
// it pass, without needing a browser to prove it.
|
|
const result = await new GeminiWebExecutor().execute({
|
|
model: "gemini-3.6-flash",
|
|
body: {
|
|
messages: [{ role: "user", content: "hi" }],
|
|
tools: [GET_WEATHER_TOOL],
|
|
tool_choice: "auto",
|
|
},
|
|
stream: false,
|
|
credentials: {},
|
|
signal: AbortSignal.timeout(10_000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 401, "should reach the cookie check, not the guard");
|
|
});
|
|
|
|
// ─── Catalog metadata ───────────────────────────────────────────────────────
|
|
|
|
test("#9356 registry advertises no native tool calling and no reasoning for gemini-web", () => {
|
|
assert.ok(gemini_webProvider.models.length > 0);
|
|
for (const model of gemini_webProvider.models) {
|
|
assert.equal(
|
|
model.toolCalling,
|
|
false,
|
|
`${model.id} must not advertise native tool calling — /v1/models feeds agent routers`
|
|
);
|
|
assert.equal(
|
|
model.supportsReasoning,
|
|
false,
|
|
`${model.id} must advertise reasoning:false so agent routers stop selecting it for ` +
|
|
"reasoning work (the executor has no thinking control to drive)"
|
|
);
|
|
}
|
|
});
|
|
|
|
test("#9356 resolved capabilities — not just the raw registry — report no reasoning/tools", () => {
|
|
// The registry literal is only the input; `getResolvedModelCapabilities` is what
|
|
// the catalog, the combo compatibility filter and the thinking-budget translator
|
|
// actually read. Assert the resolved view so a downstream default cannot quietly
|
|
// re-advertise a capability the executor does not have.
|
|
for (const model of gemini_webProvider.models) {
|
|
const input = { provider: "gemini-web", model: model.id };
|
|
assert.equal(supportsReasoning(input), false, `${model.id} resolved reasoning must be false`);
|
|
assert.equal(
|
|
supportsToolCalling(input),
|
|
false,
|
|
`${model.id} resolved NATIVE tool calling must be false — prompt emulation is advertised ` +
|
|
'separately as toolCalling:"emulated" on the provider constant'
|
|
);
|
|
}
|
|
});
|
|
|
|
test("#9356 the provider still advertises emulated tool calling, so #7286 combos keep routing", () => {
|
|
// Guard against over-correcting: dropping the emulation advertisement here would
|
|
// make filterTargetsByRequestCompatibility fail these targets closed and break
|
|
// emulation-only combos (#5240 / #8488).
|
|
assert.equal(providerSupportsEmulatedToolCalling("gemini-web"), true);
|
|
assert.equal(providerSupportsEmulatedToolCalling("gweb"), true);
|
|
});
|