From 8de8cce7f30a8b55aebe8d4938df2fd8e4baed1b Mon Sep 17 00:00:00 2001 From: kirigayakazima Date: Sat, 5 Sep 2026 15:45:42 +0800 Subject: [PATCH 1/2] fix: restore model selection via x-goog-ext-525001261-jspb header Google moved model routing from payload slot79 to the x-goog-ext-525001261-jspb request header (Issue #82). Without it, every request is served by the account default model and model selection silently no-ops. Changes: - Add MODEL_IDS mapping (verified internal IDs from browser captures) - Add build_model_header() and send the header on both stream and non-stream paths with model_name threaded through call sites - Add fetch_xsrf_token() auto-discovery (FdrFJe, successor of SNlM0e) - load_cookie() now also accepts gemini-auth.json (exported by the bundled extension) and injects xsrf/gemini_bl/auth_user into CONFIG - Add gemini-3.8-flash model entry --- gemini_web2api.py | 134 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 9 deletions(-) diff --git a/gemini_web2api.py b/gemini_web2api.py index 1f73f6e..5171300 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -71,9 +71,13 @@ # 1=FAST, 2=THINKING, 3=PRO, 4=AUTO, 5=FAST_DYNAMIC_THINKING, 6=FLASH_LITE MODELS = { + "gemini-3.8-flash": { + "mode": 1, "think": 4, + "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,6 +109,44 @@ }, } +# ─── 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): @@ -113,8 +155,19 @@ def log(msg: str): sys.stderr.flush() +_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 +180,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) @@ -194,6 +259,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 +318,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: @@ -272,6 +371,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"]): @@ -311,7 +413,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: @@ -362,12 +465,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 @@ -762,8 +868,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: @@ -803,7 +909,7 @@ def handle_chat(self, body: bytes): 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): + for delta_text in gemini_stream_generate_iter(prompt, model_id, think_mode, file_refs, model_name): 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()) @@ -822,7 +928,7 @@ def handle_chat(self, body: bytes): # 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 @@ -1081,6 +1187,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 From 61751efd77c1abe7c17ee88a1592c9ab86cdc3a6 Mon Sep 17 00:00:00 2001 From: kirigayakazima Date: Sun, 6 Sep 2026 17:33:01 +0800 Subject: [PATCH 2/2] fix: DSH/agent compatibility - SSE stream termination, true streaming with tools, lean tool prompt Resolves turn never completing in strict OpenAI clients (DSH, Codex): - protocol_version=HTTP/1.0: SSE streams now end via connection close (EOF). HTTP/1.1 without chunked transfer encoding leaves strict clients waiting forever for a body terminator that BaseHTTPRequestHandler never sends, causing idle timeouts and endless 'thinking' states. - True streaming with tools: stream text chunks as they arrive (fast TTFT), emit parsed tool_calls + finish_reason=tool_calls at the end. Previously tool requests fell back to a blocking full-generate-then-single-chunk path. - Compact tool descriptions (trim to 150 chars, keep full parameters): DSH sends 43 tools (~32KB) which took Google Web minutes to process and hit clients' stream idle timeout (300s). Now ~12KB, responses in seconds. - Log to server.log in real time (stderr + file, unbuffered). --- gemini_web2api.py | 201 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 180 insertions(+), 21 deletions(-) diff --git a/gemini_web2api.py b/gemini_web2api.py index 5171300..826b67f 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -72,7 +72,7 @@ MODELS = { "gemini-3.8-flash": { - "mode": 1, "think": 4, + "mode": 1, "think": 1, "desc": "Latest all-around model (Gemini 3.8 Flash)", }, "gemini-3.7-flash": { @@ -150,9 +150,16 @@ def build_model_header(model_name: str, model_id: int) -> Optional[str]: # ─── 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 @@ -622,6 +629,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 = [] @@ -636,11 +655,40 @@ 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") + # Smart compaction: DSH sends 43 tools with verbose descriptions + # (32KB total). Google Web takes minutes to process such a large + # prompt, causing DSH's 300s stream idle timeout. Strategy: + # - keep FULL parameters (model needs the schema) + # - trim each description to first ~150 chars (core meaning) + # This cuts ~32KB down to ~12KB while keeping tools usable. + MAX_DESC = 150 + compact_defs = [] + for t in tool_defs: + d = t.get("description", "") + if len(d) > MAX_DESC: + d = d[:MAX_DESC].rstrip() + "…" + compact_defs.append({ + "name": t.get("name", ""), + "description": d, + "parameters": t.get("parameters", {}), + }) + # Compact JSON: no whitespace + TOOLS_BUDGET = PROMPT_MAX_BYTES * 3 // 4 + tools_json = json.dumps(compact_defs, ensure_ascii=False, separators=(",", ":")) + # Breakdown log: largest tool descriptions by size + try: + sizes = sorted( + ((len(t.get("description", "")), t.get("name", "")) for t in compact_defs), + reverse=True) + top = ", ".join(f"{n}({s}B)" for s, n in sizes[:8]) + log(f"Tools: {len(compact_defs)} compacted {len(tools_json)}B (was {len(json.dumps(tool_defs, ensure_ascii=False, separators=(',', ':')))}B) | largest: {top}") + except Exception: + pass + if len(tools_json) > TOOLS_BUDGET: + # Absolute fallback: strip descriptions but STILL keep parameters. + 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. " "To call a tool, respond with:\n" @@ -677,7 +725,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 @@ -745,6 +793,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}") @@ -758,6 +811,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: @@ -878,6 +952,32 @@ def _call_gemini(self, prompt, model_id, think_mode, tools, file_refs=None, mode 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", "") + cstr = c if isinstance(c, str) else f"" + tcs = f" tc={len(m.get('tool_calls', []))}" if m.get("tool_calls") else "" + last3.append(f"{m.get('role')}:{cstr[:50]}{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}") model_name, model_id, think_mode, err = self._resolve_model( req.get("model", CONFIG["default_model"])) if err: @@ -886,11 +986,23 @@ def handle_chat(self, body: bytes): tools = req.get("tools") prompt, images = messages_to_prompt(req.get("messages", []), tools) + # 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 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) @@ -900,16 +1012,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()) + 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()) @@ -918,6 +1029,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): @@ -926,6 +1040,52 @@ def handle_chat(self, body: bytes): log(f"Stream error: {e}") return + if stream and tools: + # True streaming WITH tools: stream text as it arrives (fast TTFT), + # then emit tool_calls delta at the end when the JSON block is complete. + self._send_stream_headers() + try: + 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()) + self.wfile.flush() + # Stream text chunks in real-time + 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()) + self.wfile.flush() + # Parse tool calls from accumulated text + clean_text, tool_calls = parse_tool_calls(full_text) + if tool_calls: + # Tool JSON block was streamed as plain text; resend cleaned + # content + tool_calls in the final delta so the client gets + # the parsed structure (and the JSON block is removed). + msg = {"role": "assistant", "content": clean_text or None, + "tool_calls": tool_calls} + else: + # No tools: text already streamed. Standard OpenAI streams + # end with delta:{} + finish_reason; using content:null can + # confuse strict clients (DSH checks content.length > 0). + msg = {} + finish = "tool_calls" if tool_calls else "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(full_text)}B tools={len(tool_calls)}") + 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, model_name) @@ -939,15 +1099,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: