From dc6ee4a5ea2b445e3fa60865adab68b35c073d07 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Mon, 24 Aug 2026 04:40:37 +0000 Subject: [PATCH 1/3] fix(parsing): return only the real answer frame, never thought/draft/chip frames The response parser picked the longest text found anywhere in the StreamGenerate response (single-file: the last text). A Gemini Web response also contains thought summaries, alternative drafts and follow-up/image frames; when the real answer was short (e.g. replying to '??'), a longer unrelated frame won and the API returned content that had nothing to do with the user's question. - Frame-aware parser: only the primary candidate of the main answer frame (the one carrying conversation/response ids) is used; answer segments are joined instead of returning the longest fragment; longest joined candidate kept as protocol-drift fallback - Streaming: same strict frame selection; no more splicing unrelated texts (t[len(prev):] without prefix check) and no more spurious 'content changed' retries; freezes on mid-stream rewrites; rescans buffered response as fallback; retries only when nothing was emitted - _reqid: process-wide browser-like incrementing counter (+100000 per call) instead of timestamp; concurrent requests (cronjob + chat) no longer send identical request ids; fresh id on every retry attempt - Applied to both the package (gemini_web2api/gemini.py) and the single-file build (gemini_web2api.py) - Add tests/test_response_parsing.py with simulated multi-frame wire payloads covering all failure modes (36 tests total, all pass) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- gemini_web2api.py | 187 ++++++++++++++++-------- gemini_web2api/gemini.py | 198 ++++++++++++++++++++------ tests/test_response_parsing.py | 251 +++++++++++++++++++++++++++++++++ 3 files changed, 533 insertions(+), 103 deletions(-) create mode 100644 tests/test_response_parsing.py diff --git a/gemini_web2api.py b/gemini_web2api.py index 1f73f6e..4cd989d 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -29,6 +29,8 @@ import uuid import re import os +import random +import threading import hashlib import argparse import base64 @@ -107,6 +109,21 @@ # ─── Utilities ─────────────────────────────────────────────────────────────── +# Browser-like per-session RPC counter. The real Gemini web app sends an +# ever-increasing _reqid (+100000 per RPC) for every request in a page +# session. Deriving it from a timestamp made concurrent requests (e.g. a +# cronjob firing while the user is chatting) send identical ids. +_REQID_LOCK = threading.Lock() +_REQID_NEXT = random.randrange(10000, 99999) + + +def _next_reqid() -> int: + global _REQID_NEXT + with _REQID_LOCK: + _REQID_NEXT += 100000 + return _REQID_NEXT + + def log(msg: str): if CONFIG["log_requests"]: sys.stderr.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n") @@ -250,13 +267,7 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref if CONFIG.get("xsrf_token"): params["at"] = CONFIG["xsrf_token"] body = urllib.parse.urlencode(params).encode() - reqid = int(time.time()) % 1000000 prefix = account_prefix() - url = ( - f"https://gemini.google.com{prefix}/_/BardChatUi/data/" - "assistant.lamda.BardFrontendService/StreamGenerate" - f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" - ) headers = { "Content-Type": "application/x-www-form-urlencoded", "Origin": "https://gemini.google.com", @@ -273,10 +284,17 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref if sapisid: headers["Authorization"] = make_sapisidhash(sapisid) + def build_url() -> str: + return ( + f"https://gemini.google.com{prefix}/_/BardChatUi/data/" + "assistant.lamda.BardFrontendService/StreamGenerate" + f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={_next_reqid()}&rt=c" + ) + last_err = None for attempt in range(CONFIG["retry_attempts"]): try: - req = urllib.request.Request(url, data=body, headers=headers, method="POST") + req = urllib.request.Request(build_url(), data=body, headers=headers, method="POST") ctx = ssl.create_default_context() proxy = CONFIG.get("proxy") if proxy: @@ -290,12 +308,6 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref return resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: if e.code == 405 and update_bl_if_needed(): - reqid = int(time.time()) % 1000000 - url = ( - f"https://gemini.google.com{prefix}/_/BardChatUi/data/" - "assistant.lamda.BardFrontendService/StreamGenerate" - f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" - ) log("Retrying with updated BL...") last_err = e continue @@ -341,12 +353,11 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil if CONFIG.get("xsrf_token"): params["at"] = CONFIG["xsrf_token"] body = urllib.parse.urlencode(params) - reqid = int(time.time()) % 1000000 prefix = account_prefix() url = ( f"https://gemini.google.com{prefix}/_/BardChatUi/data/" "assistant.lamda.BardFrontendService/StreamGenerate" - f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" + f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={_next_reqid()}&rt=c" ) headers = { "Content-Type": "application/x-www-form-urlencoded", @@ -389,26 +400,20 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil raise RuntimeError(f"Gemini upstream rejected request: BardErrorInfo [{m.group(1)}]") while "\n" in buf: line, buf = buf.split("\n", 1) - if '"wrb.fr"' not in line or len(line) < 200: + current = best_main_answer(line) + if not current: continue - try: - arr = json.loads(line) - inner_str = arr[0][2] - if not inner_str or len(inner_str) < 50: + if current == prev_text or prev_text.startswith(current): + continue # duplicate or older update + if not current.startswith(prev_text): + if prev_text: + log("Stream answer replaced mid-flight; keeping already-emitted text") continue - inner2 = json.loads(inner_str) - 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): - for t in part[1]: - if isinstance(t, str) and len(t) > len(prev_text): - delta = t[len(prev_text):] - delta = clean_gemini_text(delta, strip=False) - if delta: - yield delta - prev_text = t - except (json.JSONDecodeError, IndexError, TypeError): - pass + prev_text = "" # first frame was noise; adopt the real answer + delta = clean_gemini_text(current[len(prev_text):], strip=False) + prev_text = current + if delta: + yield delta except Exception as e: if HAS_HTTPX and hasattr(e, 'response') and getattr(e.response, 'status_code', 0) == 405: if update_bl_if_needed(): @@ -430,37 +435,105 @@ def clean_gemini_text(text: str, strip: bool = True) -> str: return text.strip() if strip else text +def iter_frames(line: str): + """Yield the parsed inner payload of every wrb.fr frame in one response line. + + A single StreamGenerate chunk line can carry several frames; the answer, + thought summaries, alternative drafts, follow-up chips and image-agent + updates each arrive as separate frames. + """ + if '"wrb.fr"' not in line or len(line) < 40: + return + try: + frames = json.loads(line) + except (json.JSONDecodeError, ValueError): + return + if not isinstance(frames, list): + return + for frame in frames: + if not (isinstance(frame, list) and len(frame) > 2 and frame[0] == "wrb.fr"): + continue + payload = frame[2] + if not (isinstance(payload, str) and len(payload) >= 20): + continue + try: + inner = json.loads(payload) + except (json.JSONDecodeError, ValueError): + continue + if isinstance(inner, list) and len(inner) > 4 and inner[4]: + yield inner + + +def candidate_texts(inner: list) -> list: + """Return [(candidate_index, joined_text)] for one frame payload. + + Each candidate's text is a *list* of segments in the wire format and must + be joined; treating segments as standalone answers returned fragments. + """ + out = [] + for idx, cand in enumerate(inner[4]): + if isinstance(cand, list) and len(cand) > 1 and isinstance(cand[1], list): + text = "".join(t for t in cand[1] if isinstance(t, str)) + if text: + out.append((idx, text)) + return out + + +def is_answer_frame(inner: list) -> bool: + """True for the main answer frame, which carries the new conversation id + (inner[1], e.g. "c_...") and response id (inner[2], e.g. "r_..."). + + Thought summaries, follow-up chips, search and image-agent frames do not + carry these ids -- that is what tells them apart from the real answer. + """ + if len(inner) > 2: + conv_id, resp_id = inner[1], inner[2] + return (isinstance(conv_id, str) and bool(conv_id) + and isinstance(resp_id, str) and bool(resp_id)) + return False + + +def best_main_answer(line: str): + """Best primary-candidate answer text found in one line, or None.""" + best = "" + for inner in iter_frames(line): + if not is_answer_frame(inner): + continue + for idx, text in candidate_texts(inner): + if idx == 0 and len(text) > len(best): + best = text + return best or None + + def extract_response_text(raw: str) -> str: - """Parse StreamGenerate response to extract final text.""" + """Parse StreamGenerate response to extract the final answer text. + + Selection order: + 1. Primary candidate (index 0) of the main answer frame -- the frame + carrying conversation/response ids. Longest update wins (streaming + re-sends this frame as the answer grows). + 2. Longest joined candidate from any frame (protocol drift fallback). + Never the thought/draft/chip frames, which previously won the "longest + text anywhere" heuristic and produced unrelated answers. + """ import re as _re bard_err = _re.search(r'BardErrorInfo\s*\[(\d+)\]', raw) if bard_err: raise RuntimeError(f"Gemini upstream rejected request: BardErrorInfo [{bard_err.group(1)}]") - texts = [] + main_best = "" + any_best = "" for line in raw.split("\n"): - if '"wrb.fr"' not in line or len(line) < 200: - continue - try: - arr = json.loads(line) - inner_str = arr[0][2] - if not inner_str or len(inner_str) < 50: + for inner in iter_frames(line): + cands = candidate_texts(inner) + if not cands: continue - inner = json.loads(inner_str) - if isinstance(inner, list) and len(inner) > 4 and inner[4]: - for part in inner[4]: - if isinstance(part, list) and len(part) > 1 and part[1]: - if isinstance(part[1], list): - for t in part[1]: - if isinstance(t, str) and len(t) > 0: - texts.append(t) - except (json.JSONDecodeError, IndexError, TypeError): - pass - text = "" - for t in reversed(texts): - if t.strip(): - text = t - break - return clean_gemini_text(text) + is_main = is_answer_frame(inner) + for idx, text in cands: + if len(text) > len(any_best): + any_best = text + if is_main and idx == 0 and len(text) > len(main_best): + main_best = text + return clean_gemini_text(main_best or any_best) # ─── OpenAI Format Helpers ─────────────────────────────────────────────────── diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index d69b08d..284c035 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -3,6 +3,8 @@ import time import uuid import re +import random +import threading import urllib.request import urllib.parse import ssl @@ -21,6 +23,20 @@ _cookie_cache = {"str": "", "sapisid": None, "mtime": 0} _httpx_client = None +# Browser-like per-session RPC counter. The real Gemini web app sends an +# ever-increasing _reqid (+100000 per RPC) for every request in a page +# session. Deriving it from a timestamp made concurrent requests (e.g. a +# cronjob firing while the user is chatting) send identical ids. +_REQID_LOCK = threading.Lock() +_REQID_NEXT = random.randrange(10000, 99999) + + +def _next_reqid() -> int: + global _REQID_NEXT + with _REQID_LOCK: + _REQID_NEXT += 100000 + return _REQID_NEXT + def log(msg: str): if CONFIG["log_requests"]: @@ -148,12 +164,11 @@ def _build_payload(prompt: str, model_id: int, think_mode: int, file_refs: list def _get_url() -> str: - reqid = int(time.time()) % 1000000 account_prefix = _account_prefix() return ( f"https://gemini.google.com{account_prefix}/_/BardChatUi/data/" "assistant.lamda.BardFrontendService/StreamGenerate" - f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" + f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={_next_reqid()}&rt=c" ) @@ -166,46 +181,109 @@ def clean_text(text: str, strip: bool = True) -> str: return text.strip() if strip else text -def _extract_texts_from_line(line: str) -> list: - """Parse a single wrb.fr line and return list of text strings found.""" - if '"wrb.fr"' not in line or len(line) < 200: - return [] +def _iter_frames(line: str): + """Yield the parsed inner payload of every wrb.fr frame in one response line. + + A single StreamGenerate chunk line can carry several frames; the answer, + thought summaries, alternative drafts, follow-up chips and image-agent + updates each arrive as separate frames. + """ + if '"wrb.fr"' not in line or len(line) < 40: + return try: - arr = json.loads(line) - inner_str = arr[0][2] - if not inner_str or len(inner_str) < 50: - return [] - inner = json.loads(inner_str) - if not (isinstance(inner, list) and len(inner) > 4 and inner[4]): - return [] - texts = [] - for part in inner[4]: - if isinstance(part, list) and len(part) > 1 and part[1] and isinstance(part[1], list): - for t in part[1]: - if isinstance(t, str) and t: - texts.append(t) - return texts - except (json.JSONDecodeError, IndexError, TypeError): - return [] + frames = json.loads(line) + except (json.JSONDecodeError, ValueError): + return + if not isinstance(frames, list): + return + for frame in frames: + if not (isinstance(frame, list) and len(frame) > 2 and frame[0] == "wrb.fr"): + continue + payload = frame[2] + if not (isinstance(payload, str) and len(payload) >= 20): + continue + try: + inner = json.loads(payload) + except (json.JSONDecodeError, ValueError): + continue + if isinstance(inner, list) and len(inner) > 4 and inner[4]: + yield inner + + +def _candidate_texts(inner: list) -> list: + """Return [(candidate_index, joined_text)] for one frame payload. + + Each candidate's text is a *list* of segments in the wire format and must + be joined; treating segments as standalone answers returned fragments. + """ + out = [] + for idx, cand in enumerate(inner[4]): + if isinstance(cand, list) and len(cand) > 1 and isinstance(cand[1], list): + text = "".join(t for t in cand[1] if isinstance(t, str)) + if text: + out.append((idx, text)) + return out + + +def _is_answer_frame(inner: list) -> bool: + """True for the main answer frame, which carries the new conversation id + (inner[1], e.g. "c_...") and response id (inner[2], e.g. "r_..."). + + Thought summaries, follow-up chips, search and image-agent frames do not + carry these ids -- that is what tells them apart from the real answer. + """ + if len(inner) > 2: + conv_id, resp_id = inner[1], inner[2] + return (isinstance(conv_id, str) and bool(conv_id) + and isinstance(resp_id, str) and bool(resp_id)) + return False + + +def _best_main_answer(line: str): + """Best primary-candidate answer text found in one line, or None.""" + best = "" + for inner in _iter_frames(line): + if not _is_answer_frame(inner): + continue + for idx, text in _candidate_texts(inner): + if idx == 0 and len(text) > len(best): + best = text + return best or None def extract_response_text(raw: str) -> str: - """Parse full response to get final text.""" + """Parse full response to get the final answer text. + + Selection order: + 1. Primary candidate (index 0) of the main answer frame -- the frame + carrying conversation/response ids. Longest update wins (streaming + re-sends this frame as the answer grows). + 2. Longest joined candidate from any frame (protocol drift fallback). + Never the thought/draft/chip frames, which previously won the "longest + text anywhere" heuristic and produced unrelated answers. + """ bard_err = re.search(r'BardErrorInfo\s*\[(\d+)\]', raw) if bard_err: raise RuntimeError(f"Gemini upstream rejected request: BardErrorInfo [{bard_err.group(1)}]") - last_text = "" + main_best = "" + any_best = "" for line in raw.split("\n"): - for t in _extract_texts_from_line(line): - if len(t) > len(last_text): - last_text = t - return clean_text(last_text) + for inner in _iter_frames(line): + cands = _candidate_texts(inner) + if not cands: + continue + is_main = _is_answer_frame(inner) + for idx, text in cands: + if len(text) > len(any_best): + any_best = text + if is_main and idx == 0 and len(text) > len(main_best): + main_best = text + return clean_text(main_best or any_best) def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None, extra_fields: dict = None) -> str: """Non-streaming generation with retry.""" body = _build_payload(prompt, model_id, think_mode, file_refs, extra_fields).encode() - url = _get_url() headers = _build_headers() ctx = _get_ssl_ctx() proxy = CONFIG.get("proxy") @@ -213,7 +291,7 @@ def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None last_err = None for attempt in range(CONFIG["retry_attempts"]): try: - req = urllib.request.Request(url, data=body, headers=headers, method="POST") + req = urllib.request.Request(_get_url(), data=body, headers=headers, method="POST") if proxy: opener = urllib.request.build_opener( urllib.request.ProxyHandler({"http": proxy, "https": proxy}), @@ -233,7 +311,14 @@ def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list = None, extra_fields: dict = None): - """Streaming generation via httpx with retry on connection failure.""" + """Streaming generation via httpx with retry on connection failure. + + Only the primary candidate of the main answer frame is streamed. + Thought summaries, drafts and chip frames are ignored, deltas are only + emitted when the new answer extends what was already emitted, and a + mid-stream answer rewrite freezes the already-emitted text instead of + splicing unrelated frames into the output. + """ if not HAS_HTTPX: text = generate(prompt, model_id, think_mode, file_refs, extra_fields) if text: @@ -241,15 +326,16 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list return body = _build_payload(prompt, model_id, think_mode, file_refs, extra_fields) - url = _get_url() headers = _build_headers() client = _get_httpx_client() last_err = None - emitted_raw_text = "" for attempt in range(CONFIG["retry_attempts"]): + emitted_raw_text = "" + emitted_any = False + raw_lines = [] try: - with client.stream("POST", url, content=body, headers=headers) as resp: + with client.stream("POST", _get_url(), content=body, headers=headers) as resp: resp.raise_for_status() buf = "" for chunk in resp.iter_text(): @@ -262,19 +348,39 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list ) while "\n" in buf: line, buf = buf.split("\n", 1) - for t in _extract_texts_from_line(line): - if t == emitted_raw_text or emitted_raw_text.startswith(t): + raw_lines.append(line) + current = _best_main_answer(line) + if not current: + continue + if current == emitted_raw_text or emitted_raw_text.startswith(current): + continue # duplicate or older update + if not current.startswith(emitted_raw_text): + if emitted_any: + log("Stream answer replaced mid-flight; keeping already-emitted text") continue - if not t.startswith(emitted_raw_text): - raise RuntimeError("Gemini stream content changed during retry") - delta = clean_text(t[len(emitted_raw_text):], strip=False) - emitted_raw_text = t - if delta: - yield delta - return + emitted_raw_text = "" # first frame was noise; adopt the real answer + delta = clean_text(current[len(emitted_raw_text):], strip=False) + emitted_raw_text = current + if delta: + emitted_any = True + yield delta + if emitted_any: + return + # Strict parser matched nothing (protocol drift): rescan the full + # buffered response with the fallback tiers before giving up. + if raw_lines: + fallback = extract_response_text("\n".join(raw_lines)) + if fallback: + yield fallback + return + last_err = RuntimeError("no answer frame in Gemini stream response") except Exception as e: + if emitted_any: + # Content already streamed to the client; retrying would + # duplicate it. + raise last_err = e - if attempt < CONFIG["retry_attempts"] - 1: - log(f"Stream retry {attempt+1}/{CONFIG['retry_attempts']}: {e}") - time.sleep(CONFIG["retry_delay_sec"]) + if attempt < CONFIG["retry_attempts"] - 1: + log(f"Stream retry {attempt+1}/{CONFIG['retry_attempts']}: {last_err}") + time.sleep(CONFIG["retry_delay_sec"]) raise last_err diff --git a/tests/test_response_parsing.py b/tests/test_response_parsing.py new file mode 100644 index 0000000..33da66e --- /dev/null +++ b/tests/test_response_parsing.py @@ -0,0 +1,251 @@ +"""Regression tests for StreamGenerate response parsing. + +These simulate the multi-frame wire format that Gemini Web actually returns: +the main answer frame (carrying conversation/response ids) plus thought +summaries, alternative drafts and follow-up chip frames that do NOT carry +those ids. + +The old parser picked the longest text found anywhere in the response, so a +long thought-summary / draft / chip frame won over a short real answer and +the API returned content unrelated to the user's question. +""" +import importlib.util +import json +import os +import unittest +from unittest import mock + +from gemini_web2api.config import CONFIG +from gemini_web2api.gemini import ( + HAS_HTTPX, + extract_response_text, + generate_stream, + _next_reqid, +) + +SINGLE_FILE = os.path.join(os.path.dirname(__file__), "..", "gemini_web2api.py") + + +def load_single_file(): + spec = importlib.util.spec_from_file_location("gemini_web2api_single", SINGLE_FILE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# ─── wire-format builders ──────────────────────────────────────────────────── + +def make_line(*inners): + """Serialize one StreamGenerate chunk line containing several frames.""" + return json.dumps([["wrb.fr", "f%d" % i, json.dumps(inner)] for i, inner in enumerate(inners)]) + + +def make_raw(*lines): + body = "".join("%d\n%s\n" % (len(l), l) for l in lines) + return ")]}'\n\n" + body + + +def answer_inner(segments, drafts=(), convo="c_abc123", resp="r_def456"): + """Main answer frame: carries conversation id at [1] and response id at [2].""" + inner = [None] * 6 + inner[1] = convo + inner[2] = resp + candidates = [[None, list(segments)]] + for draft in drafts: + candidates.append([None, list(draft)]) + inner[4] = candidates + return inner + + +def noise_inner(segments): + """A frame without conversation/response ids: thought summary, follow-up + chip, image-agent or search frame.""" + inner = [None] * 6 + inner[4] = [[None, list(segments)]] + return inner + + +ANATOMY_NOISE = ( + "The pharynx, larynx, and trachea form a continuous vertical pathway " + "connecting your nasal cavity and mouth down to your lungs. " * 8 +) +SHORT_ANSWER = "I'm not sure what you mean. Could you clarify?" + + +# ─── non-streaming parser ──────────────────────────────────────────────────── + +class ExtractResponseTextTests(unittest.TestCase): + def test_thought_frame_longer_than_answer_is_ignored(self): + raw = make_raw( + make_line(noise_inner([ANATOMY_NOISE])), + make_line(answer_inner([SHORT_ANSWER])), + ) + self.assertEqual(extract_response_text(raw), SHORT_ANSWER) + + def test_chip_frame_after_answer_is_ignored(self): + chip = "Would you like a closer look at how the epiglottis prevents choking during swallowing? " * 6 + raw = make_raw( + make_line(answer_inner([SHORT_ANSWER])), + make_line(noise_inner([chip])), + ) + self.assertEqual(extract_response_text(raw), SHORT_ANSWER) + + def test_multi_segment_answer_is_joined(self): + raw = make_raw(make_line(answer_inner(["你好,", "我是一段", "被切開的答案", "請重新組合"]))) + self.assertEqual(extract_response_text(raw), "你好,我是一段被切開的答案請重新組合") + + def test_longer_alternative_draft_does_not_override_primary(self): + draft = "This alternative draft is much longer than the primary answer " * 5 + raw = make_raw(make_line(answer_inner([SHORT_ANSWER], drafts=[(draft,)]))) + self.assertEqual(extract_response_text(raw), SHORT_ANSWER) + + def test_progressive_updates_return_final_text(self): + growing = ["Hel", "Hello", "Hello, world!"] + raw = make_raw(*[make_line(answer_inner([t])) for t in growing]) + self.assertEqual(extract_response_text(raw), "Hello, world!") + + def test_fallback_when_no_frame_carries_ids(self): + # Protocol drift: no answer frame ids anywhere. Longest joined + # candidate is returned so the API stays functional. + raw = make_raw( + make_line(noise_inner(["short"])), + make_line(noise_inner([SHORT_ANSWER])), + ) + self.assertEqual(extract_response_text(raw), SHORT_ANSWER) + + def test_multi_frame_line_all_frames_parsed(self): + # A single line carrying both noise and the answer frame. + raw = make_raw(make_line(noise_inner([ANATOMY_NOISE]), answer_inner([SHORT_ANSWER]))) + self.assertEqual(extract_response_text(raw), SHORT_ANSWER) + + def test_bard_error_raises(self): + with self.assertRaises(RuntimeError): + extract_response_text(")]}'\n\n[3,[\"er\",null,\"BardErrorInfo [1037]\"]]\n") + + def test_garbage_returns_empty(self): + self.assertEqual(extract_response_text(")]}'\n\n12\n[\"e\",4]\n"), "") + + +class SingleFileParityTests(unittest.TestCase): + """The single-file build must behave identically.""" + + @classmethod + def setUpClass(cls): + cls.single = load_single_file() + + def test_thought_frame_longer_than_answer_is_ignored(self): + raw = make_raw( + make_line(noise_inner([ANATOMY_NOISE])), + make_line(answer_inner([SHORT_ANSWER])), + ) + self.assertEqual(self.single.extract_response_text(raw), SHORT_ANSWER) + + def test_multi_segment_answer_is_joined(self): + raw = make_raw(make_line(answer_inner(["a ", "b", "c"]))) + self.assertEqual(self.single.extract_response_text(raw), "a bc") + + def test_longer_alternative_draft_does_not_override_primary(self): + draft = "This alternative draft is much longer than the primary answer " * 5 + raw = make_raw(make_line(answer_inner([SHORT_ANSWER], drafts=[(draft,)]))) + self.assertEqual(self.single.extract_response_text(raw), SHORT_ANSWER) + + +# ─── request ids ───────────────────────────────────────────────────────────── + +class ReqidTests(unittest.TestCase): + def test_reqids_unique_and_increasing_even_within_same_second(self): + ids = [_next_reqid() for _ in range(50)] + self.assertEqual(len(set(ids)), 50) + self.assertEqual(ids, sorted(ids)) + + +# ─── streaming ─────────────────────────────────────────────────────────────── + +class FakeStreamResponse: + def __init__(self, chunks): + self._chunks = chunks + + def raise_for_status(self): + pass + + def iter_text(self): + yield from self._chunks + + +class FakeStreamContext: + def __init__(self, chunks): + self._chunks = chunks + + def __enter__(self): + return FakeStreamResponse(self._chunks) + + def __exit__(self, *args): + return False + + +class FakeHttpClient: + def __init__(self, chunk_iterables): + self._chunk_iterables = chunk_iterables + self.calls = 0 + + def stream(self, method, url, content=None, headers=None): + chunks = self._chunk_iterables[min(self.calls, len(self._chunk_iterables) - 1)] + self.calls += 1 + return FakeStreamContext(chunks) + + +@unittest.skipUnless(HAS_HTTPX, "httpx not installed") +class GenerateStreamTests(unittest.TestCase): + def setUp(self): + self.original_config = dict(CONFIG) + CONFIG["retry_attempts"] = 1 + CONFIG["retry_delay_sec"] = 0 + CONFIG["log_requests"] = False + + def tearDown(self): + CONFIG.clear() + CONFIG.update(self.original_config) + + def _run(self, client): + with mock.patch("gemini_web2api.gemini._get_httpx_client", return_value=client): + return "".join(generate_stream("prompt", 1, 4)) + + def test_stream_ignores_thought_frames_and_emits_answer_deltas(self): + chunks = [ + ")]}'\n\n", + make_line(noise_inner([ANATOMY_NOISE])) + "\n", + make_line(answer_inner(["Hello"])) + "\n", + make_line(answer_inner(["Hello, wor"])) + "\n", + make_line(answer_inner(["Hello, world!"])) + "\n", + ] + self.assertEqual(self._run(FakeHttpClient([chunks])), "Hello, world!") + + def test_stream_tolerates_answer_rewrite_without_splicing(self): + chunks = [ + make_line(answer_inner(["Original answer, already emitted."])) + "\n", + make_line(answer_inner(["A completely different replacement text."])) + "\n", + ] + out = self._run(FakeHttpClient([chunks])) + self.assertEqual(out, "Original answer, already emitted.") + + def test_stream_falls_back_when_no_answer_frame_ids(self): + chunks = [make_line(noise_inner([SHORT_ANSWER])) + "\n"] + self.assertEqual(self._run(FakeHttpClient([chunks])), SHORT_ANSWER) + + def test_stream_retries_when_first_attempt_is_empty(self): + CONFIG["retry_attempts"] = 2 + empty = ["[\"e\",4]\n"] + good = [make_line(answer_inner(["Recovered on retry"])) + "\n"] + client = FakeHttpClient([empty, good]) + with mock.patch("gemini_web2api.gemini._get_httpx_client", return_value=client): + out = "".join(generate_stream("prompt", 1, 4)) + self.assertEqual(out, "Recovered on retry") + self.assertEqual(client.calls, 2) + + def test_stream_raises_after_all_attempts_empty(self): + with self.assertRaises(RuntimeError): + self._run(FakeHttpClient([["[\"e\",4]\n"]])) + + +if __name__ == "__main__": + unittest.main() From ad082f357d64ddac90e77751d8612bf11a6eae16 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Mon, 24 Aug 2026 04:47:53 +0000 Subject: [PATCH 2/3] fix(cloudflare): port strict answer-frame parsing to the Worker build The Cloudflare Worker port had the same three bugs fixed in the Python version: - extractResponseText used 'last non-empty text anywhere' -- a thought summary / follow-up chip frame appearing after the answer won and the API returned content unrelated to the question - streaming emitted t.slice(prevText.length) with only a length check, splicing unrelated frames into the output - _reqid came from a timestamp, so concurrent requests sent identical ids Port the fixes: frame-aware parser (main answer frame only, joined segments, fallback tiers), strict streaming with freeze-on-rewrite and end-of-stream fallback rescan, process-wide incrementing _reqid. Adds tests/worker_parsing.test.js (Node, 12 checks). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- cloudflare/README.MD | 3 +- cloudflare/worker.js | 347 +++++++++++++++++++++-------------- tests/worker_parsing.test.js | 139 ++++++++++++++ 3 files changed, 353 insertions(+), 136 deletions(-) create mode 100644 tests/worker_parsing.test.js diff --git a/cloudflare/README.MD b/cloudflare/README.MD index b55c90e..ea50490 100644 --- a/cloudflare/README.MD +++ b/cloudflare/README.MD @@ -64,7 +64,7 @@ https://你的worker.workers.dev/health ```json { "status": "ok", - "version": "1.5.0-cf-multifingerprint", + "version": "1.5.1-cf-strict-parsing", "platform": "Cloudflare Workers", "models": ["gemini-3.6-flash", "gemini-3.5-flash", "..."], "hasCookie": false, @@ -287,6 +287,7 @@ SAPISID = "sapisid_1| sapisid_2| sapisid_3" | 版本 | 日期 | 更新内容 | |------|------|---------| +| 1.5.1 | 2026-08-24 | 修复答非所问问题:只取主答案框架(携带对话/响应 ID)的第 0 号候选,忽略思考摘要/替代草稿/后续建议框架;答案多段文字正确 join;串流不再切片拼接无关文本,中途改写时冻结已发送内容;`_reqid` 改为进程内递增计数器,消除并发碰撞 | | 1.5.0 | 2026-07-31 | 新增多指纹轮换、多Cookie轮换、随机延迟机制 | | 1.4.0 | 2026-07-30 | 修复并发串扰、速率限制内存安全 | | 1.3.0 | 2026-07-29 | 修复 SSE 流式格式、NextChat 兼容性 | diff --git a/cloudflare/worker.js b/cloudflare/worker.js index 938c240..18d855f 100644 --- a/cloudflare/worker.js +++ b/cloudflare/worker.js @@ -991,18 +991,27 @@ function buildPayload(prompt, modelId, thinkMode, config) { * 参数说明: * - bl (build label): Gemini 前端构建版本标识,用于 API 版本控制 * - hl (host language): 界面语言,固定为 en(英语) - * - _reqid: 请求 ID,使用时间戳的后 6 位数字 + * - _reqid: 请求 ID,进程内递增计数器(模拟真实浏览器行为,每次 +100000) * - rt: 请求类型,c 表示普通聊天请求 * * @param {Object} config - 请求级配置对象 * @returns {string} 完整的请求 URL */ +// 浏览器会话级 RPC 计数器。真实 Gemini 网页在同一页面会话中,每次请求的 +// _reqid 都是递增的(+100000)。之前用时间戳取后 6 位,会让同一秒内的并发 +// 请求(例如排程任务触发的同时用户正在聊天)发出完全相同的请求 ID。 +var REQID_NEXT = 10000 + Math.floor(Math.random() * 90000); + +function nextReqid() { + REQID_NEXT += 100000; + return REQID_NEXT; +} + function buildUrl(config) { // 获取多账户 URL 前缀 var prefix = getAccountPrefix(config); - // 生成请求 ID(使用时间戳的后 6 位数字) - // 例如 timestamp() = 1753872000 → reqid = 872000 - var reqid = timestamp() % 1000000; + // 生成请求 ID(进程内唯一、递增) + var reqid = nextReqid(); // 拼接完整 URL return 'https://gemini.google.com' + prefix + '/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate' + @@ -1283,32 +1292,122 @@ function cleanGeminiText(text, strip) { } /** - * 从 Gemini API 原始响应中提取最终文本 - * - * Gemini API 返回的是多行嵌套 JSON 数据,每行格式如下: - * [["wrb.fr", "[[...]]", ...], ...] - * - * 解析逻辑: - * 1. 检查是否有 BardErrorInfo 错误信息 - * 2. 按行分割原始响应文本 - * 3. 跳过不包含 "wrb.fr" 标记的行(非数据行) - * 4. 跳过长度小于 200 的行(太短,不包含有效数据) - * 5. 解析每行的 JSON 数据(双层嵌套结构) - * 6. 从 inner[4] 中提取文本内容 - * 7. 返回最后一个非空文本(通常是最终的完整响应) - * - * 数据结构说明: - * 外层 JSON 数组: - * [0]: "wrb.fr"(数据标记) - * [1]: 预留 - * [2]: 内层 JSON 字符串 - * 内层 JSON 数组: - * [4]: 对话内容数组 - * [*][0]: 内容类型 - * [*][1]: 文本数组 + * 解析一行响应中的所有 wrb.fr 框架 + * + * 一行 StreamGenerate 数据块可以携带多个框架:最终答案、思考摘要、 + * 替代草稿、后续建议、图片代理更新等各自是独立的框架。 + * + * @param {string} line - 单行响应文本 + * @returns {Array} 该行中所有有效框架的内层 payload(inner 数组) + */ +function iterFrames(line) { + if (line.indexOf('"wrb.fr"') === -1 || line.length < 40) return []; + var frames; + try { + frames = JSON.parse(line); + } catch (e) { + return []; + } + if (!Array.isArray(frames)) return []; + var out = []; + for (var i = 0; i < frames.length; i++) { + var frame = frames[i]; + if (!(Array.isArray(frame) && frame.length > 2 && frame[0] === 'wrb.fr')) continue; + var payload = frame[2]; + if (!(typeof payload === 'string' && payload.length >= 20)) continue; + try { + var inner = JSON.parse(payload); + } catch (e) { + continue; + } + if (Array.isArray(inner) && inner.length > 4 && inner[4]) { + out.push(inner); + } + } + return out; +} + +/** + * 提取一个框架中所有候选的完整文本 + * + * 线上格式中每个候选的文本是「多段文字组成的数组」,必须 join 起来才 + * 是完整答案;把单段当独立答案会只返回片段。 + * + * @param {Array} inner - 框架的内层 payload + * @returns {Array} [{index: 候选索引, text: join 后的完整文本}] + */ +function candidateTexts(inner) { + var out = []; + var parts = inner[4]; + for (var idx = 0; idx < parts.length; idx++) { + var cand = parts[idx]; + if (Array.isArray(cand) && cand.length > 1 && Array.isArray(cand[1])) { + var joined = ''; + for (var k = 0; k < cand[1].length; k++) { + if (typeof cand[1][k] === 'string') joined += cand[1][k]; + } + if (joined) out.push({ index: idx, text: joined }); + } + } + return out; +} + +/** + * 判断是否为主答案框架 + * + * 主答案框架在 inner[1] 携带新对话 ID(如 "c_...")、inner[2] 携带响应 + * ID(如 "r_...")。思考摘要、后续建议、搜索与图片代理框架都不带这些 + * ID——这正是把它们和真正的答案区分开来的依据。 + * + * @param {Array} inner - 框架的内层 payload + * @returns {boolean} + */ +function isAnswerFrame(inner) { + if (inner.length > 2) { + var convId = inner[1]; + var respId = inner[2]; + return (typeof convId === 'string' && convId.length > 0 && + typeof respId === 'string' && respId.length > 0); + } + return false; +} + +/** + * 提取一行中最新的主答案文本(用于流式) + * + * @param {string} line - 单行响应文本 + * @returns {string|null} 最新的主候选答案文本,没有则返回 null + */ +function bestMainAnswer(line) { + var best = ''; + var frames = iterFrames(line); + for (var i = 0; i < frames.length; i++) { + var inner = frames[i]; + if (!isAnswerFrame(inner)) continue; + var cands = candidateTexts(inner); + for (var j = 0; j < cands.length; j++) { + if (cands[j].index === 0 && cands[j].text.length > best.length) { + best = cands[j].text; + } + } + } + return best || null; +} + +/** + * 从 Gemini API 原始响应中提取最终答案文本 + * + * 选择顺序: + * 1. 主答案框架(携带对话/响应 ID 的框架)的第 0 号候选。流式过程中 + * 该框架会随答案增长被重发,取最长的一次更新。 + * 2. 任何框架中最长的 join 后候选文本(协议漂移时的兜底)。 + * + * 绝不采用思考摘要/替代草稿/后续建议框架——旧版「整份响应里最后一段 + * 非空文字就是答案」的启发式会让这些框架胜出,返回与用户问题完全无关 + * 的内容。 * * @param {string} raw - API 原始响应文本 - * @returns {string} 提取并清理后的最终文本 + * @returns {string} 提取并清理后的最终答案 * @throws {Error} 如果检测到 BardErrorInfo 错误 */ function extractResponseText(raw) { @@ -1320,70 +1419,30 @@ function extractResponseText(raw) { throw new Error('Gemini upstream rejected request: BardErrorInfo [' + bardErr[1] + ']'); } - // 第二步:收集所有提取到的文本片段 - var texts = []; - - // 第三步:按行分割原始响应 + var mainBest = ''; + var anyBest = ''; var lines = raw.split('\n'); for (var i = 0; i < lines.length; i++) { var line = lines[i]; - - // 跳过不包含 "wrb.fr" 的行(不是数据行) - // 跳过长度小于 200 的行(太短,不包含有效数据) - if (line.indexOf('"wrb.fr"') === -1 || line.length < 200) continue; - - try { - // 第四步:解析外层 JSON - var arr = JSON.parse(line); - // 提取内层 JSON 字符串(arr[0][2]) - var innerStr = arr[0][2]; - - // 跳过空的或太短的内层 JSON - if (!innerStr || innerStr.length < 50) continue; - - // 第五步:解析内层 JSON - var inner = JSON.parse(innerStr); - - // 第六步:检查 inner[4] 是否存在且包含内容 - if (Array.isArray(inner) && inner.length > 4 && inner[4]) { - var parts = inner[4]; - // 遍历 inner[4] 的每个部分 - for (var j = 0; j < parts.length; j++) { - var part = parts[j]; - // part[1] 包含文本数据 - if (Array.isArray(part) && part.length > 1 && part[1]) { - if (Array.isArray(part[1])) { - var textItems = part[1]; - // 遍历文本项 - for (var k = 0; k < textItems.length; k++) { - var t = textItems[k]; - // 收集非空字符串 - if (typeof t === 'string' && t.length > 0) { - texts.push(t); - } - } - } - } + var frames = iterFrames(line); + for (var f = 0; f < frames.length; f++) { + var inner = frames[f]; + var cands = candidateTexts(inner); + if (cands.length === 0) continue; + var isMain = isAnswerFrame(inner); + for (var c = 0; c < cands.length; c++) { + if (cands[c].text.length > anyBest.length) { + anyBest = cands[c].text; + } + if (isMain && cands[c].index === 0 && cands[c].text.length > mainBest.length) { + mainBest = cands[c].text; } } - } catch (e) { - // JSON 解析错误,可能是响应不完整 - // 继续处理下一行,不中断整个解析过程 } } - // 第七步:获取最后一个非空文本 - // Gemini 的响应是逐步累积的,最后一个文本通常包含完整内容 - var text = ''; - for (var m = texts.length - 1; m >= 0; m--) { - if (texts[m].trim()) { - text = texts[m]; - break; - } - } - - // 第八步:清理代码执行痕迹并返回 - return cleanGeminiText(text); + // 清理代码执行痕迹并返回 + return cleanGeminiText(mainBest || anyBest); } // ============================================================================ @@ -2137,6 +2196,8 @@ async function handleChatCompletions(request, body, config) { var decoder = new TextDecoder(); var buffer = ''; // 行缓冲区(处理不完整的行) var prevText = ''; // 记录之前已发送的完整文本 + var emittedAny = false; // 是否已向客户端推送过内容 + var rawLines = []; // 已完成的行(流结束时兜底重扫用) while (true) { var readResult = await reader.read(); @@ -2161,58 +2222,74 @@ async function handleChatCompletions(request, body, config) { // 遍历每一行完整的数据 for (var li = 0; li < lines.length; li++) { var line = lines[li]; - // 跳过不包含数据标记的行或太短的行 - if (line.indexOf('"wrb.fr"') === -1 || line.length < 200) continue; - - try { - // 解析 Gemini 的嵌套 JSON 响应 - var arr = JSON.parse(line); - var innerStr = arr[0][2]; - if (!innerStr || innerStr.length < 50) continue; - - var inner2 = JSON.parse(innerStr); - - // 提取文本内容 - if (Array.isArray(inner2) && inner2.length > 4 && inner2[4]) { - var parts = inner2[4]; - for (var pi = 0; pi < parts.length; pi++) { - var part = parts[pi]; - if (Array.isArray(part) && part.length > 1 && part[1] && Array.isArray(part[1])) { - var textItems = part[1]; - for (var ti = 0; ti < textItems.length; ti++) { - var t = textItems[ti]; - // 检查是否有新内容(文本长度增加了) - if (typeof t === 'string' && t.length > prevText.length) { - // 🔑 计算增量文本 - // 增量 = 当前完整文本 - 之前已发送的完整文本 - var delta = t.slice(prevText.length); - // 清理代码执行痕迹(不 trim,保留空白格式) - var cleaned = cleanGeminiText(delta, false); - if (cleaned) { - // 立即将增量块推送给客户端(打字机效果) - controller.enqueue(streamEncoder.encode('data: ' + JSON.stringify({ - id: chatId, - object: 'chat.completion.chunk', - created: timestamp(), - model: modelName, - choices: [{ - index: 0, - delta: { content: cleaned }, - finish_reason: null - }], - }) + '\n\n')); - } - // 更新已发送的文本记录 - prevText = t; - } - } - } - } + rawLines.push(line); + + // 只采用主答案框架(携带对话/响应 ID 的框架)的最新文本; + // 思考摘要、替代草稿、后续建议等框架一律忽略,从源头杜绝 + // 把与问题无关的内容拼接进输出 + var current = bestMainAnswer(line); + if (!current) continue; + + // 重复或较旧的更新(流式过程中同一框架会随答案增长被重发) + if (current === prevText || prevText.indexOf(current) === 0) continue; + + // 新文本不是已发送内容的延伸(上游中途改写了答案) + if (current.indexOf(prevText) !== 0) { + if (emittedAny) { + // 已推送的内容无法收回:冻结在第一版答案上, + // 绝不切片拼接其他框架的文字 + log('Stream answer replaced mid-flight; keeping already-emitted text', 'WARN', config); + continue; } - } catch (e) { - // JSON 解析错误,继续处理下一行 - // Gemini 的响应可能在传输中被截断 + // 尚未推送任何内容:丢弃噪音基线,采用真正的答案 + prevText = ''; } + + // 🔑 计算增量文本(保证是同一答案的延伸,而非无关文本的切片) + var delta = current.slice(prevText.length); + // 清理代码执行痕迹(不 trim,保留空白格式) + var cleaned = cleanGeminiText(delta, false); + if (cleaned) { + emittedAny = true; + // 立即将增量块推送给客户端(打字机效果) + controller.enqueue(streamEncoder.encode('data: ' + JSON.stringify({ + id: chatId, + object: 'chat.completion.chunk', + created: timestamp(), + model: modelName, + choices: [{ + index: 0, + delta: { content: cleaned }, + finish_reason: null + }], + }) + '\n\n')); + } + // 更新已发送的文本记录 + prevText = current; + } + } + + // ---- 第四步半:流结束兜底 ---- + // 严格解析器全程未匹配到主答案框架(协议漂移)时,对缓冲的 + // 完整响应做降级重扫,避免给客户端返回空流 + if (!emittedAny && rawLines.length > 0) { + try { + var fallbackText = extractResponseText(rawLines.join('\n')); + if (fallbackText) { + controller.enqueue(streamEncoder.encode('data: ' + JSON.stringify({ + id: chatId, + object: 'chat.completion.chunk', + created: timestamp(), + model: modelName, + choices: [{ + index: 0, + delta: { content: fallbackText }, + finish_reason: null + }], + }) + '\n\n')); + } + } catch (e) { + log('Stream fallback rescan failed: ' + e.message, 'WARN', config); } } } finally { @@ -2586,7 +2663,7 @@ export default { if (path === '/' || path === '/health') { return sendJSON({ status: 'ok', - version: '1.5.0-cf-multifingerprint', + version: '1.5.1-cf-strict-parsing', platform: 'Cloudflare Workers', models: Object.keys(MODELS), defaultModel: config.defaultModel, diff --git a/tests/worker_parsing.test.js b/tests/worker_parsing.test.js new file mode 100644 index 0000000..59d949e --- /dev/null +++ b/tests/worker_parsing.test.js @@ -0,0 +1,139 @@ +/** + * Node tests for the Cloudflare Worker's StreamGenerate response parsing. + * Run: node tests/worker_parsing.test.js + * + * Mirrors tests/test_response_parsing.py: the parser must return only the + * primary candidate of the main answer frame, never thought summaries, + * alternative drafts or follow-up chip frames. + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const source = fs.readFileSync(path.join(__dirname, '..', 'cloudflare', 'worker.js'), 'utf8'); + +// Extract just the parsing helpers and load them in a sandbox. +// (The full worker registers a fetch handler / uses Cloudflare APIs, so we +// slice out the functions we need by evaluating the whole file in a context +// where such APIs are stubbed.) +const sandbox = { + console: { log: () => {} }, + addEventListener: () => {}, + exportDefault: undefined, + TextDecoder: require('util').TextDecoder, + TextEncoder: require('util').TextEncoder, + URLSearchParams, + Map, + crypto: { getRandomValues: (arr) => { for (let i = 0; i < arr.length; i++) arr[i] = i + 1; return arr; } }, + fetch: () => Promise.reject(new Error('no network in tests')), + setTimeout, clearTimeout, +}; +vm.createContext(sandbox); +// The worker uses an ES module `export default`; rewrite it to a global +// assignment so the file can be evaluated in a plain script context. +const script = source.replace(/^export\s+default\s*\{/m, 'globalThis.__workerDefault = {'); +vm.runInContext(script, sandbox); + +const { iterFrames, candidateTexts, isAnswerFrame, bestMainAnswer, extractResponseText, nextReqid } = sandbox; + +// ─── wire-format builders (mirror the Python tests) ───────────────────────── +function makeLine(...inners) { + return JSON.stringify(inners.map((inner, i) => ['wrb.fr', 'f' + i, JSON.stringify(inner)])); +} +function makeRaw(...lines) { + return ")]}'\n\n" + lines.map((l) => `${l.length}\n${l}\n`).join(''); +} +function answerInner(segments, drafts = []) { + const inner = new Array(6).fill(null); + inner[1] = 'c_abc123'; + inner[2] = 'r_def456'; + inner[4] = [[null, [...segments]], ...drafts.map((d) => [null, [...d]])]; + return inner; +} +function noiseInner(segments) { + const inner = new Array(6).fill(null); + inner[4] = [[null, [...segments]]]; + return inner; +} + +const ANATOMY_NOISE = + 'The pharynx, larynx, and trachea form a continuous vertical pathway connecting your nasal cavity and mouth down to your lungs. '.repeat(8); +const SHORT_ANSWER = "I'm not sure what you mean. Could you clarify?"; + +let passed = 0; +let failed = 0; +function check(name, actual, expected) { + const ok = actual === expected; + if (ok) { passed++; console.log(`ok - ${name}`); } + else { failed++; console.log(`FAIL - ${name}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); } +} + +// 1. thought/noise frame longer than answer +check( + 'thought frame longer than answer is ignored', + extractResponseText(makeRaw(makeLine(noiseInner([ANATOMY_NOISE])), makeLine(answerInner([SHORT_ANSWER])))), + SHORT_ANSWER +); + +// 2. follow-up chip frame after answer +const chip = 'Would you like a closer look at how the epiglottis prevents choking during swallowing? '.repeat(6); +check( + 'chip frame after answer is ignored', + extractResponseText(makeRaw(makeLine(answerInner([SHORT_ANSWER])), makeLine(noiseInner([chip])))), + SHORT_ANSWER +); + +// 3. multi-segment answer joined +check( + 'multi-segment answer is joined', + extractResponseText(makeRaw(makeLine(answerInner(['你好,', '我是一段', '被切開的答案', '請重新組合'])))), + '你好,我是一段被切開的答案請重新組合' +); + +// 4. longer alternative draft does not override primary +const draft = 'This alternative draft is much longer than the primary answer '.repeat(5); +check( + 'longer alternative draft does not override primary', + extractResponseText(makeRaw(makeLine(answerInner([SHORT_ANSWER], [draft])))), + SHORT_ANSWER +); + +// 5. progressive updates +check( + 'progressive updates return final text', + extractResponseText(makeRaw(makeLine(answerInner(['Hel'])), makeLine(answerInner(['Hello'])), makeLine(answerInner(['Hello, world!'])))), + 'Hello, world!' +); + +// 6. fallback when no frame carries ids +check( + 'fallback when no frame carries ids', + extractResponseText(makeRaw(makeLine(noiseInner(['short'])), makeLine(noiseInner([SHORT_ANSWER])))), + SHORT_ANSWER +); + +// 7. multi-frame line +check( + 'multi-frame line all frames parsed', + extractResponseText(makeRaw(makeLine(noiseInner([ANATOMY_NOISE]), answerInner([SHORT_ANSWER])))), + SHORT_ANSWER +); + +// 8. bestMainAnswer returns null for noise-only lines +check('bestMainAnswer ignores noise lines', bestMainAnswer(makeLine(noiseInner([ANATOMY_NOISE]))), null); +check('bestMainAnswer finds answer lines', bestMainAnswer(makeLine(answerInner(['abc']))), 'abc'); + +// 9. BardErrorInfo raises +let raised = false; +try { extractResponseText(")]}'\n\n[3,[\"er\",null,\"BardErrorInfo [1037]\"]]\n"); } +catch (e) { raised = /BardErrorInfo/.test(e.message); } +check('bard error raises', raised, true); + +// 10. reqid uniqueness +const ids = []; +for (let i = 0; i < 50; i++) ids.push(nextReqid()); +check('reqids unique', new Set(ids).size, 50); +check('reqids increasing', JSON.stringify(ids), JSON.stringify([...ids].sort((a, b) => a - b))); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); From 4a041d1b519216c4b67e7e05817fa3afeb6398a6 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Mon, 24 Aug 2026 04:57:26 +0000 Subject: [PATCH 3/3] feat(cloudflare): add wrangler.toml for Git-connected builds Cloudflare's 'Connect Git repository' flow runs 'npx wrangler deploy' as the build command, which failed because the repo had no wrangler config. Add wrangler.toml pointing main at cloudflare/worker.js (validated with 'npx wrangler@latest deploy --dry-run', 48.58 KiB bundle, no errors) and document the flow plus the Access/non-prod-build toggles in cloudflare/README.MD. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- cloudflare/README.MD | 14 +++++++++++++- wrangler.toml | 13 +++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 wrangler.toml diff --git a/cloudflare/README.MD b/cloudflare/README.MD index ea50490..6067275 100644 --- a/cloudflare/README.MD +++ b/cloudflare/README.MD @@ -25,7 +25,19 @@ Gemini Web2API 是一个部署在 Cloudflare Workers 上的无服务器代理服 ## 🚀 快速部署 -### 第一步:登录 Cloudflare +### 方式一:连接 Git 储存库(自动构建) + +1. 在 Cloudflare Dashboard → **Workers & Pages** → 创建 → 选择 **连接 Git 储存库**,选中本项目的 fork +2. 构建设置保持默认即可: + - **Build command**:`npx wrangler deploy`(默认值,本仓库已内置 `wrangler.toml`,wrangler 会自动部署 `cloudflare/worker.js`) + - **Builds for non-production branches**:建议**关闭**(此 Worker 无预览页概念,非生产分支构建只会重复部署) + - **Protect with Cloudflare Access**:建议**关闭**(会给预览 URL 加登录墙;若你的 API 客户端(如 Hermes/机器人)要直接调用,任何 Access 保护都会挡掉请求) +3. 若你在 Dashboard 先建立了 Worker 再连接 Git,请把 `wrangler.toml` 里的 `name` 改成与该 Worker 同名,避免部署出第二个 Worker +4. 部署完成后,到 Worker → **设置 → 变量** 配置环境变量(`API_KEYS`、`COOKIE_STRING`、`SAPISID` 等,见下文),**每次修改代码推送后自动重新部署,变量不受影响** + +> 提示:此方式由 Cloudflare 托管构建,无需自己申请 API Token。若改用 Pages + `npx wrangler deploy` 的构建命令方式,则需自行配置 `CLOUDFLARE_API_TOKEN` 与 `CLOUDFLARE_ACCOUNT_ID` 环境变量。 + +### 方式二:Dashboard 粘贴代码(最简单) 1. 打开 [Cloudflare Dashboard](https://dash.cloudflare.com) 2. 登录你的 Cloudflare 账号(没有账号可以免费注册) diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..7813bcc --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,13 @@ +# Cloudflare Workers 部署設定檔 +# +# 给「连接 Git 储存库」流程使用:Cloudflare 建置时执行 `npx wrangler deploy`, +# wrangler 会读取本文件并部署 cloudflare/worker.js。 +# +# name 可以改成你自己的 Worker 名称(若已在 Dashboard 建立 Worker 并连接 +# Git,请保持与该 Worker 同名,避免部署出第二个 Worker)。 + +name = "gemini-web2api" +main = "cloudflare/worker.js" +compatibility_date = "2025-06-01" + +# 免费方案即可运行;此 Worker 为纯 I/O 代理,无 KV / D1 / R2 绑定需求。