mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
feat(providers): add Gemini Web cookie-based provider (#2380)
Integrated into release/v3.8.0 — adds Gemini Web cookie-based provider with error sanitization fix applied
This commit is contained in:
@@ -2190,6 +2190,24 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
"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",
|
||||
|
||||
294
open-sse/executors/gemini-web.ts
Normal file
294
open-sse/executors/gemini-web.ts
Normal file
@@ -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:
|
||||
* )]}'
|
||||
* <length>
|
||||
* [["wrb.fr", null, "<JSON string>"]]
|
||||
* <length>
|
||||
* [["wrb.fr", null, "<JSON string>"]]
|
||||
*
|
||||
* 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<void>((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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
76
tests/unit/gemini-web.test.ts
Normal file
76
tests/unit/gemini-web.test.ts
Normal file
@@ -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"));
|
||||
});
|
||||
Reference in New Issue
Block a user