diff --git a/open-sse/executors/gemini-business.ts b/open-sse/executors/gemini-business.ts new file mode 100644 index 0000000000..ec8a4d03cd --- /dev/null +++ b/open-sse/executors/gemini-business.ts @@ -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= + * + * 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 = { + // 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; + + // 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=) + 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 = { + "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): + * )]}' + * + * [["wrb.fr", null, ""]] + * + * [["wrb.fr", null, ""]] + * + * 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; + 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}`; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index f67c2f63e1..2379eb6e22 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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(), diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index b937becf80..402cfad60d 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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 diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index f3f159c554..4f9bfec84c 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -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", diff --git a/tests/unit/gemini-business-provider.test.ts b/tests/unit/gemini-business-provider.test.ts new file mode 100644 index 0000000000..b87d96bda0 --- /dev/null +++ b/tests/unit/gemini-business-provider.test.ts @@ -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."); +});