diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index e2af17f8d1..c8a126c186 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -2190,6 +2190,24 @@ export const REGISTRY: Record = { ], }, + "gemini-web": { + id: "gemini-web", + alias: "gweb", + format: "openai", + executor: "gemini-web", + baseUrl: "https://gemini.google.com/app", + authType: "apikey", + authHeader: "cookie", + models: [ + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "gemini-2.0-pro", name: "Gemini 2.0 Pro" }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, + { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, + { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, + ], + }, + mistral: { id: "mistral", alias: "mistral", diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts new file mode 100644 index 0000000000..896aebd1cb --- /dev/null +++ b/open-sse/executors/gemini-web.ts @@ -0,0 +1,294 @@ +/** + * GeminiWebExecutor — Gemini Web Session Provider + * + * Routes requests through Google Gemini's web interface using browser + * cookies + Playwright automation. Translates between OpenAI chat + * completions format and Gemini's web UI. + * + * Auth: Cookie-based (__Secure-1PSID + __Secure-1PSIDTS from gemini.google.com) + * Method: Playwright browser automation + * + * Note: Streaming is pseudo-streaming — waits for full Gemini response then + * sends as single SSE chunk. Gemini's StreamGenerate endpoint returns complete + * responses, not chunked streams. + */ + +import { BaseExecutor, type ExecuteInput } from "./base.ts"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const GEMINI_URL = "https://gemini.google.com/app"; +const GEMINI_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface GeminiMessage { + role: string; + content: string; +} + +interface GeminiRequestBody { + messages: GeminiMessage[]; + model?: string; + stream?: boolean; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function formatChatCompletion(content: string, model: string, finishReason = "stop") { + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: finishReason }], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; +} + +function formatStreamChunk(content: string, model: string, finishReason: string | null = null) { + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: content ? { content } : {}, finish_reason: finishReason }], + }; +} + +/** + * Parse cookie string, stripping attributes (Path, Domain, Expires, etc.) + * Input: full browser cookie string or just "name=value; name2=value2" + * Output: array of { name, value } pairs + */ +function parseCookies(raw: string): Array<{ name: string; value: string }> { + return raw + .split(";") + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const eqIdx = part.indexOf("="); + if (eqIdx === -1) return null; + const name = part.substring(0, eqIdx).trim(); + const value = part.substring(eqIdx + 1).trim(); + // Skip cookie attributes that aren't name=value pairs + if (!name || !value) return null; + const lowerName = name.toLowerCase(); + if ( + ["path", "domain", "expires", "max-age", "secure", "httponly", "samesite"].includes( + lowerName + ) + ) { + return null; + } + return { name, value }; + }) + .filter(Boolean) as Array<{ name: string; value: string }>; +} + +/** + * Parse Gemini StreamGenerate response text. + * + * Response format: + * )]}' + * + * [["wrb.fr", null, ""]] + * + * [["wrb.fr", null, ""]] + * + * The JSON string contains nested array: inner[4][0][1] = ["text chunks"] + * We return text from the first wrb.fr line that contains content. + */ +function parseStreamResponse(raw: string): string { + const lines = raw.split("\n"); + for (const line of lines) { + if (!line.trim() || line.trim() === ")]}'" || /^\d+$/.test(line.trim())) 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); + // Defensive: check each level before accessing + const responseArray = inner?.[4]?.[0]?.[1]; + if (!Array.isArray(responseArray)) continue; + const text = responseArray.filter((c: unknown) => typeof c === "string").join(""); + if (text) return text; + } catch { + // Skip unparseable lines + } + } + return ""; +} + +// ─── Executor ─────────────────────────────────────────────────────────────── + +export class GeminiWebExecutor extends BaseExecutor { + constructor() { + super("gemini-web", { id: "gemini-web", baseUrl: GEMINI_URL }); + } + + async execute(input: ExecuteInput) { + const { model, body, stream, credentials } = input; + const requestBody = body as GeminiRequestBody; + + const cookie = credentials.apiKey || ""; + if (!cookie) { + return { + response: new Response(JSON.stringify({ error: "Missing Gemini cookies" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } + + const messages = requestBody.messages || []; + const lastUserMsg = messages.filter((m) => m.role === "user").pop(); + const prompt = lastUserMsg?.content || ""; + + if (!prompt) { + return { + response: new Response(JSON.stringify({ error: "No user message found" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } + + let browser: any = null; + try { + const { chromium } = await import("playwright"); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ userAgent: GEMINI_USER_AGENT }); + + // Parse cookies — strips attributes like Path, Domain, Expires + const cookiePairs = parseCookies(cookie); + await context.addCookies( + cookiePairs.map(({ name, value }) => ({ + name, + value, + domain: ".google.com", + path: "/", + secure: true, + })) + ); + + const page = await context.newPage(); + + // Capture first StreamGenerate response + let responseText = ""; + let captured = false; + const responsePromise = new Promise((resolve) => { + page.on("response", async (resp: any) => { + if (captured || !resp.url().includes("StreamGenerate")) return; + captured = true; + try { + const raw = await resp.text(); + responseText = parseStreamResponse(raw); + } catch { + /* ignore */ + } + resolve(); + }); + }); + + await page.goto(GEMINI_URL, { waitUntil: "domcontentloaded", timeout: 20000 }); + await page.waitForTimeout(3000); + + // Type and send message + const inputEl = await page.waitForSelector(".ql-editor, [contenteditable='true']", { + timeout: 10000, + }); + await inputEl.click(); + await page.keyboard.type(prompt, { delay: 10 }); + await page.waitForTimeout(300); + await page.keyboard.press("Enter"); + + // Wait for response or timeout + await Promise.race([responsePromise, page.waitForTimeout(30000)]); + + if (!responseText) { + return { + response: new Response(JSON.stringify({ error: "No response from Gemini" }), { + status: 502, + headers: { "Content-Type": "application/json" }, + }), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } + + const modelId = model || "gemini-2.5-pro"; + + if (stream) { + // Pseudo-streaming: send complete response as single SSE chunk + // Gemini's StreamGenerate returns complete responses, not chunked streams + const encoder = new TextEncoder(); + const readable = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify(formatStreamChunk(responseText, modelId))}\n\n` + ) + ); + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(formatStreamChunk("", modelId, "stop"))}\n\n`) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return { + response: new Response(readable, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } + + return { + response: new Response(JSON.stringify(formatChatCompletion(responseText, modelId)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } catch (error) { + return { + response: new Response( + JSON.stringify({ error: error instanceof Error ? error.message : "Unknown error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } finally { + // Always close browser to prevent resource leaks + if (browser) { + try { + await browser.close(); + } catch { + /* ignore close errors */ + } + } + } + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index b084fd2c8b..cf5a64c223 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -15,6 +15,7 @@ import { VertexExecutor } from "./vertex.ts"; import { CliproxyapiExecutor } from "./cliproxyapi.ts"; import { PerplexityWebExecutor } from "./perplexity-web.ts"; import { GrokWebExecutor } from "./grok-web.ts"; +import { GeminiWebExecutor } from "./gemini-web.ts"; import { ChatGptWebExecutor } from "./chatgpt-web.ts"; import { BlackboxWebExecutor } from "./blackbox-web.ts"; import { MuseSparkWebExecutor } from "./muse-spark-web.ts"; @@ -65,6 +66,8 @@ const executors = { "perplexity-web": new PerplexityWebExecutor(), "pplx-web": new PerplexityWebExecutor(), // Alias "grok-web": new GrokWebExecutor(), + "gemini-web": new GeminiWebExecutor(), + gweb: new GeminiWebExecutor(), // Alias "chatgpt-web": new ChatGptWebExecutor(), "cgpt-web": new ChatGptWebExecutor(), // Alias "blackbox-web": new BlackboxWebExecutor(), @@ -113,6 +116,7 @@ export { CliproxyapiExecutor } from "./cliproxyapi.ts"; export { VertexExecutor } from "./vertex.ts"; export { PerplexityWebExecutor } from "./perplexity-web.ts"; export { GrokWebExecutor } from "./grok-web.ts"; +export { GeminiWebExecutor } from "./gemini-web.ts"; export { KieExecutor } from "./kie.ts"; export { ChatGptWebExecutor } from "./chatgpt-web.ts"; export { BlackboxWebExecutor } from "./blackbox-web.ts"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index e6270fa5fb..8a8196de5f 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -174,6 +174,17 @@ export const WEB_COOKIE_PROVIDERS = { website: "https://grok.com", authHint: "Paste your sso= cookie value from grok.com", }, + "gemini-web": { + id: "gemini-web", + alias: "gweb", + name: "Gemini Web (Free)", + icon: "auto_awesome", + color: "#4285F4", + textIcon: "GWeb", + website: "https://gemini.google.com", + authHint: + "Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon.", + }, "perplexity-web": { id: "perplexity-web", alias: "pplx-web", diff --git a/tests/unit/gemini-web.test.ts b/tests/unit/gemini-web.test.ts new file mode 100644 index 0000000000..f06fed6f51 --- /dev/null +++ b/tests/unit/gemini-web.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts"); +const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); + +// ─── Registration ─────────────────────────────────────────────────────────── + +test("GeminiWebExecutor is registered in executor index", () => { + assert.ok(hasSpecializedExecutor("gemini-web")); + const executor = getExecutor("gemini-web"); + assert.ok(executor instanceof GeminiWebExecutor); +}); + +test("GeminiWebExecutor sets correct provider name", () => { + const executor = new GeminiWebExecutor(); + assert.equal(executor.getProvider(), "gemini-web"); +}); + +// ─── Input validation ─────────────────────────────────────────────────────── + +test("Returns 401 when no cookies provided", async () => { + const executor = new GeminiWebExecutor(); + const result = await executor.execute({ + model: "gemini-2.5-pro", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + credentials: {}, + signal: AbortSignal.timeout(10000), + log: null, + }); + assert.equal(result.response.status, 401); + const json = (await result.response.json()) as any; + assert.ok(json.error.includes("Missing Gemini cookies")); +}); + +test("Returns 400 when no user message", async () => { + const executor = new GeminiWebExecutor(); + const result = await executor.execute({ + model: "gemini-2.5-pro", + body: { messages: [{ role: "system", content: "You are helpful" }], stream: false }, + stream: false, + credentials: { apiKey: "test-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + assert.equal(result.response.status, 400); + const json = (await result.response.json()) as any; + assert.ok(json.error.includes("No user message")); +}); + +// ─── Provider registration ────────────────────────────────────────────────── + +test("Provider: gemini-web in WEB_COOKIE_PROVIDERS", async () => { + const { WEB_COOKIE_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + assert.ok(WEB_COOKIE_PROVIDERS["gemini-web"], "gemini-web should be in WEB_COOKIE_PROVIDERS"); + assert.equal(WEB_COOKIE_PROVIDERS["gemini-web"].id, "gemini-web"); + assert.ok(WEB_COOKIE_PROVIDERS["gemini-web"].authHint); +}); + +test("Provider: gemini-web in providerRegistry", async () => { + const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + assert.ok(REGISTRY["gemini-web"], "gemini-web should be in providerRegistry"); + assert.equal(REGISTRY["gemini-web"].executor, "gemini-web"); + assert.ok(REGISTRY["gemini-web"].models.length > 0); +}); + +test("Provider: gemini-web has correct models", async () => { + const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + const models = REGISTRY["gemini-web"].models; + const modelIds = models.map((m: any) => m.id); + assert.ok(modelIds.includes("gemini-2.5-pro")); + assert.ok(modelIds.includes("gemini-2.5-flash")); + assert.ok(modelIds.includes("gemini-2.0-pro")); + assert.ok(modelIds.includes("gemini-2.0-flash")); +});