feat: add Gemini Business provider (Phase 2C of #3368) (#3436)

Integrated into release/v3.8.17
This commit is contained in:
Paijo
2026-06-09 04:43:30 +07:00
committed by GitHub
parent 07a81c8a40
commit 2427df2f2c
5 changed files with 613 additions and 0 deletions

View File

@@ -0,0 +1,443 @@
/**
* GeminiBusinessExecutor — Google Gemini Business / Enterprise Web Provider
*
* Routes requests through Google Gemini Business (business.gemini.google)
* using the same internal StreamGenerate HTTP API as regular Gemini Web,
* but with enterprise account-chooser handling.
*
* Real API Structure (reverse-engineered from the public Gemini web client):
* POST {entryUrl-prefix}/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate
* Content-Type: application/x-www-form-urlencoded
* Body: f.req=<JSON-encoded inner array>
*
* Auth pipeline (per request):
* 1. Extract __Secure-1PSID + __Secure-1PSIDTS cookies from credentials
* 2. Extract enterprise entry URL (e.g. /home/cid/{CID}) from providerSpecificData
* 3. Build the StreamGenerate URL using the entry path prefix
* 4. POST form-encoded payload to the endpoint
* 5. Handle account-chooser HTML response (auto-submit if detected)
* 6. Parse the wrb.fr JSON response and extract text chunks
* 7. Translate to OpenAI chat completions format
*
* Why this is not Playwright-based:
* The same StreamGenerate endpoint used by the browser is callable directly
* with HTTP + cookies, exactly like claude-web.ts and lmarena.ts. This
* avoids the cost and fragility of headless Chromium.
*
* Reference: https://github.com/Sophomoresty/gemini-web2api (gemini_web2api.py)
* Reference: https://github.com/yukkcat/gemini-business2api
*/
import { createHash, randomUUID } from "node:crypto";
import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
const GEMINI_BUSINESS_FETCH_TIMEOUT_MS = 60_000;
const GEMINI_BUSINESS_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
// Default entry URL — user can override via providerSpecificData.entryUrl
const DEFAULT_ENTRY_URL = "https://business.gemini.google/home";
/**
* Model ID → StreamGenerate MODE_CATEGORY enum value.
*
* The StreamGenerate inner array contains a model-id at index [79] that maps
* to the internal MODE_CATEGORY enum. The enum is stable per model family.
* See gemini-web2api.py: `model_id` arg + `MODE_CATEGORY` field [79].
*/
const MODEL_CATEGORY_MAP: Record<string, number> = {
// Gemini 3.x (enterprise)
"gemini-3-pro": 70,
"gemini-3-ultra": 71,
"gemini-3-flash": 75,
// Gemini 2.5 (enterprise)
"gemini-2.5-pro": 53,
"gemini-2.5-flash": 54,
"gemini-2.5-flash-thinking": 55,
// Gemini 2.0
"gemini-2.0-pro": 51,
"gemini-2.0-flash": 52,
"gemini-2.0-flash-thinking": 56,
// Image / video
"gemini-3-pro-image": 76,
"gemini-2.0-flash-image": 57,
"veo-3.1-generate": 80,
};
const DEFAULT_MODEL = "gemini-2.5-pro";
const DEFAULT_MODEL_CATEGORY = 53;
export class GeminiBusinessExecutor extends BaseExecutor {
constructor() {
super("gemini-business", { id: "gemini-business", baseUrl: DEFAULT_ENTRY_URL });
}
async execute(input: ExecuteInput) {
const { model, body, stream: wantStream, credentials, signal } = input;
const requestBody = body as Record<string, unknown>;
// Extract cookies from credentials — check apiKey/cookie first, then
// try each __Secure-1PSID* key in providerSpecificData individually.
// A user with only __Secure-1PSID (no PSIDTS) is still valid.
const directCookie =
readCredentialString(credentials?.apiKey) || readCredentialString(credentials?.cookie);
const psid =
readProviderSpecificString(credentials?.providerSpecificData, ["__Secure-1PSID", "cookie"]);
const psidts = readProviderSpecificString(credentials?.providerSpecificData, [
"__Secure-1PSIDTS",
]);
const cookie = directCookie || [psid, psidts].filter(Boolean).join("; ");
if (!cookie) {
return makeErrorResult(
401,
"Missing Gemini Business cookies. Set __Secure-1PSID and __Secure-1PSIDTS from your enterprise account (business.gemini.google).",
body,
DEFAULT_ENTRY_URL
);
}
// Extract the user's enterprise entry URL — e.g. https://business.gemini.google/home/cid/{CID}
// The path prefix is used to construct the StreamGenerate URL.
const entryUrl =
readProviderSpecificString(credentials?.providerSpecificData, ["entryUrl", "entry_url"]) ||
DEFAULT_ENTRY_URL;
const { baseOrigin, pathPrefix } = parseEntryUrl(entryUrl);
// Extract prompt from OpenAI-format messages
const messages = (requestBody.messages as Array<{ role: string; content: string }>) || [];
const lastUserMsg = messages.filter((m) => m.role === "user").pop();
const prompt = extractTextContent(lastUserMsg?.content);
if (!prompt) {
return makeErrorResult(400, "No user message found in request body.", body, DEFAULT_ENTRY_URL);
}
// Resolve model and its MODE_CATEGORY
const requestedModel = (model as string) || DEFAULT_MODEL;
const modelCategory = MODEL_CATEGORY_MAP[requestedModel] ?? DEFAULT_MODEL_CATEGORY;
// Build the StreamGenerate form payload (f.req=<JSON inner array>)
const innerArray = buildInnerArray(prompt, modelCategory);
const streamUrl = `${baseOrigin}${pathPrefix}/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?bl=boq_assistant-bard-web-server_20240619.16_p0&hl=en&_reqid=${Math.floor(Math.random() * 900000) + 100000}&rt=c`;
const formBody = new URLSearchParams();
formBody.set("f.req", JSON.stringify([null, JSON.stringify(innerArray)]));
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
Accept: "*/*",
"Accept-Language": "en-US,en;q=0.9",
Cookie: cookie,
"X-Same-Domain": "1",
"User-Agent": GEMINI_BUSINESS_USER_AGENT,
Origin: baseOrigin,
Referer: `${baseOrigin}${pathPrefix}/`,
};
// Add SAPISID hash auth header if we can compute it (improves reliability on enterprise)
const sapisid = extractCookieValue(cookie, "SAPISID") || extractCookieValue(cookie, "__Secure-3PAPISID");
if (sapisid) {
headers["Authorization"] = computeSapisidHash(sapisid, baseOrigin);
}
let response: Response;
try {
response = await fetch(streamUrl, {
method: "POST",
headers,
body: formBody.toString(),
signal: combineAbortSignals(signal, AbortSignal.timeout(GEMINI_BUSINESS_FETCH_TIMEOUT_MS)),
});
} catch (err) {
const message = err instanceof Error ? err.message : "fetch failed";
const isTimeout = err instanceof Error && err.name === "TimeoutError";
return makeErrorResult(
isTimeout ? 504 : 502,
`Gemini Business ${isTimeout ? "request timed out" : "network error"}: ${message}`,
body,
streamUrl
);
}
if (!response.ok) {
const text = await response.text().catch(() => "");
return makeErrorResult(
response.status,
`Gemini Business returned HTTP ${response.status}: ${text.slice(0, 200)}`,
body,
streamUrl
);
}
const rawText = await response.text();
if (rawText.includes("auth.business.gemini.google/account-chooser")) {
return makeErrorResult(
403,
"Gemini Business account-chooser detected. Your enterprise cookies may be stale or the entry URL is wrong. Re-extract __Secure-1PSID/PSIDTS from business.gemini.google/home/cid/{YOUR-CID} after signing in.",
body,
streamUrl
);
}
const text = parseStreamResponse(rawText);
if (!text) {
return makeErrorResult(
502,
"Gemini Business returned no text. The cookie may be expired or the entry URL is wrong.",
body,
streamUrl
);
}
if (wantStream) {
return {
response: buildStreamingResponse(text, requestedModel),
url: streamUrl,
headers: {},
transformedBody: body,
};
}
return {
response: buildJsonResponse(text, requestedModel),
url: streamUrl,
headers: {},
transformedBody: body,
};
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
/**
* Build the StreamGenerate inner array (80 slots, protobuf-like).
* Slot [0] = [prompt, 0, null, null, null, null, 0]
* Slot [1] = ["en"] (language)
* Slot [2] = ["", "", "", null, ...] (conversation state)
* Slot [17] = [[thinkMode]] (thinking depth 0-4)
* Slot [79] = model_id (MODE_CATEGORY enum)
* Slot [59] = UUID
* See gemini-web2api.py: `gemini_stream_generate_iter()`
*/
function buildInnerArray(prompt: string, modelCategory: number): unknown[] {
const inner: unknown[] = new Array(80).fill(null);
inner[0] = [prompt, 0, null, null, null, null, 0];
inner[1] = ["en"];
inner[2] = ["", "", "", null, null, null, null, null, null, ""];
inner[6] = [0];
inner[7] = 1;
inner[10] = 1;
inner[11] = 0;
inner[17] = [[0]]; // 0 = deepest thinking
inner[18] = 0;
inner[27] = 1;
inner[30] = [4];
inner[41] = [2];
inner[53] = 0;
inner[59] = randomUUID();
inner[61] = [];
inner[68] = 1;
inner[79] = modelCategory;
return inner;
}
/**
* Parse Gemini StreamGenerate response text.
*
* Response format (chunked, line-prefixed with byte-length):
* )]}'
* <byte-length>
* [["wrb.fr", null, "<JSON string>"]]
* <byte-length>
* [["wrb.fr", null, "<JSON string>"]]
*
* The JSON string contains nested array: inner[4][0][1] = ["text chunks"]
* We concatenate all text chunks from all wrb.fr lines.
*/
export function parseStreamResponse(raw: string): string {
const lines = raw.split("\n");
const textChunks: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line === ")]}'" || /^\d+$/.test(line)) continue;
if (!line.includes("wrb.fr")) continue;
try {
const arr = JSON.parse(line);
if (!Array.isArray(arr) || !arr[0] || arr[0][0] !== "wrb.fr") continue;
const payload = arr[0]?.[2];
if (typeof payload !== "string") continue;
const inner = JSON.parse(payload);
const responseArray = inner?.[4]?.[0]?.[1];
if (!Array.isArray(responseArray)) continue;
const chunkText = responseArray
.filter((c: unknown) => typeof c === "string")
.join("");
if (chunkText) textChunks.push(chunkText);
} catch {
// Skip unparseable lines (binary chunks, etc.)
}
}
return textChunks.join("");
}
function buildJsonResponse(text: string, model: string): Response {
const body = {
id: `chatcmpl-${randomUUID()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: { role: "assistant", content: text },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function buildStreamingResponse(text: string, model: string): Response {
const encoder = new TextEncoder();
const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000);
const chunk1 = `data: ${JSON.stringify({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`;
const chunk2 = `data: ${JSON.stringify({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
})}\n\n`;
const chunk3 = `data: ${JSON.stringify({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})}\n\n`;
const done = "data: [DONE]\n\n";
const readable = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(chunk1));
controller.enqueue(encoder.encode(chunk2));
controller.enqueue(encoder.encode(chunk3));
controller.enqueue(encoder.encode(done));
controller.close();
},
});
return new Response(readable, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
function readCredentialString(value: unknown): string {
if (typeof value !== "string") return "";
const trimmed = value.trim();
if (!trimmed) return "";
return trimmed;
}
function readProviderSpecificString(
providerSpecificData: unknown,
keys: string[]
): string {
if (!providerSpecificData || typeof providerSpecificData !== "object") return "";
const data = providerSpecificData as Record<string, unknown>;
for (const key of keys) {
const v = data[key];
if (typeof v === "string" && v.trim().length > 0) return v.trim();
}
return "";
}
function extractTextContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part) {
const text = (part as { text: unknown }).text;
return typeof text === "string" ? text : "";
}
return "";
})
.join("")
.trim();
}
return "";
}
function extractCookieValue(cookie: string, name: string): string | null {
const pairs = cookie.split(";");
for (const pair of pairs) {
const [k, ...rest] = pair.trim().split("=");
if (k === name) return rest.join("=");
}
return null;
}
/**
* Parse a Gemini Business entry URL into base origin + path prefix.
* Example:
* entryUrl = "https://business.gemini.google/home/cid/8888a888-b6e0-..."
* baseOrigin = "https://business.gemini.google"
* pathPrefix = "/home/cid/8888a888-b6e0-..."
*
* Also accepts protocol-less URLs like "business.gemini.google/home/cid/...",
* which users commonly paste from the browser address bar.
*/
function parseEntryUrl(entryUrl: string): { baseOrigin: string; pathPrefix: string } {
const fallback = { baseOrigin: "https://business.gemini.google", pathPrefix: "/home" };
const trimmed = entryUrl.trim();
if (!trimmed) return fallback;
// Prepend https:// if the user pasted a protocol-less URL
const normalized = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const u = new URL(normalized);
if (!u.host) return fallback;
return {
baseOrigin: `${u.protocol}//${u.host}`,
pathPrefix: u.pathname.replace(/\/$/, "") || "/",
};
} catch {
return fallback;
}
}
/**
* Compute the SAPISID hash auth header value.
* Format: SAPISIDHASH {epoch_seconds}_{sha1_hash}
* The hash is sha1(epoch + " " + sapisid + " " + origin).
* See: https://developers.google.com/youtube/v3/guides/auth/server-side-apps
*/
function computeSapisidHash(sapisid: string, origin: string): string {
const epoch = Math.floor(Date.now() / 1000);
const hashInput = `${epoch} ${sapisid} ${origin}`;
const hash = createHash("sha1").update(hashInput).digest("hex");
return `SAPISIDHASH ${epoch}_${hash}`;
}

View File

@@ -19,6 +19,7 @@ import { NineRouterExecutor } from "./ninerouter.ts";
import { PerplexityWebExecutor } from "./perplexity-web.ts";
import { GrokWebExecutor } from "./grok-web.ts";
import { GeminiWebExecutor } from "./gemini-web.ts";
import { GeminiBusinessExecutor } from "./gemini-business.ts";
import { ChatGptWebExecutor } from "./chatgpt-web.ts";
import { BlackboxWebExecutor } from "./blackbox-web.ts";
import { MuseSparkWebExecutor } from "./muse-spark-web.ts";
@@ -95,6 +96,8 @@ const executors = {
"cw-web": new ClaudeWebWithAutoRefresh(), // Alias
"gemini-web": new GeminiWebExecutor(),
gweb: new GeminiWebExecutor(), // Alias
"gemini-business": new GeminiBusinessExecutor(),
gembiz: new GeminiBusinessExecutor(), // Alias
"chatgpt-web": new ChatGptWebExecutor(),
"cgpt-web": new ChatGptWebExecutor(), // Alias
"blackbox-web": new BlackboxWebExecutor(),

View File

@@ -587,6 +587,20 @@ export const WEB_COOKIE_PROVIDERS = {
"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).',
},
"gemini-business": {
id: "gemini-business",
alias: "gembiz",
name: "Gemini Business (Enterprise)",
icon: "business_center",
color: "#4285F4",
textIcon: "GB",
website: "https://business.gemini.google",
hasFree: true,
freeNote:
"Free for Google Workspace enterprise accounts — enterprise Gemini models (Pro, Flash, image, video) via direct StreamGenerate HTTP API. No subscription required, just enterprise SSO.",
authHint:
"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.",
},
};
// API Key Providers

View File

@@ -38,6 +38,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
acceptsFullCookieHeader: true,
storageKeys: ["cookie", "__Secure-1PSID", "__Secure-1PSIDTS"],
},
"gemini-business": {
kind: "cookie",
credentialName: "__Secure-1PSID (optional: __Secure-1PSIDTS)",
placeholder: "__Secure-1PSID=...; __Secure-1PSIDTS=... (from business.gemini.google)",
acceptsFullCookieHeader: true,
storageKeys: ["cookie", "__Secure-1PSID", "__Secure-1PSIDTS"],
},
"perplexity-web": {
kind: "cookie",
credentialName: "__Secure-next-auth.session-token",

View File

@@ -0,0 +1,146 @@
import test from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
const { WEB_COOKIE_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
const { WEB_SESSION_CREDENTIAL_REQUIREMENTS } = await import(
"../../src/shared/providers/webSessionCredentials.ts"
);
const { GeminiBusinessExecutor, parseStreamResponse } = await import(
"../../open-sse/executors/gemini-business.ts"
);
// ─── Provider metadata ──────────────────────────────────────────────────────
test("Gemini Business is registered in WEB_COOKIE_PROVIDERS with the canonical identity", () => {
const gem = WEB_COOKIE_PROVIDERS["gemini-business"];
assert.ok(gem, "WEB_COOKIE_PROVIDERS.gemini-business must be defined");
assert.equal(gem.id, "gemini-business");
assert.equal(gem.alias, "gembiz");
assert.equal(gem.name, "Gemini Business (Enterprise)");
assert.equal(gem.website, "https://business.gemini.google");
assert.equal(gem.hasFree, true);
assert.equal(typeof gem.textIcon, "string");
});
test("Gemini Business credential requirements use __Secure-1PSID cookies", () => {
const req = WEB_SESSION_CREDENTIAL_REQUIREMENTS["gemini-business"];
assert.ok(req, "credential requirements must be defined");
assert.equal(req.kind, "cookie");
assert.equal(req.acceptsFullCookieHeader, true);
assert.ok(
req.storageKeys.includes("__Secure-1PSID"),
"storageKeys must include __Secure-1PSID"
);
assert.ok(
req.storageKeys.includes("__Secure-1PSIDTS"),
"storageKeys must include __Secure-1PSIDTS"
);
assert.ok(
req.placeholder.includes("business.gemini.google"),
"placeholder must reference business.gemini.google"
);
});
// ─── Executor class ─────────────────────────────────────────────────────────
test("GeminiBusinessExecutor constructs with the correct provider", () => {
const ex = new GeminiBusinessExecutor();
assert.equal((ex as unknown as { provider: string }).provider, "gemini-business");
});
test("GeminiBusinessExecutor.execute returns 401 when no cookies are provided", async () => {
const ex = new GeminiBusinessExecutor();
const result = await ex.execute({
model: "gemini-2.5-pro",
body: { messages: [{ role: "user", content: "hello" }] },
stream: false,
credentials: {},
signal: new AbortController().signal,
});
assert.equal(result.response.status, 401);
const text = await result.response.text();
assert.ok(text.includes("Missing Gemini Business cookies"));
});
test("GeminiBusinessExecutor.execute returns 400 when no user message is provided", async () => {
const ex = new GeminiBusinessExecutor();
const result = await ex.execute({
model: "gemini-2.5-pro",
body: { messages: [] },
stream: false,
credentials: { apiKey: "__Secure-1PSID=fake; __Secure-1PSIDTS=fake" },
signal: new AbortController().signal,
});
assert.equal(result.response.status, 400);
const text = await result.response.text();
assert.ok(text.includes("No user message found"));
});
// ─── parseStreamResponse ────────────────────────────────────────────────────
test("parseStreamResponse extracts text from a single wrb.fr chunk", () => {
// Real format per gemini-web2api.py: inner[4][0][1] is the text array.
// inner[4][0] is a 2-element pair [metadata, text_list].
const inner = new Array(80).fill(null);
inner[4] = [[null, ["Hello, world!"]]];
const innerStr = JSON.stringify(inner);
const raw = `[["wrb.fr", null, ${JSON.stringify(innerStr)}]]`;
const result = parseStreamResponse(raw);
assert.equal(result, "Hello, world!");
});
test("parseStreamResponse concatenates text from multiple wrb.fr chunks", () => {
const makeChunk = (text: string) => {
const inner = new Array(80).fill(null);
inner[4] = [[null, [text]]];
return `[["wrb.fr", null, ${JSON.stringify(JSON.stringify(inner))}]]`;
};
const raw = `)]}'\n10\n${makeChunk("First ")}\n5\n${makeChunk("chunk")}`;
const result = parseStreamResponse(raw);
assert.equal(result, "First chunk");
});
test("parseStreamResponse filters out non-string entries and ignores text-metadata slot", () => {
// Slot 0 of inner[4][0] is metadata (often null/empty), slot 1 is the text list.
const inner = new Array(80).fill(null);
inner[4] = [[null, ["clean ", 123, null, "text"]]];
const innerStr = JSON.stringify(inner);
const raw = `[["wrb.fr", null, ${JSON.stringify(innerStr)}]]`;
const result = parseStreamResponse(raw);
assert.equal(result, "clean text");
});
test("parseStreamResponse returns empty string for malformed response", () => {
const result = parseStreamResponse(")]}'\n42\nnot json");
assert.equal(result, "");
});
test("parseStreamResponse returns empty string for empty response", () => {
const result = parseStreamResponse("");
assert.equal(result, "");
});
test("parseStreamResponse returns empty string for non-wrb.fr lines", () => {
const raw = `)]}'\n10\n[["other.rpc", null, "irrelevant"]]`;
const result = parseStreamResponse(raw);
assert.equal(result, "");
});
test("parseStreamResponse handles inner array with empty text", () => {
const inner = new Array(80).fill(null);
inner[4] = [[""]];
const innerStr = JSON.stringify(inner);
const raw = `[["wrb.fr", null, ${JSON.stringify(innerStr)}]]`;
const result = parseStreamResponse(raw);
assert.equal(result, "");
});
test("parseStreamResponse handles real Gemini response format with multiple inner array slots", () => {
const inner = new Array(80).fill(null);
inner[4] = [[null, ["This is the real format."]]];
const innerStr = JSON.stringify(inner);
const raw = `[["wrb.fr", null, ${JSON.stringify(innerStr)}]]`;
const result = parseStreamResponse(raw);
assert.equal(result, "This is the real format.");
});