feat: streaming passthrough for stream=True + first-byte timeout (30s) — distinguish slow upstream (data flowing) vs hung key (no first byte); fix 180s cutoff killing legit 3-8min requests
This commit is contained in:
@@ -20,7 +20,7 @@ OpenAI 兼容代理,背后池化 6 个 OpenCode Go API key。
|
|||||||
└─ dashboard (:5803) ◄── /api/status
|
└─ dashboard (:5803) ◄── /api/status
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os, sys, json, time, logging, threading
|
import os, sys, json, time, logging, threading, socket
|
||||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||||
from urllib.request import Request, urlopen, HTTPError
|
from urllib.request import Request, urlopen, HTTPError
|
||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
@@ -51,6 +51,9 @@ MAX_RETRIES = 6
|
|||||||
# 单次上游请求保底超时(秒):上游处理大请求(1MB+)可能要 60-90s+,
|
# 单次上游请求保底超时(秒):上游处理大请求(1MB+)可能要 60-90s+,
|
||||||
# 故设宽超时兜底(防 TCP 挂死无限等),正常靠错误分类快速换 key,不依赖此超时
|
# 故设宽超时兜底(防 TCP 挂死无限等),正常靠错误分类快速换 key,不依赖此超时
|
||||||
UPSTREAM_REQUEST_TIMEOUT = 180
|
UPSTREAM_REQUEST_TIMEOUT = 180
|
||||||
|
# 首字节超时(秒):上游连接建立后,30s 内必须返回第一个字节,否则判定该 key 挂起,换下一个 key。
|
||||||
|
# 这是"区分上游慢 vs 挂"的关键——有数据流=在正常生成,不该中断;无响应=key 有问题,快速切换。
|
||||||
|
FIRST_BYTE_TIMEOUT = 30
|
||||||
|
|
||||||
# ── 日志 ────────────────────────────────────────────────────
|
# ── 日志 ────────────────────────────────────────────────────
|
||||||
LOG_DIR = _GATEWAY_DIR / "logs"
|
LOG_DIR = _GATEWAY_DIR / "logs"
|
||||||
@@ -633,7 +636,12 @@ class RouterHandler(BaseHTTPRequestHandler):
|
|||||||
self._error_response(502, f"All OCG keys exhausted: {last_error}")
|
self._error_response(502, f"All OCG keys exhausted: {last_error}")
|
||||||
|
|
||||||
def _forward_request(self, api_key, body, is_stream):
|
def _forward_request(self, api_key, body, is_stream):
|
||||||
"""转发单次请求到指定 key。返回 {ok, status, error}。"""
|
"""转发单次请求到指定 key。返回 {ok, status, error}。
|
||||||
|
|
||||||
|
stream=True:流式透传——边收上游边发客户端,客户端尽早拿到首 token。
|
||||||
|
stream=False:完整读取 + 空输出检测(需要完整 JSON)。
|
||||||
|
两者都做首字节超时(FIRST_BYTE_TIMEOUT):连接建立后 30s 内无响应 = key 挂起,换 key。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
req = Request(
|
req = Request(
|
||||||
f"{OPENCODE_BASE}/chat/completions",
|
f"{OPENCODE_BASE}/chat/completions",
|
||||||
@@ -651,10 +659,43 @@ class RouterHandler(BaseHTTPRequestHandler):
|
|||||||
err_body = resp.read().decode("utf-8", errors="replace")[:500]
|
err_body = resp.read().decode("utf-8", errors="replace")[:500]
|
||||||
return {"ok": False, "status": status, "error": f"HTTP {status}: {err_body}"}
|
return {"ok": False, "status": status, "error": f"HTTP {status}: {err_body}"}
|
||||||
|
|
||||||
# 透传响应 + 空输出检测(2026-08-14 老莫:空输出自动换 key)
|
# 首字节超时:底层 socket 设 FIRST_BYTE_TIMEOUT,
|
||||||
resp_body = resp.read()
|
# 30s 内读不到第一个字节 → 判定 key 挂起(socket.timeout 会被捕获并换 key)
|
||||||
if not is_stream:
|
try:
|
||||||
# 非流式:解析 JSON 检测空输出
|
sock = resp.fp.raw._sock
|
||||||
|
sock.settimeout(FIRST_BYTE_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
pass # 拿不到 socket 就算了,靠总超时兜底
|
||||||
|
|
||||||
|
if is_stream:
|
||||||
|
# 流式透传:边收边发,客户端尽早拿到首 token
|
||||||
|
# 一旦开始有数据流(上游在生成),后续读取不再受首字节超时限制
|
||||||
|
try:
|
||||||
|
self.send_response(status)
|
||||||
|
for k, v in resp.headers.items():
|
||||||
|
if k.lower() in ("content-type", "access-control-allow-origin"):
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
while True:
|
||||||
|
chunk = resp.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
sock.settimeout(None) # 已有数据流,解除首字节超时
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
self.wfile.flush()
|
||||||
|
return {"ok": True}
|
||||||
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||||
|
# 客户端中途断开:数据已发一部分,不算 key 问题
|
||||||
|
log.info("client disconnected during streaming via %s", api_key[:8])
|
||||||
|
return {"ok": True} # 响应已开始,视为成功(客户端自行处理断流)
|
||||||
|
else:
|
||||||
|
# 非流式:完整读取 + 空输出检测
|
||||||
|
resp_body = resp.read()
|
||||||
try:
|
try:
|
||||||
import json as _json
|
import json as _json
|
||||||
d = _json.loads(resp_body.decode("utf-8", errors="replace"))
|
d = _json.loads(resp_body.decode("utf-8", errors="replace"))
|
||||||
@@ -668,15 +709,14 @@ class RouterHandler(BaseHTTPRequestHandler):
|
|||||||
has_reasoning = bool(reasoning and str(reasoning).strip())
|
has_reasoning = bool(reasoning and str(reasoning).strip())
|
||||||
if (not content or not str(content).strip()) and not has_reasoning:
|
if (not content or not str(content).strip()) and not has_reasoning:
|
||||||
if finish_reason == "length":
|
if finish_reason == "length":
|
||||||
# max_tokens 截断,content 可能为空但属于正常响应
|
|
||||||
log.warning("content empty but finish_reason=length (truncated), passing through")
|
log.warning("content empty but finish_reason=length (truncated), passing through")
|
||||||
else:
|
else:
|
||||||
log.warning("empty content detected via key, retrying next key")
|
log.warning("empty content detected via key, retrying next key")
|
||||||
return {"ok": False, "status": 0, "error": "empty_content"}
|
return {"ok": False, "status": 0, "error": "empty_content"}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # 解析失败不阻断(正常透传)
|
pass # 解析失败不阻断(正常透传)
|
||||||
self._send_response(status, dict(resp.headers), resp_body)
|
self._send_response(status, dict(resp.headers), resp_body)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
err_body = e.read().decode("utf-8", errors="replace")[:500]
|
err_body = e.read().decode("utf-8", errors="replace")[:500]
|
||||||
@@ -684,6 +724,9 @@ class RouterHandler(BaseHTTPRequestHandler):
|
|||||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as e:
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as e:
|
||||||
# 客户端断开:不是 key 问题,标记特殊错误码让调用方不重试不冷却
|
# 客户端断开:不是 key 问题,标记特殊错误码让调用方不重试不冷却
|
||||||
return {"ok": False, "status": -1, "error": f"client_disconnected: {e}"}
|
return {"ok": False, "status": -1, "error": f"client_disconnected: {e}"}
|
||||||
|
except (TimeoutError, socket.timeout) as e:
|
||||||
|
# 首字节超时或总超时:key 挂起,换下一个 key 重试(不冷却)
|
||||||
|
return {"ok": False, "status": 0, "error": f"upstream_timeout: {e}"}
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
return {"ok": False, "status": 0, "error": f"URLError: {e.reason}"}
|
return {"ok": False, "status": 0, "error": f"URLError: {e.reason}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user