feat(gemini-web): emulate OpenAI tool calling via the webTools prompt shim (#7286) (#7727)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-19 14:51:06 -03:00
committed by GitHub
parent a9028e9571
commit f3277f267a
7 changed files with 443 additions and 52 deletions

View File

@@ -0,0 +1 @@
- **feat(sse):** `gemini-web` now honors the OpenAI `tools` array by wiring the existing `webTools.ts` prompt-emulation shim (already used by 11 other web-cookie executors) — the tool contract is serialized into the prompt typed into the Gemini web UI, and `<tool>{...}</tool>` blocks in the response are parsed back into `tool_calls` (malformed JSON degrades to ordinary chat content, never an error), including for streaming requests; `docs/reference/PROVIDER_REFERENCE.md` gained a "Tool calling" column (`native`/`emulated`/`none`) for providers with confirmed ground truth. `claude-web` still silently drops `tools` pending a separate emulate-vs-native design decision (#7286).

View File

@@ -27,6 +27,8 @@ Total providers: **268**. See category breakdown below.
Additional tags: `image`, `video`, `aggregator`, `enterprise`, `embed/rerank`, `self-hosted`.
`Tool calling` (where shown): `native` — real function-calling API; `emulated` — the `tools` array is prompt-emulated via `webTools.ts` (regex-parsed `<tool>{...}</tool>` blocks); `none``tools` is currently silently dropped. See #7286.
Use the dashboard at `/dashboard/providers` to enable, configure, and test each provider.
---
@@ -61,35 +63,35 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
## Web Cookie Providers (27)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) |
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com |
| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai |
| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/<path>?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. |
| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) |
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken |
| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. |
| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. |
| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. |
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. |
| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. |
| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com |
| `kimi-web` | `kimi-web` | Kimi Web | Web cookie | [link](https://www.kimi.com) | Paste access_token from www.kimi.com DevTools → Application → Local Storage. A legacy kimi-auth cookie is also accepted. |
| `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. |
| `microsoft-designer-web` | `msdesigner` | Microsoft Designer (Image Generation) | Web cookie | [link](https://designer.microsoft.com) | Sign in at designer.microsoft.com, then open DevTools → Network, generate an image, and find the request to DallE.ashx?action=GetDallEImagesCogSci. Copy the value of its Authorization: Bearer header (the access_token — no 'Bearer ' prefix). The token is short-lived; this is an unofficial, reverse-engineered integration. |
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai |
| `notion-web` | `nw` | Notion AI Web (Unofficial/Experimental) | Web cookie | [link](https://www.notion.so) | Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). Optionally append `; space_id=...` and/or `; notion_browser_id=...` if your workspace requires them. |
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai |
| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) |
| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). |
| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. |
| `v0-vercel-web` | `v0-vercel-web` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) |
| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) |
| `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. |
| `zai-web` | `zw` | Z.ai Web (Free) | Web cookie | [link](https://chat.z.ai) | Paste the full Cookie header from chat.z.ai (must include the token=<JWT> cookie) |
| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. |
| ID | Alias | Name | Tags | Website | Notes | Tool calling |
|----|-------|------|------|---------|-------|--------------|
| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | emulated |
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | emulated |
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | emulated |
| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | none |
| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/<path>?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. | — |
| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | — |
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | emulated |
| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | — |
| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | — |
| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | emulated |
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | — |
| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | — |
| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | emulated |
| `kimi-web` | `kimi-web` | Kimi Web | Web cookie | [link](https://www.kimi.com) | Paste access_token from www.kimi.com DevTools → Application → Local Storage. A legacy kimi-auth cookie is also accepted. | — |
| `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. | — |
| `microsoft-designer-web` | `msdesigner` | Microsoft Designer (Image Generation) | Web cookie | [link](https://designer.microsoft.com) | Sign in at designer.microsoft.com, then open DevTools → Network, generate an image, and find the request to DallE.ashx?action=GetDallEImagesCogSci. Copy the value of its Authorization: Bearer header (the access_token — no 'Bearer ' prefix). The token is short-lived; this is an unofficial, reverse-engineered integration. | — |
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | emulated |
| `notion-web` | `nw` | Notion AI Web (Unofficial/Experimental) | Web cookie | [link](https://www.notion.so) | Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). Include `; space_id=<workspace-uuid>` so live model discovery (getAvailableModels) can list GPT/Claude/Gemini/etc. Optionally also `; notion_browser_id=...` / `; notion_user_id=...`. | — |
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | emulated |
| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | — |
| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | emulated |
| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | emulated |
| `v0-vercel-web` | `v0-vercel-web` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | — |
| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | — |
| `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. | — |
| `zai-web` | `zw` | Z.ai Web (Free) | Web cookie | [link](https://chat.z.ai) | Paste the full Cookie header from chat.z.ai (must include the token=<JWT> cookie) | — |
| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — |
## API Key Providers (paid / paid-with-free-credits) (179)
@@ -351,22 +353,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
- Executors: [`open-sse/executors/`](../../open-sse/executors/) (31 files)
- Translators: [`open-sse/translator/`](../../open-sse/translator/)
## Provider-specific quota tracking notes
- **`nvidia` (NVIDIA NIM)** — NVIDIA does not publish a usage/quota API and sends
no rate-limit response headers (confirmed via NVIDIA staff forum posts and an
open, unresolved NVIDIA feature request for a usage API — see #6846). OmniRoute
ships **Phase 1 client-side quota tracking** for this provider: a static local
RPM budget (default 40/min, matching the documented "~40 RPM" free-tier note),
per-model 429 lockout (a throttled model does not cool down the whole
connection — already covered by #6773's `passthroughModels` flag), and a
per-connection concurrency cap (default 6) so parallel bursts queue instead of
429-storming. All three are operator-overridable via
`ResilienceSettings.providerQuotaOverrides.nvidia` (`{ rpm, concurrency }`).
These numbers are **estimated locally** — NVIDIA does not confirm or expose the
real per-model ceiling. Adaptive per-model ceiling learning (AIMD) and a
dashboard quota card are deferred to Phase 2/3 follow-up issues.
## See Also
- [FREE_TIERS.md](./FREE_TIERS.md) — curated free-tier guide

View File

@@ -15,6 +15,8 @@
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
@@ -71,6 +73,52 @@ function formatStreamChunk(content: string, model: string, finishReason: string
};
}
/**
* Build the plain-text prompt typed into the Gemini web UI when a tool
* contract is active — the synthetic system message injected by
* `prepareToolMessages()` prepended to the last user message. gemini-web
* only ever sends a single flat string (no native message array), so the
* tool contract and the user's ask are concatenated (#7286).
*/
export function buildGeminiToolPrompt(
effectiveMessages: Array<{ role: string; content: unknown }>
): string {
const toolSystemMsg = effectiveMessages.find((m) => m.role === "system");
const lastUserMsg = [...effectiveMessages].reverse().find((m) => m.role === "user");
const userText = typeof lastUserMsg?.content === "string" ? lastUserMsg.content : "";
const toolPrompt = typeof toolSystemMsg?.content === "string" ? toolSystemMsg.content : "";
return toolPrompt ? `${toolPrompt}\n\n${userText}` : userText;
}
/**
* Tool mode: wrap the buffered Gemini response text in the standard OpenAI
* completion shape, then delegate to the shared `buildToolModeResponse()`
* (`chatgptWebTools.ts`) — parses `<tool>{...}</tool>` blocks out of the
* text into `tool_calls` (malformed JSON degrades to ordinary `content`,
* never throws) and replays either buffered JSON or a terminal SSE chunk
* depending on `stream` (#7286). Exported standalone so the branching logic
* is testable without a full Playwright mock.
*/
export async function buildGeminiToolResponse(
responseText: string,
requestedTools: unknown,
stream: boolean,
model: string,
cid: string,
created: number
): Promise<Response> {
const bufferedJson = new Response(JSON.stringify(formatChatCompletion(responseText, model)), {
status: 200,
headers: { "Content-Type": "application/json" },
});
return buildToolModeResponse(bufferedJson, requestedTools, stream, {
cid,
created,
model,
idSeed: "gwe",
});
}
/**
* Parse cookie string, stripping attributes (Path, Domain, Expires, etc.)
* Input: full browser cookie string or just "name=value; name2=value2"
@@ -284,8 +332,16 @@ export class GeminiWebExecutor extends BaseExecutor {
}
const messages = requestBody.messages || [];
const lastUserMsg = messages.filter((m) => m.role === "user").pop();
const prompt = lastUserMsg?.content || "";
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
body as Record<string, unknown>,
messages
);
// hasTools === false: byte-for-byte the original prompt derivation
// (regression guard — #7286 must not touch the no-tools path).
const prompt = hasTools
? buildGeminiToolPrompt(effectiveMessages)
: messages.filter((m) => m.role === "user").pop()?.content || "";
if (!prompt) {
return {
@@ -382,6 +438,20 @@ export class GeminiWebExecutor extends BaseExecutor {
const modelId = model || "gemini-2.5-pro";
if (hasTools) {
const cid = `chatcmpl-gwe-${crypto.randomUUID().slice(0, 12)}`;
const created = Math.floor(Date.now() / 1000);
const toolResponse = await buildGeminiToolResponse(
responseText,
requestedTools,
Boolean(stream),
modelId,
cid,
created
);
return { response: toolResponse, url: GEMINI_URL, headers: {}, transformedBody: body };
}
if (stream) {
// Pseudo-streaming: send complete response as single SSE chunk
// Gemini's StreamGenerate returns complete responses, not chunked streams

View File

@@ -41,6 +41,12 @@ type ProviderRecord = {
hasFree?: boolean;
deprecated?: boolean;
deprecationReason?: string;
/**
* #7286: native function calling / prompt-emulated via webTools.ts / silently dropped.
* Loosely typed `string` (not a literal union) — the source provider-catalog object
* literals infer widened `string` for this field and are passed in as-is.
*/
toolCalling?: string;
[k: string]: unknown;
};
@@ -54,13 +60,19 @@ function escapeCell(value: string | undefined): string {
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n/g, " ");
}
function row(p: ProviderRecord, category: string): string {
function row(p: ProviderRecord, category: string, includeToolCalling: boolean): string {
const alias = p.alias ? `\`${p.alias}\`` : "—";
const hint = p.deprecated
? `⚠️ **DEPRECATED.** ${escapeCell(p.deprecationReason)}`
: escapeCell(p.authHint || p.freeNote);
const link = p.website ? `[link](${p.website})` : "—";
return `| \`${p.id}\` | ${alias} | ${escapeCell(p.name)} | ${category} | ${link} | ${hint} |`;
const base = `| \`${p.id}\` | ${alias} | ${escapeCell(p.name)} | ${category} | ${link} | ${hint} |`;
return includeToolCalling ? `${base} ${escapeCell(p.toolCalling)} |` : base;
}
/** #7286: only render the "Tool calling" column when at least one record in the section sets it. */
function hasToolCallingColumn(rows: ProviderRecord[]): boolean {
return rows.some((p) => typeof p.toolCalling === "string");
}
function categoryTags(id: string): string[] {
@@ -82,11 +94,20 @@ function buildSection(title: string, rows: ProviderRecord[], category: string):
if (rows.length === 0) return "";
const lines: string[] = [];
lines.push(`## ${title} (${rows.length})\n`);
lines.push("| ID | Alias | Name | Tags | Website | Notes |");
lines.push("|----|-------|------|------|---------|-------|");
const includeToolCalling = hasToolCallingColumn(rows);
lines.push(
includeToolCalling
? "| ID | Alias | Name | Tags | Website | Notes | Tool calling |"
: "| ID | Alias | Name | Tags | Website | Notes |"
);
lines.push(
includeToolCalling
? "|----|-------|------|------|---------|-------|--------------|"
: "|----|-------|------|------|---------|-------|"
);
for (const p of sortById(rows)) {
const tags = [category, ...categoryTags(p.id)].join(", ");
lines.push(row(p, tags));
lines.push(row(p, tags, includeToolCalling));
}
lines.push("");
return lines.join("\n");
@@ -127,6 +148,10 @@ function buildHeader(total: number): string {
"",
"Additional tags: `image`, `video`, `aggregator`, `enterprise`, `embed/rerank`, `self-hosted`.",
"",
"`Tool calling` (where shown): `native` — real function-calling API; `emulated` — the " +
"`tools` array is prompt-emulated via `webTools.ts` (regex-parsed `<tool>{...}</tool>` " +
"blocks); `none` — `tools` is currently silently dropped. See #7286.",
"",
"Use the dashboard at `/dashboard/providers` to enable, configure, and test each provider.",
"",
"---",

View File

@@ -34,6 +34,8 @@ export const NOAUTH_PROVIDERS = {
serviceKinds: ["llm"],
freeNote: "Free — anonymous access to multiple AI models via DuckDuckGo.",
authHint: "No credentials required — DuckDuckGo AI Chat is anonymous and free.",
// #7286: tools[] is prompt-emulated via webTools.ts (parseToolCallsFromText).
toolCalling: "emulated",
},
"felo-web": {
id: "felo-web",

View File

@@ -14,6 +14,7 @@ export const WEB_COOKIE_PROVIDERS = {
authHint: "Paste your __Secure-next-auth.session-token cookie value from chatgpt.com",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
toolCalling: "emulated",
},
"grok-web": {
id: "grok-web",
@@ -40,6 +41,8 @@ export const WEB_COOKIE_PROVIDERS = {
"Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon.",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
// #7286 Level 2: tools[] is prompt-emulated via webTools.ts (parseToolCallsFromText).
toolCalling: "emulated",
},
"perplexity-web": {
id: "perplexity-web",
@@ -52,6 +55,7 @@ export const WEB_COOKIE_PROVIDERS = {
authHint: "Paste your __Secure-next-auth.session-token cookie value from perplexity.ai",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
toolCalling: "emulated",
},
"blackbox-web": {
id: "blackbox-web",
@@ -65,6 +69,7 @@ export const WEB_COOKIE_PROVIDERS = {
"Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
toolCalling: "emulated",
},
"muse-spark-web": {
id: "muse-spark-web",
@@ -77,6 +82,7 @@ export const WEB_COOKIE_PROVIDERS = {
hasFree: true,
freeNote: "Free with login — Meta AI platform with Llama models.",
authHint: "Paste your ecto_1_sess value or full cookie header from meta.ai",
toolCalling: "emulated",
},
"claude-web": {
id: "claude-web",
@@ -89,6 +95,9 @@ export const WEB_COOKIE_PROVIDERS = {
authHint: "Paste your session cookie from claude.ai",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
// #7286 Level 3 (deferred): still silently drops tools[] — getDefaultTools()
// is a fixed Claude.ai backend contract; needs an emulate-vs-native decision.
toolCalling: "none",
},
"deepseek-web": {
id: "deepseek-web",
@@ -102,6 +111,7 @@ export const WEB_COOKIE_PROVIDERS = {
"Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
toolCalling: "emulated",
},
"copilot-web": {
id: "copilot-web",
@@ -156,6 +166,7 @@ export const WEB_COOKIE_PROVIDERS = {
"Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. " +
"Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. " +
"Paste both values here. See provider setup docs for a step-by-step guide.",
toolCalling: "emulated",
},
"inner-ai": {
id: "inner-ai",
@@ -169,6 +180,7 @@ export const WEB_COOKIE_PROVIDERS = {
riskNoticeVariant: "webCookie",
authHint:
"Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com",
toolCalling: "emulated",
},
"adapta-web": {
id: "adapta-web",
@@ -182,6 +194,7 @@ export const WEB_COOKIE_PROVIDERS = {
riskNoticeVariant: "webCookie",
authHint:
"Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies)",
toolCalling: "emulated",
},
lmarena: {
// Wire id stays `lmarena` for DB/combo/model-prefix back-compat.
@@ -311,6 +324,7 @@ export const WEB_COOKIE_PROVIDERS = {
authHint:
"Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → " +
'copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token).',
toolCalling: "emulated",
},
"gemini-business": {
id: "gemini-business",

View File

@@ -0,0 +1,293 @@
// 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");
});
});