Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Generate text and images via the reverse-engineered Gemini Web API with multi-turn conversation support.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/gemini-webapi/client.ts
1import { Endpoint, ErrorCode, Headers, Model } from './constants.js';2import { GemMixin } from './components/gem-mixin.js';3import {4APIError,5AuthError,6GeminiError,7ImageGenerationError,8ModelInvalid,9TemporarilyBlocked,10TimeoutError,11UsageLimitExceeded,12} from './exceptions.js';13import { Candidate, Gem, GeneratedImage, ModelOutput, RPCData, WebImage } from './types/index.js';14import {15extract_json_from_response,16get_access_token,17get_nested_value,18logger,19parse_file_name,20rotate_1psidts,21rotate_tasks,22fetch_with_timeout,23sleep,24upload_file,25write_cookie_file,26resolveGeminiWebCookiePath,27} from './utils/index.js';2829type InitOptions = {30timeout?: number;31auto_close?: boolean;32close_delay?: number;33auto_refresh?: boolean;34refresh_interval?: number;35verbose?: boolean;36};3738type RequestKwargs = RequestInit & { timeout_ms?: number };3940const GENERATED_IMAGE_URL_PREFIX = 'https://lh3.googleusercontent.com/gg-dl/';4142function normalize_headers(h?: HeadersInit): Record<string, string> {43if (!h) return {};44if (Array.isArray(h)) return Object.fromEntries(h.map(([k, v]) => [k, v]));45if (h instanceof Headers) {46const out: Record<string, string> = {};47h.forEach((v, k) => {48out[k] = v;49});50return out;51}52return { ...(h as Record<string, string>) };53}5455function collect_strings(root: unknown, accept: (s: string) => boolean, limit: number = 20): string[] {56const out: string[] = [];57const seen = new Set<string>();58const stack: unknown[] = [root];5960while (stack.length > 0 && out.length < limit) {61const v = stack.pop();62if (typeof v === 'string') {63if (accept(v) && !seen.has(v)) {64seen.add(v);65out.push(v);66}67continue;68}6970if (Array.isArray(v)) {71for (let i = 0; i < v.length; i++) stack.push(v[i]);72continue;73}7475if (v && typeof v === 'object') {76for (const val of Object.values(v as Record<string, unknown>)) stack.push(val);77}78}7980return out;81}8283function collect_generated_image_urls(root: unknown, limit: number = 4): string[] {84return collect_strings(root, (s) => s.startsWith(GENERATED_IMAGE_URL_PREFIX), limit);85}8687function parse_response_part_body(part: unknown): unknown[] | null {88if (!Array.isArray(part)) return null;89const part_body = get_nested_value<string | null>(part, [2], null);90if (!part_body) return null;91try {92const part_json = JSON.parse(part_body) as unknown;93return Array.isArray(part_json) ? part_json : null;94} catch {95return null;96}97}9899function find_generated_image_part(100response_json: unknown[],101body_index: number,102candidate_index: number,103limit: number = 4,104): { body: unknown[]; urls: string[] } | null {105for (let part_index = body_index; part_index < response_json.length; part_index++) {106const part_json = parse_response_part_body(response_json[part_index]);107if (!part_json) continue;108const cand = get_nested_value<unknown>(part_json, [4, candidate_index], null);109if (!cand) continue;110const urls = collect_generated_image_urls(cand, limit);111if (urls.length > 0) return { body: part_json, urls };112}113return null;114}115116export function collect_generated_image_urls_from_response_parts(117response_json: unknown[],118body_index: number,119candidate_index: number,120limit: number = 4,121): string[] {122return find_generated_image_part(response_json, body_index, candidate_index, limit)?.urls ?? [];123}124125function push_generated_images(126generated_images: GeneratedImage[],127urls: string[],128proxy: string | null,129cookies: Record<string, string>,130): void {131for (const url of urls) {132generated_images.push(new GeneratedImage(url, '[Generated Image]', '', proxy, cookies));133}134}135136export class GeminiClient extends GemMixin {137public cookies: Record<string, string> = {};138public proxy: string | null = null;139public _running: boolean = false;140public access_token: string | null = null;141public timeout: number = 300;142public auto_close: boolean = false;143public close_delay: number = 300;144public auto_refresh: boolean = true;145public refresh_interval: number = 540;146public kwargs: RequestInit;147148private close_timer: ReturnType<typeof setTimeout> | null = null;149private refresh_abort: AbortController | null = null;150151constructor(152secure_1psid: string | null = null,153secure_1psidts: string | null = null,154proxy: string | null = null,155kwargs: RequestInit = {},156) {157super();158this.proxy = proxy;159this.kwargs = kwargs;160161if (secure_1psid) {162this.cookies['__Secure-1PSID'] = secure_1psid;163if (secure_1psidts) this.cookies['__Secure-1PSIDTS'] = secure_1psidts;164}165}166167async init(168timeoutOrOpts: number | InitOptions = 300,169auto_close: boolean = false,170close_delay: number = 300,171auto_refresh: boolean = true,172refresh_interval: number = 540,173verbose: boolean = true,174): Promise<void> {175const opts: InitOptions =176typeof timeoutOrOpts === 'object'177? timeoutOrOpts178: { timeout: timeoutOrOpts, auto_close, close_delay, auto_refresh, refresh_interval, verbose };179180const timeout = opts.timeout ?? 300;181const ac = opts.auto_close ?? false;182const cd = opts.close_delay ?? 300;183const ar = opts.auto_refresh ?? true;184const ri = opts.refresh_interval ?? 540;185const vb = opts.verbose ?? true;186187try {188const [token, valid] = await get_access_token(this.cookies, this.proxy, vb);189this.access_token = token;190this.cookies = valid;191this._running = true;192193this.timeout = timeout;194this.auto_close = ac;195this.close_delay = cd;196if (this.auto_close) await this.reset_close_task();197198this.auto_refresh = ar;199this.refresh_interval = ri;200201const sid = this.cookies['__Secure-1PSID'];202if (sid) {203const existing = rotate_tasks.get(sid);204if (existing && existing instanceof AbortController) existing.abort();205rotate_tasks.delete(sid);206}207208if (this.auto_refresh && sid) {209const ctl = new AbortController();210this.refresh_abort?.abort();211this.refresh_abort = ctl;212rotate_tasks.set(sid, ctl);213void this.start_auto_refresh(ctl.signal);214}215216await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'client').catch(() => {});217218if (vb) logger.success('Gemini client initialized successfully.');219} catch (e) {220await this.close();221throw e;222}223}224225async close(delay: number = 0): Promise<void> {226if (delay > 0) await sleep(delay * 1000);227this._running = false;228229if (this.close_timer) {230clearTimeout(this.close_timer);231this.close_timer = null;232}233234this.refresh_abort?.abort();235this.refresh_abort = null;236237const sid = this.cookies['__Secure-1PSID'];238const t = sid ? rotate_tasks.get(sid) : null;239if (t && t instanceof AbortController) t.abort();240if (sid) rotate_tasks.delete(sid);241}242243async reset_close_task(): Promise<void> {244if (this.close_timer) {245clearTimeout(this.close_timer);246this.close_timer = null;247}248249this.close_timer = setTimeout(() => {250void this.close(0);251}, this.close_delay * 1000);252this.close_timer.unref?.();253}254255async start_auto_refresh(signal: AbortSignal): Promise<void> {256while (!signal.aborted) {257let newTs: string | null = null;258try {259newTs = await rotate_1psidts(this.cookies, this.proxy);260} catch (e) {261if (e instanceof AuthError) {262logger.warning('AuthError: Failed to refresh cookies. Auto refresh task canceled.');263return;264}265logger.warning(`Unexpected error while refreshing cookies: ${e instanceof Error ? e.message : String(e)}`);266}267268if (newTs) {269this.cookies['__Secure-1PSIDTS'] = newTs;270await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'refresh').catch(() => {});271logger.debug('Cookies refreshed. New __Secure-1PSIDTS applied.');272}273274await sleep(this.refresh_interval * 1000, signal);275}276}277278protected async _run<T>(fn: () => Promise<T>, retry: number): Promise<T> {279try {280if (!this._running) {281await this.init({282timeout: this.timeout,283auto_close: this.auto_close,284close_delay: this.close_delay,285auto_refresh: this.auto_refresh,286refresh_interval: this.refresh_interval,287verbose: false,288});289290if (!this._running) {291throw new APIError('Client initialization failed.');292}293}294295return await fn();296} catch (e) {297let r = retry;298if (e instanceof ImageGenerationError) r = Math.min(1, r);299if (e instanceof APIError && r > 0) {300await sleep(1000);301return await this._run(fn, r - 1);302}303throw e;304}305}306307async generate_content(308prompt: string,309files: string[] | null = null,310model: Model | string | Record<string, unknown> = Model.UNSPECIFIED,311gem: Gem | string | null = null,312chat: ChatSession | null = null,313kwargs: RequestKwargs = {},314): Promise<ModelOutput> {315return await this._run(async () => {316if (!prompt) throw new Error('Prompt cannot be empty.');317318let mdl: Model;319if (typeof model === 'string') mdl = Model.from_name(model);320else if (model instanceof Model) mdl = model;321else if (model && typeof model === 'object') mdl = Model.from_dict(model);322else throw new TypeError(`'model' must be a Model instance, string, or dictionary; got ${typeof model}`);323324const gem_id = gem instanceof Gem ? gem.id : gem;325326if (this.auto_close) await this.reset_close_task();327328if (!this.access_token) throw new APIError('Missing access token.');329330const f = files?.length ? files : null;331const uploaded =332f &&333(await Promise.all(334f.map(async (p) => [[await upload_file(p, this.proxy)], parse_file_name(p)] as [string[], string]),335));336337const first = uploaded ? [prompt, 0, null, uploaded] : [prompt];338const inner: unknown[] = [first, null, chat ? chat.metadata : null];339340if (gem_id) {341for (let i = 0; i < 16; i++) inner.push(null);342inner.push(gem_id);343}344345const f_req = JSON.stringify([null, JSON.stringify(inner)]);346const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();347348const h0 = { ...Headers.GEMINI, ...mdl.model_header, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };349const h1 = { ...h0, ...normalize_headers(kwargs.headers) };350351let res: Response;352try {353const timeout_ms = typeof kwargs.timeout_ms === 'number' ? kwargs.timeout_ms : this.timeout * 1000;354const { timeout_ms: _t, ...rest } = kwargs;355res = await fetch_with_timeout(Endpoint.GENERATE, {356method: 'POST',357headers: h1,358body,359redirect: 'follow',360...this.kwargs,361...rest,362timeout_ms,363});364} catch (e) {365throw new TimeoutError(366`Generate content request timed out, please try again. If the problem persists, consider setting a higher 'timeout' value when initializing GeminiClient. (${e instanceof Error ? e.message : String(e)})`,367);368}369370if (res.status !== 200) {371await this.close();372throw new APIError(`Failed to generate contents. Request failed with status code ${res.status}`);373}374375const txt = await res.text();376const response_json = extract_json_from_response(txt);377378let body_json: unknown[] | null = null;379let body_index = 0;380381try {382if (!Array.isArray(response_json)) throw new Error('Invalid JSON');383for (let part_index = 0; part_index < response_json.length; part_index++) {384const part = response_json[part_index];385if (!Array.isArray(part)) continue;386const part_body = get_nested_value<string | null>(part, [2], null);387if (!part_body) continue;388try {389const part_json = JSON.parse(part_body) as unknown[];390if (get_nested_value(part_json, [4], null)) {391body_index = part_index;392body_json = part_json;393break;394}395} catch {}396}397if (!body_json) throw new Error('No body');398} catch {399await this.close();400try {401const code = get_nested_value<number>(response_json, [0, 5, 2, 0, 1, 0], -1);402if (code === ErrorCode.USAGE_LIMIT_EXCEEDED) {403throw new UsageLimitExceeded(404`Failed to generate contents. Usage limit of ${mdl.model_name} model has exceeded. Please try switching to another model.`,405);406}407if (code === ErrorCode.MODEL_INCONSISTENT) {408throw new ModelInvalid(409'Failed to generate contents. The specified model is inconsistent with the chat history. Please make sure to pass the same `model` parameter when starting a chat session with previous metadata.',410);411}412if (code === ErrorCode.MODEL_HEADER_INVALID) {413throw new ModelInvalid(414'Failed to generate contents. The specified model is not available. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',415);416}417if (code === ErrorCode.IP_TEMPORARILY_BLOCKED) {418throw new TemporarilyBlocked(419'Failed to generate contents. Your IP address is temporarily blocked by Google. Please try using a proxy or waiting for a while.',420);421}422} catch (e) {423if (e instanceof GeminiError) throw e;424}425426logger.debug(`Invalid response: ${txt.slice(0, 500)}`);427throw new APIError('Failed to generate contents. Invalid response data received. Client will try to re-initialize on next request.');428}429430try {431const candidate_list = get_nested_value<unknown[]>(body_json, [4], []);432const out: Candidate[] = [];433434for (let candidate_index = 0; candidate_index < candidate_list.length; candidate_index++) {435const candidate = candidate_list[candidate_index];436if (!Array.isArray(candidate)) continue;437438const rcid = get_nested_value<string | null>(candidate, [0], null);439if (!rcid) continue;440441let text = String(get_nested_value(candidate, [1, 0], ''));442if (/^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(text)) {443text = String(get_nested_value(candidate, [22, 0], text));444}445446const thoughts = get_nested_value<string | null>(candidate, [37, 0, 0], null);447448const web_images: WebImage[] = [];449for (const w of get_nested_value<unknown[]>(candidate, [12, 1], [])) {450if (!Array.isArray(w)) continue;451const url = get_nested_value<string | null>(w, [0, 0, 0], null);452if (!url) continue;453web_images.push(new WebImage(url, String(get_nested_value(w, [7, 0], '')), String(get_nested_value(w, [0, 4], '')), this.proxy));454}455456const generated_images: GeneratedImage[] = [];457const wants_generated =458get_nested_value(candidate, [12, 7, 0], null) != null ||459/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text);460461if (wants_generated) {462const image_part = find_generated_image_part(response_json as unknown[], body_index, candidate_index, 1);463const img_body = image_part?.body ?? null;464465if (!img_body) {466throw new ImageGenerationError(467'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',468);469}470471const img_candidate = get_nested_value<unknown[]>(img_body, [4, candidate_index], []);472const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);473if (finished) {474text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();475}476477const gen = get_nested_value<unknown[]>(img_candidate, [12, 7, 0], []);478for (let img_index = 0; img_index < gen.length; img_index++) {479const g = gen[img_index];480if (!Array.isArray(g)) continue;481const url = get_nested_value<string | null>(g, [0, 3, 3], null);482if (!url) continue;483const img_num = get_nested_value<number | null>(g, [3, 6], null);484const title = img_num ? `[Generated Image ${img_num}]` : '[Generated Image]';485const alt_list = get_nested_value<unknown[]>(g, [3, 5], []);486const alt =487(typeof alt_list[img_index] === 'string' ? (alt_list[img_index] as string) : null) ??488(typeof alt_list[0] === 'string' ? (alt_list[0] as string) : '') ??489'';490generated_images.push(new GeneratedImage(url, title, alt, this.proxy, this.cookies));491}492493if (generated_images.length === 0) {494push_generated_images(generated_images, collect_generated_image_urls(img_candidate), this.proxy, this.cookies);495}496}497498// Fallback: unconditionally scan all response parts for generated image URLs.499// The `wants_generated` detection above relies on `candidate[12][7][0]` and an old500// `googleusercontent.com/image_generation_content/` URL pattern, both of which no501// longer appear in the current Gemini Web API response format.502// When Gemini does generate images, their URLs now start with503// `https://lh3.googleusercontent.com/gg-dl/` and are present somewhere in the504// response parts — this fallback finds them so images are not silently dropped.505if (generated_images.length === 0) {506const urls = collect_generated_image_urls_from_response_parts(response_json as unknown[], body_index, candidate_index);507push_generated_images(generated_images, urls, this.proxy, this.cookies);508}509510out.push(new Candidate({ rcid, text, thoughts, web_images, generated_images }));511}512513if (out.length === 0) {514throw new GeminiError('Failed to generate contents. No output data found in response.');515}516517const metadata = get_nested_value<string[]>(body_json, [1], []);518const output = new ModelOutput({ metadata, candidates: out });519520if (chat instanceof ChatSession) chat.last_output = output;521return output;522} catch (e) {523if (e instanceof GeminiError || e instanceof APIError) throw e;524throw new APIError('Failed to parse response body. Data structure is invalid.');525}526}, 2);527}528529async generateContent(530prompt: string,531files?: string[] | null,532model?: Model | string | Record<string, unknown>,533gem?: Gem | string | null,534chat?: ChatSession | null,535kwargs?: RequestKwargs,536): Promise<ModelOutput> {537return await this.generate_content(prompt, files ?? null, model ?? Model.UNSPECIFIED, gem ?? null, chat ?? null, kwargs ?? {});538}539540start_chat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {541return new ChatSession(this, opts);542}543544startChat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {545return this.start_chat(opts);546}547548protected async _batch_execute(payloads: RPCData[], opts: RequestInit = {}): Promise<Response> {549if (!this.access_token) throw new APIError('Missing access token.');550551const f_req = JSON.stringify([payloads.map((p) => p.serialize())]);552const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();553554const h0 = { ...Headers.GEMINI, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };555const h1 = { ...h0, ...normalize_headers(opts.headers) };556557const res = await fetch_with_timeout(Endpoint.BATCH_EXEC, {558method: 'POST',559headers: h1,560body,561redirect: 'follow',562...this.kwargs,563...opts,564timeout_ms: this.timeout * 1000,565});566567if (res.status !== 200) {568await this.close();569throw new APIError(`Batch execution failed with status code ${res.status}`);570}571572return res;573}574}575576export class ChatSession {577private __metadata: Array<string | null> = [null, null, null];578public geminiclient: GeminiClient;579private _last_output: ModelOutput | null = null;580public model: Model | string | Record<string, unknown>;581public gem: Gem | string | null;582583constructor(584geminiclient: GeminiClient,585opts: {586metadata?: Array<string | null>;587cid?: string | null;588rid?: string | null;589rcid?: string | null;590model?: Model | string | Record<string, unknown>;591gem?: Gem | string | null;592} = {},593) {594this.geminiclient = geminiclient;595this.model = opts.model ?? Model.UNSPECIFIED;596this.gem = opts.gem ?? null;597598if (opts.metadata) this.metadata = opts.metadata;599if (opts.cid) this.cid = opts.cid;600if (opts.rid) this.rid = opts.rid;601if (opts.rcid) this.rcid = opts.rcid;602}603604toString(): string {605return `ChatSession(cid='${this.cid}', rid='${this.rid}', rcid='${this.rcid}')`;606}607608get last_output(): ModelOutput | null {609return this._last_output;610}611612set last_output(v: ModelOutput | null) {613this._last_output = v;614if (v) {615this.metadata = (v.metadata ?? []) as Array<string | null>;616this.rcid = v.rcid;617}618}619620async send_message(prompt: string, files: string[] | null = null, kwargs: RequestKwargs = {}): Promise<ModelOutput> {621return await this.geminiclient.generate_content(prompt, files, this.model, this.gem, this, kwargs);622}623624async sendMessage(prompt: string, files?: string[] | null, kwargs?: RequestKwargs): Promise<ModelOutput> {625return await this.send_message(prompt, files ?? null, kwargs ?? {});626}627628choose_candidate(index: number): ModelOutput {629if (!this.last_output) throw new Error('No previous output data found in this chat session.');630if (index >= this.last_output.candidates.length) {631throw new Error(`Index ${index} exceeds the number of candidates in last model output.`);632}633this.last_output.chosen = index;634this.rcid = this.last_output.rcid;635return this.last_output;636}637638chooseCandidate(index: number): ModelOutput {639return this.choose_candidate(index);640}641642get metadata(): Array<string | null> {643return this.__metadata;644}645646set metadata(v: Array<string | null>) {647if (v.length > 3) throw new Error('metadata cannot exceed 3 elements');648this.__metadata = [null, null, null];649for (let i = 0; i < v.length; i++) this.__metadata[i] = v[i] ?? null;650}651652get cid(): string | null {653return this.__metadata[0];654}655656set cid(v: string | null) {657this.__metadata[0] = v;658}659660get rid(): string | null {661return this.__metadata[1];662}663664set rid(v: string | null) {665this.__metadata[1] = v;666}667668get rcid(): string | null {669return this.__metadata[2];670}671672set rcid(v: string | null) {673this.__metadata[2] = v;674}675}676