mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
Integrated into release/v3.8.17
This commit is contained in:
443
open-sse/executors/gemini-business.ts
Normal file
443
open-sse/executors/gemini-business.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
146
tests/unit/gemini-business-provider.test.ts
Normal file
146
tests/unit/gemini-business-provider.test.ts
Normal 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.");
|
||||
});
|
||||
Reference in New Issue
Block a user