diff --git a/gemini-cookie-sync-extension/background.js b/gemini-cookie-sync-extension/background.js new file mode 100644 index 0000000..8f01529 --- /dev/null +++ b/gemini-cookie-sync-extension/background.js @@ -0,0 +1,171 @@ +/** + * Gemini Cookie Sync — background service worker (v2) + * + * 自动同步:监听 Google 域 cookie 变化 → 防抖收集 → 推送 native host 写 gemini-auth.json。 + * + * 触发逻辑: + * - cookies.onChanged 任何 Google 域 cookie 增删改 → 重置防抖计时器(2.5s) + * - 防抖到点 → 收集当前全部 Google cookie + 从打开的 Gemini 标签页抓 xsrf/gemini_bl + * - 校验关键字段 → connectNative 推送给 native host + * - 也监听周期定时器(每 30 分钟强制刷新一次,防漏) + */ + +const HOST_NAME = 'gemini_cookie_sync_host' +const GOOGLE_DOMAINS = ['google.com', 'gemini.google.com', 'accounts.google.com'] +const TS_COOKIES = ['__Secure-1PSIDTS', '__Secure-3PSIDTS', 'SIDCC', '__Secure-1PSIDCC'] +const FORCE_INTERVAL_MS = 30 * 60 * 1000 // 30 分钟强制推送一次 + +// ── 状态 ───────────────────────────────────────────── +let debounceTimer = null +let lastPushedHash = '' // 避免重复推送相同内容 +let port = null // native host 连接(复用) + +// ── cookie 收集(与 popup.js 逻辑一致)──────────────── +const EXPORT_ORDER = [ + 'SID', 'HSID', 'SSID', 'APISID', 'SAPISID', + 'LSID', 'OSID', 'SIDCC', 'AEC', 'NID', 'COMPASS', + '__Secure-1PAPISID', '__Secure-1PSID', '__Secure-1PSIDTS', '__Secure-1PSIDCC', '__Secure-1PSIDRTS', + '__Secure-3PAPISID', '__Secure-3PSID', '__Secure-3PSIDTS', '__Secure-3PSIDCC', '__Secure-3PSIDRTS', + '__Secure-OSID', '__Host-1PLSID', '__Host-3PLSID', +] + +function normalizeDomain(d = '') { return d.replace(/^\./, '').toLowerCase() } +function isGoogleCookie(c) { + const d = normalizeDomain(c.domain) + return d === 'google.com' || d.endsWith('.google.com') +} +function scoreCookie(c) { + const d = (c.domain || '').toLowerCase() + let s = 0 + if (d === '.google.com') s += 120 + else if (d === 'google.com') s += 110 + else if (d === '.gemini.google.com') s += 100 + else if (d === 'gemini.google.com') s += 95 + else if (d === '.accounts.google.com') s += 80 + else if (d.endsWith('.google.com')) s += 40 + if (c.path === '/') s += 10 + if (c.secure) s += 3 + if (c.httpOnly) s += 2 + return s +} + +async function collectCookies() { + const all = await chrome.cookies.getAll({}) + const google = all.filter((c) => isGoogleCookie(c) && c.value) + const selected = new Map() + for (const name of EXPORT_ORDER) { + const candidates = google.filter((c) => c.name === name).sort((a, b) => scoreCookie(b) - scoreCookie(a)) + if (candidates.length) selected.set(name, candidates[0]) + } + const cookieStr = EXPORT_ORDER.filter((n) => selected.has(n)).map((n) => `${n}=${selected.get(n).value}`).join('; ') + const sapisid = selected.get('SAPISID')?.value || '' + return { cookieStr, sapisid, hasTs: TS_COOKIES.some((n) => selected.has(n)) } +} + +// ── 从打开的 Gemini 标签页抓 xsrf / gemini_bl ──────── +async function collectPageMeta() { + try { + const tabs = await chrome.tabs.query({ url: 'https://gemini.google.com/*' }) + if (!tabs.length) return { xsrf: null, bl: null } + const tab = tabs.find((t) => t.active) || tabs[0] + const results = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + world: 'MAIN', + func: () => { + const wiz = globalThis.WIZ_global_data || {} + const html = document.documentElement?.innerHTML || '' + const decode = (v) => { + if (!v) return null + try { return JSON.parse(`"${v.replace(/"/g, '\\"')}"`) } catch { return v } + } + const regexValue = (name) => { + const m = html.match(new RegExp(`"${name}"\\s*:\\s*"([^"\\n]+)"`)) + return m ? decode(m[1]) : null + } + let resourceBl = null + try { + for (const e of performance.getEntriesByType('resource')) { + if (!e?.name?.includes('gemini.google.com')) continue + const bl = new URL(e.name).searchParams.get('bl') + if (bl) { resourceBl = bl; break } + } + } catch {} + return { + xsrf: wiz.SNlM0e || regexValue('SNlM0e') || regexValue('FdrFJe') || null, + bl: wiz.cfb2h || resourceBl || regexValue('cfb2h') || null, + } + }, + }) + return results?.[0]?.result || { xsrf: null, bl: null } + } catch { return { xsrf: null, bl: null } } +} + +// ── 推送 native host ───────────────────────────────── +function ensurePort() { + if (port) return port + try { + port = chrome.runtime.connectNative(HOST_NAME) + port.onDisconnect.addListener(() => { port = null }) + return port + } catch { return null } +} + +async function pushToHost(force = false) { + try { + const { cookieStr, sapisid, hasTs } = await collectCookies() + if (!cookieStr || !sapisid) return + const { xsrf, bl } = await collectPageMeta() + + // 指纹:内容 + 时间,避免频繁重复推送 + const payload = { + cookie: cookieStr, + sapisid, + auth_user: null, + xsrf_token: xsrf || '', + gemini_bl: bl || '', + } + const fingerprint = `${cookieStr.length}|${sapisid.slice(-8)}|${hasTs ? 'TS' : 'noTS'}|${xsrf ? 'X' : 'nx'}|${bl ? 'B' : 'nb'}` + if (!force && fingerprint === lastPushedHash) return + lastPushedHash = fingerprint + + const p = ensurePort() + if (!p) { + console.warn('[gemini-cookie-sync] native host 未连接(未安装?),跳过自动推送') + return + } + p.postMessage({ type: 'push', payload }) + console.log('[gemini-cookie-sync] pushed to host, cookieLen=', cookieStr.length, 'hasTs=', hasTs) + } catch (e) { + console.error('[gemini-cookie-sync] push error:', e) + } +} + +// ── 事件监听 ───────────────────────────────────────── +chrome.cookies.onChanged.addListener((changeInfo) => { + const c = changeInfo.cookie + if (!isGoogleCookie(c)) return + // 防抖:连续 cookie 轮换时合并为一次推送 + clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { void pushToHost() }, 2500) +}) + +// 周期强制刷新(防漏 + 兜底) +chrome.alarms?.create?.('gemini-cookie-force', { periodInMinutes: 30 }) +chrome.alarms?.onAlarm?.addListener((alarm) => { + if (alarm.name === 'gemini-cookie-force') void pushToHost(true) +}) + +// 启动时推一次 +void pushToHost(true) + +// 供 popup 查询最近推送状态 +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === 'get-sync-status') { + sendResponse({ lastPushFingerprint: lastPushedHash, hostConnected: !!port }) + return true + } + if (msg?.type === 'force-sync') { + void pushToHost(true).then(() => sendResponse({ ok: true })) + return true + } +}) diff --git a/gemini-cookie-sync-extension/manifest.json b/gemini-cookie-sync-extension/manifest.json index e5634ee..573c87c 100644 --- a/gemini-cookie-sync-extension/manifest.json +++ b/gemini-cookie-sync-extension/manifest.json @@ -1,18 +1,24 @@ { "manifest_version": 3, "name": "Gemini Cookie Sync", - "version": "1.0", - "description": "Exports Gemini cookies plus XSRF/build metadata for gemini-web2api.", + "version": "2.0", + "description": "Exports Gemini cookies plus XSRF/build metadata for gemini-web2api. Auto-syncs to native host when session cookies rotate.", "permissions": [ "cookies", "downloads", "tabs", - "scripting" + "scripting", + "nativeMessaging", + "alarms" ], "host_permissions": [ "https://google.com/*", - "https://*.google.com/*" + "https://*.google.com/*", + "https://gemini.google.com/*" ], + "background": { + "service_worker": "background.js" + }, "action": { "default_title": "Gemini Cookie Sync", "default_popup": "popup.html" diff --git a/gemini-cookie-sync-extension/popup.html b/gemini-cookie-sync-extension/popup.html index 0adcd7e..ea696b4 100644 --- a/gemini-cookie-sync-extension/popup.html +++ b/gemini-cookie-sync-extension/popup.html @@ -50,6 +50,8 @@

Gemini Cookie Sync

+ +

自动同步: 检测中…

Ready.

Open Gemini, sign in, then refresh the page before inspection. The generated file contains highly sensitive session data; do not share it or commit it to Git.

diff --git a/gemini-cookie-sync-extension/popup.js b/gemini-cookie-sync-extension/popup.js index acc2db6..f152e6d 100644 --- a/gemini-cookie-sync-extension/popup.js +++ b/gemini-cookie-sync-extension/popup.js @@ -406,3 +406,45 @@ exportButton.addEventListener("click", async () => { exportButton.disabled = false; } }); + +// ============ v2: 自动同步(native host)============ +const forceSyncButton = document.getElementById("force-sync"); +const syncStatusEl = document.getElementById("sync-status"); + +function setSyncStatus(message, kind = "") { + if (!syncStatusEl) return; + syncStatusEl.textContent = "自动同步: " + message; + syncStatusEl.className = kind; +} + +async function refreshSyncStatus() { + try { + const resp = await chrome.runtime.sendMessage({ type: "get-sync-status" }); + if (resp) { + const connected = resp.hostConnected; + setSyncStatus( + (connected ? "✅ 已连接 native host" : "⚠️ native host 未连接(先运行 install-host.ps1)"), + connected ? "ok" : "warn" + ); + } + } catch (e) { + setSyncStatus("查询失败", "warn"); + } +} + +forceSyncButton?.addEventListener("click", async () => { + forceSyncButton.disabled = true; + setSyncStatus("同步中…"); + try { + const resp = await chrome.runtime.sendMessage({ type: "force-sync" }); + if (resp?.ok) setSyncStatus("✅ 已推送到本地 gemini-auth.json", "ok"); + else setSyncStatus("❌ 推送失败", "warn"); + } catch (e) { + setSyncStatus("❌ " + String(e), "warn"); + } finally { + forceSyncButton.disabled = false; + } +}); + +void refreshSyncStatus(); +setInterval(refreshSyncStatus, 3000); diff --git a/gemini_web2api.py b/gemini_web2api.py index 1f73f6e..e6450ea 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -61,9 +61,95 @@ "cookie_file": None, "proxy": None, "api_keys": [], - "temporary_chats": False, + "temporary_chats": True, + "persistent_chat": False, } +# ─── Persistent chat window (for cache reuse) ──────────────────────────────── +# When persistent_chat is enabled, the proxy reuses the SAME Gemini Web chat +# across requests within a DSH session window, so Google can serve a prefix KV +# cache (faster, less quota). The window is detected heuristically from the +# OpenAI messages array: an increasing message count = same chat continuing; +# a shrink (new DSH window / history reset) = start a fresh chat. +class ChatWindow: + """Tracks the active Gemini Web conversation so persistent_chat can reuse + it across requests (prefix KV cache reuse). Conversation continuation on + the web protocol works via inner[2] = [conv_id, resp_id, ...]: + - conv_id (c_xxx) identifies the conversation; stays stable. + - resp_id (r_xxx) is the last response id; must be updated each turn. + New window is detected heuristically: no history, message count shrink + (new DSH window / history reset), or idle timeout.""" + + def __init__(self): + self.conv_id = None # c_xxx (stable) + self.resp_id = None # r_xxx (updates every turn) + self.sent_msg_count = 0 # msgs already sent to Google in this window + self.last_msg_count = None + self.last_ts = None + self.is_new_window = False # set by resolve() for the current request + self._pending_send_count = 0 # msg count to advance on successful response + + def resolve(self, msg_count: int, idle_timeout_s: int = 1800) -> None: + """Decide whether this request continues the current chat. If it is a + new window, reset conv/resp ids so the next request starts fresh.""" + import time as _t + now = _t.time() + is_new = ( + self.conv_id is None + or self.last_msg_count is None + or msg_count < self.last_msg_count + or (self.last_ts is not None and now - self.last_ts > idle_timeout_s) + ) + self.is_new_window = is_new + if is_new: + self.conv_id = None + self.resp_id = None + self.sent_msg_count = 0 + log(f"ChatWindow: new window (msgs={msg_count})") + else: + log(f"ChatWindow: continue window (msgs={msg_count} conv={self.conv_id[:8] if self.conv_id else None})") + self.last_msg_count = msg_count + self.last_ts = now + + def update_from_response(self, conv_id, resp_id) -> None: + """Store conversation ids observed in a response (called after a turn).""" + if conv_id: + self.conv_id = conv_id + if resp_id: + self.resp_id = resp_id + # A real c_ id confirms this request succeeded upstream, so the sent + # counter can advance (next request sends only the delta). + if conv_id and self._pending_send_count: + self.advance_sent(self._pending_send_count) + self._pending_send_count = 0 + + def advance_sent(self, msg_count: int) -> None: + """Mark msg_count messages as successfully sent to the current chat. + Called only after a successful upstream response, so a failed turn that + DSH retries re-sends the same tail instead of computing an empty delta.""" + if msg_count > self.sent_msg_count: + self.sent_msg_count = msg_count + + def mark_request_sent(self, msg_count: int) -> None: + """Record the current request's total msg count as 'to be advanced' + once the upstream responds successfully. Falls back safely if the + window is not active.""" + self._pending_send_count = msg_count if self.active else 0 + + def confirm_sent(self) -> None: + """Called after a successful upstream response: advance the sent + counter so the next request only sends the delta.""" + if getattr(self, "_pending_send_count", 0): + self.advance_sent(self._pending_send_count) + self._pending_send_count = 0 + + @property + def active(self) -> bool: + return self.conv_id is not None + + +CHAT_WINDOW = ChatWindow() + CONFIG = dict(DEFAULT_CONFIG) # ─── Models ────────────────────────────────────────────────────────────────── @@ -71,9 +157,13 @@ # 1=FAST, 2=THINKING, 3=PRO, 4=AUTO, 5=FAST_DYNAMIC_THINKING, 6=FLASH_LITE MODELS = { + "gemini-3.8-flash": { + "mode": 1, "think": 0, + "desc": "Latest all-around model (Gemini 3.8 Flash)", + }, "gemini-3.7-flash": { "mode": 1, "think": 4, - "desc": "Latest all-around model (Gemini 3.7 Flash)", + "desc": "All-around model (Gemini 3.7 Flash)", }, "gemini-3.6-flash": { "mode": 1, "think": 4, @@ -105,16 +195,72 @@ }, } +# ─── Model selection header (x-goog-ext-525001261-jspb) ───────────────────── +# Verified internal model IDs (from browser captures, Issue #82). +# When this header is absent, upstream ignores slot79 and serves the account +# default model, so model selection silently no-ops. See: +# https://github.com/Sophomoresty/gemini-web2api/issues/82 +MODEL_IDS = { + "gemini-3.8-flash": "56fdd199312815e2", # not yet verified separately; 3.7 ID is stable + "gemini-3.7-flash": "56fdd199312815e2", # cat 1 (verified) + "gemini-3.6-flash": "56fdd199312815e2", # alias to 3.7 id for now + "gemini-3.5-flash": "56fdd199312815e2", + "gemini-3.1-pro": "e6fa609c3fa255c0", # cat 3 (verified) + "gemini-flash-lite": "8c46e95b1a07cecc", # cat 6 (verified) + "gemini-3.5-flash-thinking": "56fdd199312815e2", + "gemini-3.5-flash-thinking-lite": "56fdd199312815e2", + "gemini-auto": None, # no header = account default +} + +def build_model_header(model_name: str, model_id: int) -> Optional[str]: + """Build the x-goog-ext-525001261-jspb model-selection header. + + Contract (Issue #82): [1,null,null,null,"",null,null,0, + [4,5,6,8,4,5,6,8],null,null,2,null,null,,,""] + idx4 = model selector; idx14 must equal payload slot79; idx15 = slot80. + Returns None for models without a known internal ID (-> account default). + """ + mid = MODEL_IDS.get(model_name) + if not mid: + return None + try: + return json.dumps( + [1, None, None, None, mid, None, None, 0, + [4, 5, 6, 8, 4, 5, 6, 8], None, None, 2, + None, None, model_id, 0, str(uuid.uuid4())], + separators=(",", ":")) + except Exception: + return None + + # ─── Utilities ─────────────────────────────────────────────────────────────── def log(msg: str): - if CONFIG["log_requests"]: - sys.stderr.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n") - sys.stderr.flush() + """Log to stderr AND append to server.log (real-time).""" + line = f"[{time.strftime('%H:%M:%S')}] {msg}\n" + sys.stderr.write(line) + sys.stderr.flush() + try: + log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "server.log") + with open(log_path, "a", encoding="utf-8") as f: + f.write(line) + except Exception: + pass + + +_AUTH_FIELDS_LOADED = False def load_cookie() -> tuple: - """Load cookie from file. Returns (cookie_str, sapisid).""" + """Load cookie from file. Returns (cookie_str, sapisid). + + Also supports the gemini-auth.json format exported by the bundled + browser extension: {cookie, sapisid, auth_user, xsrf_token, gemini_bl}. + Those auth fields are injected into CONFIG on first load, so the user + only needs to point cookie_file at the exported json (no manual config + edits for xsrf/bl/auth_user). + """ + global _AUTH_FIELDS_LOADED cookie_file = CONFIG.get("cookie_file") if not cookie_file: return "", None @@ -127,6 +273,18 @@ def load_cookie() -> tuple: data = json.loads(content) cookie_str = data.get("cookie", "") sapisid = data.get("sapisid", "") + # Inject auth metadata from the exported json (one-time). + if not _AUTH_FIELDS_LOADED: + if data.get("xsrf_token"): + CONFIG["xsrf_token"] = data["xsrf_token"] + log(f"xsrf loaded from auth file (len {len(data['xsrf_token'])})") + if data.get("gemini_bl"): + CONFIG["gemini_bl"] = data["gemini_bl"] + log("gemini_bl loaded from auth file") + if data.get("auth_user") is not None: + CONFIG["auth_user"] = data["auth_user"] + log(f"auth_user loaded from auth file: {data['auth_user']}") + _AUTH_FIELDS_LOADED = True else: cookie_str = content pairs = dict(p.split("=", 1) for p in cookie_str.split("; ") if "=" in p) @@ -153,13 +311,55 @@ def account_prefix() -> str: def apply_chat_persistence_flags(inner: list) -> None: """Apply Gemini Web persistence flags to an outgoing request payload.""" - if CONFIG.get("temporary_chats", False): + if CONFIG.get("persistent_chat", False): + # Persistent chat: keep the same conversation across requests so Google + # can reuse prefix KV cache. This does leave traces in the web UI. + inner[41] = [2] + elif CONFIG.get("temporary_chats", False): inner[41] = [1] inner[45] = 1 else: inner[41] = [2] +def apply_chat_window(inner: list) -> None: + """Inject the active persistent conversation ids into inner[2]. + + Protocol (verified): inner[2][0] = conversation id (c_xxx, stable), + inner[2][1] = last response id (r_xxx, updates each turn). Leaving both + empty starts a brand-new conversation (stateless / no cache reuse). + Also keeps inner[59] as a fresh per-request uuid. + """ + if CONFIG.get("persistent_chat", False) and CHAT_WINDOW.active: + inner[2] = [CHAT_WINDOW.conv_id, CHAT_WINDOW.resp_id, + "", None, None, None, None, None, None, ""] + inner[59] = str(uuid.uuid4()) + + +def _capture_session_ids(raw: str) -> None: + """Scan a raw StreamGenerate response and store conv_id (c_xxx) and the + latest response id (r_xxx) into CHAT_WINDOW for conversation continuation.""" + if not CONFIG.get("persistent_chat", False): + return + try: + for line in raw.split("\n"): + if '"wrb.fr"' not in line: + continue + arr = json.loads(line) + inner_str = arr[0][2] + if not inner_str: + continue + inner2 = json.loads(inner_str) + if isinstance(inner2, list) and len(inner2) > 1 and isinstance(inner2[1], list): + c = inner2[1][0] if len(inner2[1]) > 0 else None + r = inner2[1][1] if len(inner2[1]) > 1 else None + if isinstance(c, str) and c.startswith("c_"): + CHAT_WINDOW.update_from_response(c, r if isinstance(r, str) and r.startswith("r_") else None) + break + except (json.JSONDecodeError, IndexError, TypeError): + pass + + def fetch_latest_bl() -> Optional[str]: """Fetch the latest gemini_bl from gemini.google.com page.""" try: @@ -194,6 +394,39 @@ def update_bl_if_needed() -> bool: return False +def fetch_xsrf_token() -> Optional[str]: + """Fetch the current xsrf token (FdrFJe) from the signed-in Gemini page. + + The token moves over time (SNlM0e -> FdrFJe); we probe both. Needed for + authenticated StreamGenerate calls; without it requests can be downgraded + or rejected. Returns the raw token string or None on failure. + """ + try: + req = urllib.request.Request( + "https://gemini.google.com/app", + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Cookie": load_cookie()[0], + }) + ctx = ssl.create_default_context() + proxy = CONFIG.get("proxy") + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": proxy, "https": proxy}), + urllib.request.HTTPSHandler(context=ctx)) + resp = opener.open(req, timeout=20) + html = resp.read().decode("utf-8", errors="replace") + m = re.search(r'"FdrFJe"\s*:\s*"(-?\d+)"', html) + if m: + return m.group(1) + m2 = re.search(r'SNlM0e[\'":=\s]*([A-Za-z0-9_\-]{10,})', html) + if m2: + return m2.group(1) + return None + except Exception as e: + log(f"xsrf fetch failed: {e}") + return None + + def upload_images(images: list) -> list: """Upload parsed OpenAI image parts and return Gemini file references.""" if not images: @@ -220,7 +453,8 @@ def upload_images(images: list) -> list: # ─── Gemini Protocol ───────────────────────────────────────────────────────── -def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None) -> str: +def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None, + model_name: str = None) -> str: """Send prompt to Gemini StreamGenerate with retry.""" inner = [None] * 80 if file_refs: @@ -239,8 +473,8 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref inner[27] = 1 inner[30] = [4] apply_chat_persistence_flags(inner) + apply_chat_window(inner) inner[53] = 0 - inner[59] = str(uuid.uuid4()) inner[61] = [] inner[68] = 1 inner[79] = model_id @@ -272,6 +506,9 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref headers["Cookie"] = cookie_str if sapisid: headers["Authorization"] = make_sapisidhash(sapisid) + model_hdr = build_model_header(model_name, model_id) + if model_hdr: + headers["x-goog-ext-525001261-jspb"] = model_hdr last_err = None for attempt in range(CONFIG["retry_attempts"]): @@ -287,7 +524,12 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref resp = opener.open(req, timeout=CONFIG["request_timeout_sec"]) else: resp = urllib.request.urlopen(req, context=ctx, timeout=CONFIG["request_timeout_sec"]) - return resp.read().decode("utf-8", errors="replace") + raw = resp.read().decode("utf-8", errors="replace") + # Persistent chat: capture c_/r_ ids from the raw response so the + # next request continues this conversation (prefix KV cache reuse). + if CONFIG.get("persistent_chat", False): + _capture_session_ids(raw) + return raw except urllib.error.HTTPError as e: if e.code == 405 and update_bl_if_needed(): reqid = int(time.time()) % 1000000 @@ -311,7 +553,8 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref raise last_err -def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, file_refs: list = None): +def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, file_refs: list = None, + model_name: str = None): """Send prompt and yield incremental text deltas using httpx streaming.""" inner = [None] * 80 if file_refs: @@ -330,8 +573,8 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil inner[27] = 1 inner[30] = [4] apply_chat_persistence_flags(inner) + apply_chat_window(inner) inner[53] = 0 - inner[59] = str(uuid.uuid4()) inner[61] = [] inner[68] = 1 inner[79] = model_id @@ -362,12 +605,15 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil headers["Cookie"] = cookie_str if sapisid: headers["Authorization"] = make_sapisidhash(sapisid) + model_hdr = build_model_header(model_name, model_id) + if model_hdr: + headers["x-goog-ext-525001261-jspb"] = model_hdr proxy = CONFIG.get("proxy") if not HAS_HTTPX: # Fallback: non-streaming with urllib - raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs) + raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs, model_name) text = extract_response_text(raw) if text: yield text @@ -397,6 +643,18 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil if not inner_str or len(inner_str) < 50: continue inner2 = json.loads(inner_str) + # Persistent chat: capture conv_id (c_xxx) and + # response id (r_xxx) so the next request can + # continue this conversation (prefix KV cache). + if CONFIG.get("persistent_chat", False) and isinstance(inner2, list) and len(inner2) > 1 and isinstance(inner2[1], list): + try: + _c = inner2[1][0] if len(inner2[1]) > 0 else None + _r = inner2[1][1] if len(inner2[1]) > 1 else None + if (isinstance(_c, str) and _c.startswith("c_")) or (isinstance(_r, str) and _r.startswith("r_")): + CHAT_WINDOW.update_from_response(_c if isinstance(_c, str) and _c.startswith("c_") else None, + _r if isinstance(_r, str) and _r.startswith("r_") else None) + except Exception: + pass if isinstance(inner2, list) and len(inner2) > 4 and inner2[4]: for part in inner2[4]: if isinstance(part, list) and len(part) > 1 and part[1] and isinstance(part[1], list): @@ -516,6 +774,18 @@ def image_from_part(part: dict): return None +def _truncate_tool_result(content: str, max_len: int = 1200) -> str: + """Trim oversized tool results to keep the prompt lean (faster TTFT).""" + if not content: + return content + if len(content) <= max_len: + return content + head = content[:max_len] + # keep a tail snippet for context (e.g. last error line) + tail = content[-200:] + return f"{head}\n[... truncated by proxy: {len(content) - max_len} chars omitted ...]\n{tail}" + + def messages_to_prompt(messages: list, tools: list = None) -> tuple: """Convert OpenAI messages to (prompt_str, images_list).""" parts = [] @@ -530,13 +800,55 @@ def messages_to_prompt(messages: list, tools: list = None) -> tuple: "parameters": fn.get("parameters", tool.get("parameters", {})), }) if tool_defs: - tools_json = json.dumps(tool_defs, indent=2) - if len(tools_json) > PROMPT_MAX_BYTES // 2: - slim_defs = [{"name": t["name"], "description": t["description"]} for t in tool_defs] - tools_json = json.dumps(slim_defs, indent=2) - log(f"Tools block too large ({len(tool_defs)} tools), stripped parameters") + # ── Tool filter + compact for speed ────────────────────────── + # DSH sends 43 tools (~34KB / 8650 tok). The dev_*/job_*/goals + # series are rarely needed and bulk up the prompt; core 10 tools + # cover 90%+ daily coding. Keep full parameters, trim description + # to the first sentence (~150 chars) to preserve call accuracy. + CORE_TOOL_NAMES = { + 'read', 'edit', 'write', 'grep', 'glob', 'pwsh', + 'web_search', 'todo_write', 'todo_read', 'subagent', + } + filtered = [] + skipped = 0 + for t in tool_defs: + if t.get("name", "") in CORE_TOOL_NAMES: + filtered.append(t) + else: + skipped += 1 + if skipped: + log(f"Tool filter: {len(tool_defs)} → {len(filtered)} core tools ({skipped} dev/job/goals skipped)") + MAX_DESC = 150 + compact_defs = [] + for t in filtered: + d = t.get("description", "") or "" + # Keep the first sentence (usually the core meaning). + d = d.split('.')[0].split('\n')[0].strip() + if len(d) > MAX_DESC: + d = d[:MAX_DESC].rstrip() + "..." + compact_defs.append({ + "name": t.get("name", ""), + "description": d, + "parameters": t.get("parameters", {}), + }) + TOOLS_BUDGET = PROMPT_MAX_BYTES * 3 // 4 + tools_json = json.dumps(compact_defs, ensure_ascii=False, separators=(",", ":")) + try: + sizes = sorted(((len(json.dumps(t, ensure_ascii=False, separators=(",",":"))), t.get("name","")) for t in compact_defs), reverse=True) + top = ", ".join(f"{n}({s}B)" for s, n in sizes[:5]) + log(f"Tools: {len(compact_defs)} core, {len(tools_json)}B | {top}") + except Exception: + pass + if len(tools_json) > TOOLS_BUDGET: + slim_defs = [{"name": t["name"], "parameters": t["parameters"]} for t in compact_defs] + tools_json = json.dumps(slim_defs, ensure_ascii=False, separators=(",", ":")) + log(f"Tools block too large ({len(compact_defs)} tools), stripped descriptions only") parts.append( - "[System instruction]: You have access to tools. " + "[System instruction]: You are a coding agent with real tools available. " + "When the user asks you to CREATE files, WRITE code, EDIT files, " + "SEARCH the web, RUN commands, or MANAGE tasks — you MUST call the " + "appropriate tool. Do NOT simply describe what you would do in text; " + "actually invoke the tool. If no tool is needed, answer directly.\n\n" "To call a tool, respond with:\n" '```tool_call\n{"name": "func_name", "arguments": {...}}\n```\n' "Only use tool_call blocks when needed.\n\n" @@ -571,7 +883,7 @@ def messages_to_prompt(messages: list, tools: list = None) -> tuple: else: parts.append(f"[Assistant]: {content}") elif role == "tool": - parts.append(f"[Tool result for {msg.get('name', '')}]: {content}") + parts.append(f"[Tool result for {msg.get('name', '')}]: {_truncate_tool_result(content)}") else: parts.append(content if content else "") return "\n\n".join(p for p in parts if p), images @@ -639,6 +951,11 @@ def parse_tool_calls(text: str) -> tuple: # ─── HTTP Handler ──────────────────────────────────────────────────────────── class GeminiHandler(BaseHTTPRequestHandler): + # HTTP/1.0 + connection-close: SSE streams end when the connection closes + # (EOF). HTTP/1.1 without chunked encoding makes clients wait forever for + # a body terminator that BaseHTTPRequestHandler never sends. + protocol_version = "HTTP/1.0" + def log_message(self, fmt, *args): client_ip = self.client_address[0] if self.client_address else "-" log(f"{client_ip} {fmt % args}") @@ -652,6 +969,27 @@ def send_json(self, data, status=200): self.end_headers() self.wfile.write(body) + def _send_stream_headers(self): + """SSE headers with proxy-buffering disabled for smooth streaming.""" + self.send_response(200) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.send_header("Cache-Control", "no-cache, no-transform") + self.send_header("X-Accel-Buffering", "no") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + @staticmethod + def _usage_chunk(cid, model_name, prompt, full_text): + """Build OpenAI-style usage chunk so DSH usage plugin can count tokens.""" + p_tokens = max(1, len(prompt) // 4) + c_tokens = max(1, len(full_text) // 4) + return { + "id": cid, "object": "chat.completion.chunk", "created": int(time.time()), + "model": model_name, "choices": [], + "usage": {"prompt_tokens": p_tokens, "completion_tokens": c_tokens, + "total_tokens": p_tokens + c_tokens}, + } + def _authorized(self): keys = CONFIG.get("api_keys") or [] if not keys: @@ -762,8 +1100,8 @@ def _resolve_model(self, model_name): return None, None, None, f"Unknown model: {model_name}" return model_name, cfg["mode"], (think_override if think_override is not None else cfg["think"]), None - def _call_gemini(self, prompt, model_id, think_mode, tools, file_refs=None): - raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs) + def _call_gemini(self, prompt, model_id, think_mode, tools, file_refs=None, model_name=None): + raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs, model_name) text = extract_response_text(raw) tool_calls = None if tools and text: @@ -772,6 +1110,49 @@ def _call_gemini(self, prompt, model_id, think_mode, tools, file_refs=None): def handle_chat(self, body: bytes): req = json.loads(body) + # Debug: export the real DSH tools JSON once (for prompt-size analysis) + try: + _tools = req.get("tools") + if _tools and len(_tools) >= 40: + out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dsh-tools-real.json") + if not os.path.exists(out): + with open(out, "w", encoding="utf-8") as f: + json.dump(_tools, f, ensure_ascii=False, indent=1) + log(f"Exported {len(_tools)} DSH tools to dsh-tools-real.json") + except Exception: + pass + # Debug: log request structure that DSH sends (one-time diagnostic) + try: + msgs = req.get("messages", []) + roles = [m.get("role") for m in msgs] + last3 = [] + for m in msgs[-3:]: + c = m.get("content") + if c is None: + cstr = "" + elif isinstance(c, str): + cstr = c[:50] + elif isinstance(c, list): + cstr = f"" + else: + cstr = repr(c)[:50] + tcs = f" tc={len(m.get('tool_calls', []))}" if m.get("tool_calls") else "" + last3.append(f"{m.get('role')}:{cstr}{tcs}") + log(f"DSH-REQ: keys={list(req.keys())} stream={req.get('stream')} " + f"stream_options={req.get('stream_options')} tool_choice={req.get('tool_choice')} " + f"msgs={len(msgs)} roles={roles[:5]}... last3={last3}") + except Exception as e: + log(f"DSH-REQ debug err: {e}") + # Persistent chat: resolve the shared Gemini conversation from the + # message window (same chat across requests => Google prefix KV cache + # reuse). The conv/resp ids from the last response are injected into + # inner[2] by the request builders; when persistent_chat is off the + # window is not consulted and every request is stateless (fresh chat). + try: + if CONFIG.get("persistent_chat", False): + CHAT_WINDOW.resolve(len(req.get("messages", []))) + except Exception as e: + log(f"ChatWindow resolve err: {e}") model_name, model_id, think_mode, err = self._resolve_model( req.get("model", CONFIG["default_model"])) if err: @@ -779,12 +1160,78 @@ def handle_chat(self, body: bytes): return tools = req.get("tools") - prompt, images = messages_to_prompt(req.get("messages", []), tools) + all_msgs = req.get("messages", []) + # ── Pure-incremental persistent chat ───────────────────────────── + # persistent_chat=true + an ongoing window: only the messages added + # since the last successfully-sent request are forwarded to Gemini + # (Google's server keeps the conversation memory via conv_id). The + # first request of a window sends everything (system + tools + history) + # to seed the conversation. + persist_mode = CONFIG.get("persistent_chat", False) + if persist_mode and not CHAT_WINDOW.is_new_window and CHAT_WINDOW.active and CHAT_WINDOW.sent_msg_count > 0: + start = CHAT_WINDOW.sent_msg_count + if len(all_msgs) > start: + send_msgs = all_msgs[start:] + prompt, images = messages_to_prompt(send_msgs, None) # no tool re-injection + log(f"Persist-incremental: {len(send_msgs)} new msgs (of {len(all_msgs)}), sent_idx={start}") + else: + # Nothing genuinely new (retry of a failed turn): fall back to + # full send to be safe. + prompt, images = messages_to_prompt(all_msgs, tools) + log(f"Persist-incremental: no new msgs, fallback full ({len(all_msgs)})") + else: + prompt, images = messages_to_prompt(all_msgs, tools) + # PROMPT-STATS: structured breakdown of what goes into the prompt + # (which roles / how many / char counts / est. tokens). Content is NOT + # printed — only sizes — so it's cheap and stays readable. + try: + msgs = req.get("messages", []) + role_counts = {} + sys_chars = user_chars = asst_chars = tool_chars = 0 + for m in msgs: + r = m.get("role", "?") + role_counts[r] = role_counts.get(r, 0) + 1 + c = m.get("content") + n = len(c) if isinstance(c, str) else (sum(len(p.get("text", "")) for p in c if isinstance(p, dict) and isinstance(p.get("text"), str)) if isinstance(c, list) else 0) + if r == "system": sys_chars += n + elif r == "user": user_chars += n + elif r == "assistant": asst_chars += n + elif r == "tool": tool_chars += n + tools_json_chars = 0 + if tools: + try: + _compact = [{"name": t.get("name", ""), "description": (t.get("description", "") or "")[:150], "parameters": t.get("parameters", {})} for t in tools] + tools_json_chars = len(json.dumps(_compact, ensure_ascii=False, separators=(",", ":"))) + except Exception: + pass + prompt_chars = len(prompt) + est_tok = prompt_chars // 4 + log(f"PROMPT-STATS: msgs={len(msgs)} roles={role_counts} | " + f"sys={sys_chars}B user={user_chars}B asst={asst_chars}B tool={tool_chars}B " + f"tools_json={tools_json_chars}B | prompt_total={prompt_chars}B (~{est_tok} tok)") + except Exception as e: + log(f"PROMPT-STATS err: {e}") + # Global prompt budget: keep the head (system/tools + early context) and + # the tail (recent turns), collapse the middle to keep TTFT low. + # Set high enough that tool definitions (32KB for DSH's 43 tools) are + # never clipped; conversation history is trimmed separately in + # messages_to_prompt via _truncate_tool_result. + MAX_PROMPT = 60000 # ~15k tokens + if len(prompt) > MAX_PROMPT: + head = prompt[:MAX_PROMPT * 3 // 4] + tail = prompt[-MAX_PROMPT // 4:] + prompt = f"{head}\n[... proxy: middle of prompt collapsed ...]\n{tail}" + log(f"Prompt collapsed: {len(prompt)}B") if not prompt.strip(): self.send_json({"error": {"message": "empty prompt"}}, 400) return + # Record the total msg count so the persistent-window counter advances + # only after a successful upstream response (confirm_sent via update_from_response). + if persist_mode: + CHAT_WINDOW.mark_request_sent(len(all_msgs)) stream = req.get("stream", False) + log(f"REQ: stream={stream} tools={len(tools) if tools else 0} model={model_name} prompt_bytes={len(prompt.encode('utf-8'))}") cid = f"chatcmpl-{uuid.uuid4().hex[:12]}" try: file_refs = upload_images(images) @@ -794,16 +1241,15 @@ def handle_chat(self, body: bytes): if stream and not tools: # True streaming: forward chunks as they arrive + self._send_stream_headers() try: - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Cache-Control", "no-cache") - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() + full_text = "" first_chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]} self.wfile.write(f"data: {json.dumps(first_chunk)}\n\n".encode()) - for delta_text in gemini_stream_generate_iter(prompt, model_id, think_mode, file_refs): + self.wfile.flush() + for delta_text in gemini_stream_generate_iter(prompt, model_id, think_mode, file_refs, model_name): + full_text += delta_text chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, "choices": [{"index": 0, "delta": {"content": delta_text}, "finish_reason": None}]} self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode()) @@ -812,6 +1258,9 @@ def handle_chat(self, body: bytes): chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]} self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) + # Usage chunk for DSH usage plugin + usage = self._usage_chunk(cid, model_name, prompt, full_text) + self.wfile.write(f"data: {json.dumps(usage, ensure_ascii=False)}\n\n".encode()) self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError): @@ -820,9 +1269,117 @@ def handle_chat(self, body: bytes): log(f"Stream error: {e}") return + if stream and tools: + # True streaming WITH tools: stream plain text as it arrives (fast + # TTFT), but buffer ```tool_call JSON blocks so they are NOT leaked + # into content — emit parsed tool_calls delta at the end instead. + # (Leaking the JSON into content makes clients like DSH treat the + # turn as a text reply and never execute the tool.) + self._send_stream_headers() + try: + first_chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), + "model": model_name, "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]} + self.wfile.write(f"data: {json.dumps(first_chunk)}\n\n".encode()) + self.wfile.flush() + + TOOL_START = "```tool_call" + TOOL_END = "\n```" + pending = "" # text not yet classified + in_tool = False + tool_calls = [] + full_text = "" # full raw text (for usage estimate) + text_sent = "" # text streamed as content + + def send_content(txt): + if not txt: + return + nonlocal text_sent + text_sent += txt + chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), + "model": model_name, "choices": [{"index": 0, "delta": {"content": txt}, "finish_reason": None}]} + self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode()) + self.wfile.flush() + + def parse_block(block_text): + """Parse one ```tool_call JSON block into a tool call dict.""" + try: + data = json.loads(block_text.strip()) + return { + "id": f"call_{uuid.uuid4().hex[:8]}", + "type": "function", + "function": { + "name": data["name"], + "arguments": json.dumps(data.get("arguments", {}), ensure_ascii=False), + }, + } + except (json.JSONDecodeError, KeyError, TypeError): + return None + + for delta_text in gemini_stream_generate_iter(prompt, model_id, think_mode, file_refs, model_name): + full_text += delta_text + pending += delta_text + # Process the pending buffer until it stabilizes (no + # tool block open and no new one started). + stable = False + while not stable: + stable = True + if not in_tool: + idx = pending.find(TOOL_START) + if idx >= 0: + send_content(pending[:idx]) + pending = pending[idx:] + in_tool = True + stable = False + else: + # Stream all but a tail that could become the + # tool block opener (cross-chunk safety). + keep = min(len(pending), len(TOOL_START) - 1) + if len(pending) > keep: + send_content(pending[:-keep] if keep else pending) + pending = pending[-keep:] if keep else "" + else: + # Try to close the open tool block right away. + end_idx = pending.find(TOOL_END, len(TOOL_START)) + if end_idx >= 0: + block = pending[len(TOOL_START):end_idx] + tc = parse_block(block) + if tc: + tool_calls.append(tc) + pending = pending[end_idx + len(TOOL_END):] + in_tool = False + stable = False + # Any remaining plain text after the loop. + if pending and not in_tool: + send_content(pending) + elif pending and in_tool: + # Unterminated tool block: drop it from content. + log(f"Unterminated tool block, dropped {len(pending)}B") + + # Final chunk: emit tool_calls (if any) + finish_reason. + if tool_calls: + msg = {"role": "assistant", "content": text_sent or None, "tool_calls": tool_calls} + finish = "tool_calls" + else: + msg = {} + finish = "stop" + final_chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), + "model": model_name, "choices": [{"index": 0, "delta": msg, "finish_reason": finish}]} + self.wfile.write(f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n".encode()) + # Usage chunk for DSH usage plugin + usage = self._usage_chunk(cid, model_name, prompt, full_text) + self.wfile.write(f"data: {json.dumps(usage, ensure_ascii=False)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + log(f"STREAM-COMPLETE: {cid} finish={finish} text={len(text_sent)}B tools={len(tool_calls)} raw={full_text[:120]!r}") + except (BrokenPipeError, ConnectionResetError): + pass + except Exception as e: + log(f"Stream tool error: {e}") + return + # Non-streaming (or tool calling which needs full response) try: - text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools, file_refs) + text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools, file_refs, model_name) except Exception as e: self.send_json({"error": {"message": f"upstream error: {e}"}}, 502) return @@ -833,15 +1390,14 @@ def handle_chat(self, body: bytes): finish = "tool_calls" if tool_calls else "stop" if stream: - # Stream mode with tools: send as single chunk (need full parse for tool_calls) - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Cache-Control", "no-cache") - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() + # Stream mode with tools: full response as delta (tool_calls parsed), then usage + self._send_stream_headers() chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, "choices": [{"index": 0, "delta": msg, "finish_reason": finish}]} self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode()) + # Usage chunk for DSH usage plugin + usage = self._usage_chunk(cid, model_name, prompt, text) + self.wfile.write(f"data: {json.dumps(usage, ensure_ascii=False)}\n\n".encode()) self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() else: @@ -1081,6 +1637,16 @@ def main(): if new_bl: CONFIG["gemini_bl"] = new_bl + if not CONFIG.get("xsrf_token"): + tok = fetch_xsrf_token() + # fetch_xsrf_token() calls load_cookie() internally, which may have just + # injected xsrf from the auth json file. Don't clobber that with the + # auto-fetched value — the auth-file value (SNlM0e) is the authoritative + # one the page expects as the `at` form field. + if tok and not CONFIG.get("xsrf_token"): + CONFIG["xsrf_token"] = tok + log(f"xsrf auto-fetched (len {len(tok)})") + class ThreadedServer(ThreadingMixIn, HTTPServer): daemon_threads = True allow_reuse_address = True