上游错误码给出可执行建议;新增上下文裁剪以规避 1096
Browse files用户遇到:模型回了一句拒绝文本,紧接着 [响应在上游中断...原因: APIError: ... Unknown API
error code: 1096. This might be a temporary Google service issue.]。
查证:1096 不在 gemini-webapi 的已知码表里(只映射了 1013/1037/1050/1052/1060),走兜底
分支,而那句"可能是临时 Google 服务问题"是库自己的猜测。查资料后确认 1096 属于上游
后端/会话层面的临时故障,**常见诱因之一是上下文过长**——不是内容拒绝。
改动:
1. 新增 _ERROR_CODE_HINTS 与 describe_upstream_error(),在流式断流的提示里附上错误码对应
的处理建议(1013/1037/1050/1052/1060/1096 各有一条,未知码给兜底建议,识别不出码就
不瞎猜)。
2. 新增 MAX_CONTEXT_CHARS(默认 60000)与 trim_messages():喂给上游的上下文超限时,从
最早的消息开始丢弃,始终保留 system 消息与最后一条消息(最后一条是本次提问),裁剪时
打 warning。prepare_conversation() 先调用它。设 0 关闭。
tests/test_upstream_errors.py 新增 20 项(错误码建议、裁剪边界、接线),
tests/test_streaming_fidelity.py 增至 32 项(新增 1096 提示与诊断记录)。全套 215 项通过。
- README.md +26 -2
- main.py +84 -4
- tests/run_all.py +1 -0
- tests/test_streaming_fidelity.py +24 -1
- tests/test_upstream_errors.py +97 -0
README.md
CHANGED
|
@@ -85,6 +85,7 @@ gemini-webapi 自带的后台轮换任务会按 `refresh_interval`(默认 600s
|
|
| 85 |
| `INLINE_CODE_HINT` | `true` | 是否注入"把完整源码贴在正文代码块里"的系统指令,见第 9 节 |
|
| 86 |
| `CODE_FENCE_LANGUAGE` | `text` | 要求模型用哪个围栏语言标记。用 `text` 可避免客户端把代码渲染成预览面板(那样就看不到源码);设成空字符串则不限制 |
|
| 87 |
| `TRUNCATION_NOTICE` | `true` | 回复疑似被截断时,是否在末尾追加软提示(含"回复继续"的建议)。设 `false` 只记日志、不改正文 |
|
|
|
|
| 88 |
| `EXTRA_SYSTEM_PROMPT` | 空 | 你自己的附加系统指令,会与内置指令合并后注入 |
|
| 89 |
| `GEMINI_TIMEOUT` | `300` | 单次请求超时(秒)。生成超长 HTML/代码时报 `TimeoutError` 就调大 |
|
| 90 |
| `GEMINI_WATCHDOG_TIMEOUT` | `120` | 流式静默看门狗(秒):超过这么久没收到数据就判为僵尸流并重试。调大它能减少长内容被误判中断 |
|
|
@@ -154,7 +155,7 @@ gemini-webapi 自带的后台轮换任务会按 `refresh_interval`(默认 600s
|
|
| 154 |
|
| 155 |
`tests/` 下有七个不联网的校验脚本(用鸭子类型的假 `GeminiClient` 覆盖真实调用点;
|
| 156 |
模型相关的用例则把真实的 `GeminiClient` 注入 `_model_registry` 后走真库逻辑;
|
| 157 |
-
流式相关用例真起 uvicorn + httpx),合计
|
| 158 |
|
| 159 |
```bash
|
| 160 |
python tests/run_all.py # 一次跑完
|
|
@@ -162,8 +163,9 @@ python tests/test_session_manager.py # 35 项:凭据优先级、轮换
|
|
| 162 |
python tests/test_integration.py # 21 项:真实 FastAPI lifespan、流式/非流式、失效后自动重试
|
| 163 |
python tests/test_models.py # 34 项:/v1/models 走账号真实探测、模型解析、对外 id 生成
|
| 164 |
python tests/test_streaming.py # 11 项:测 TTFB 与分片间隔,验证真流式 + 思考下发
|
| 165 |
-
python tests/test_streaming_fidelity.py #
|
| 166 |
python tests/test_upstream_retry.py # 13 项:用真实装饰器复现「两次生成被拼接」,并用真实 GeminiClient 验证 current_retry 透传
|
|
|
|
| 167 |
python tests/test_system_prompt.py # 19 项:系统指令注入、围栏语言标记、长内容自然断点、可关闭、可追加、环境变量接线
|
| 168 |
python tests/test_admin_panel.py # 30 项:面板字段与状态接口对齐、功能开关立即生效与派生值重算
|
| 169 |
```
|
|
@@ -232,6 +234,25 @@ Variables/Secrets 里的值)——想永久生效就到 Space → Settings →
|
|
| 232 |
`TRUNCATION_NOTICE=false` 关掉(关掉后只写日志、不改正文)。
|
| 233 |
- **要求超长内容(如"写两万字")时**:单次回复本来就有上限,模型不可能一次写完。系统指令
|
| 234 |
里已要求它在自然断点停下并提示用户回复「继续」,而不是写到一半被硬截断。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
- **两份文档首尾相接 = 上游重试把两次生成拼了。** 如果回复里出现**两份完整的 HTML 文档**
|
| 236 |
(标题、配色都不同)首尾相接、第一份在中间被截断,那不是截断,是 gemini-webapi 的
|
| 237 |
`@running(retry=5)` 干的:它装饰在 `_generate`(流式生成的核心,`generate_content` 与
|
|
@@ -307,3 +328,6 @@ Variables/Secrets 里的值)——想永久生效就到 Space → Settings →
|
|
| 307 |
| 回复里只有"我创建了…"的描述、完全没有代码 | 模型把源码放进了 Canvas / Artifact 面板,本 API 拿不到——见第 9 节 |
|
| 308 |
| 代码输出到一半就断了 | 先看结尾有没有 `[代码块未闭合…]` 或 `[响应在上游中断…]`:**有**说明上游确实截断了,可调大 `GEMINI_TIMEOUT` / `GEMINI_WATCHDOG_TIMEOUT`;**没有**说明是客户端显示不全,去看客户端自己的渲染/长度限制 |
|
| 309 |
| 回复里有两份文档首尾相接(标题/配色不同) | 上游中途重试导致两次生成被拼接——已通过 `current_retry=0` 关掉库的内部重试(见第 8 节)。若仍出现,检查日志里的 `Streaming aborted after … chars` |
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
| `INLINE_CODE_HINT` | `true` | 是否注入"把完整源码贴在正文代码块里"的系统指令,见第 9 节 |
|
| 86 |
| `CODE_FENCE_LANGUAGE` | `text` | 要求模型用哪个围栏语言标记。用 `text` 可避免客户端把代码渲染成预览面板(那样就看不到源码);设成空字符串则不限制 |
|
| 87 |
| `TRUNCATION_NOTICE` | `true` | 回复疑似被截断时,是否在末尾追加软提示(含"回复继续"的建议)。设 `false` 只记日志、不改正文 |
|
| 88 |
+
| `MAX_CONTEXT_CHARS` | `60000` | 单次请求喂给上游的上下文上限(字符)。超长时从最早的对话开始丢弃(始终保留 system 消息与最后一条),避免上游返回 1096 这类模糊错误。设 `0` 关闭 |
|
| 89 |
| `EXTRA_SYSTEM_PROMPT` | 空 | 你自己的附加系统指令,会与内置指令合并后注入 |
|
| 90 |
| `GEMINI_TIMEOUT` | `300` | 单次请求超时(秒)。生成超长 HTML/代码时报 `TimeoutError` 就调大 |
|
| 91 |
| `GEMINI_WATCHDOG_TIMEOUT` | `120` | 流式静默看门狗(秒):超过这么久没收到数据就判为僵尸流并重试。调大它能减少长内容被误判中断 |
|
|
|
|
| 155 |
|
| 156 |
`tests/` 下有七个不联网的校验脚本(用鸭子类型的假 `GeminiClient` 覆盖真实调用点;
|
| 157 |
模型相关的用例则把真实的 `GeminiClient` 注入 `_model_registry` 后走真库逻辑;
|
| 158 |
+
流式相关用例真起 uvicorn + httpx),合计 215 项断言:
|
| 159 |
|
| 160 |
```bash
|
| 161 |
python tests/run_all.py # 一次跑完
|
|
|
|
| 163 |
python tests/test_integration.py # 21 项:真实 FastAPI lifespan、流式/非流式、失效后自动重试
|
| 164 |
python tests/test_models.py # 34 项:/v1/models 走账号真实探测、模型解析、对外 id 生成
|
| 165 |
python tests/test_streaming.py # 11 项:测 TTFB 与分片间隔,验证真流式 + 思考下发
|
| 166 |
+
python tests/test_streaming_fidelity.py # 32 项:长文档一字不差、断流不丢缓冲、代码块不被改写、截断判定、最近回复诊断、错误码建议
|
| 167 |
python tests/test_upstream_retry.py # 13 项:用真实装饰器复现「两次生成被拼接」,并用真实 GeminiClient 验证 current_retry 透传
|
| 168 |
+
python tests/test_upstream_errors.py # 20 项:错误码处理建议、上下文裁剪(保留 system 与最后一条、可关闭)
|
| 169 |
python tests/test_system_prompt.py # 19 项:系统指令注入、围栏语言标记、长内容自然断点、可关闭、可追加、环境变量接线
|
| 170 |
python tests/test_admin_panel.py # 30 项:面板字段与状态接口对齐、功能开关立即生效与派生值重算
|
| 171 |
```
|
|
|
|
| 234 |
`TRUNCATION_NOTICE=false` 关掉(关掉后只写日志、不改正文)。
|
| 235 |
- **要求超长内容(如"写两万字")时**:单次回复本来就有上限,模型不可能一次写完。系统指令
|
| 236 |
里已要求它在自然断点停下并提示用户回复「继续」,而不是写到一半被硬截断。
|
| 237 |
+
|
| 238 |
+
### 上游错误码与超长上下文
|
| 239 |
+
|
| 240 |
+
上游报错时,提示里会带上**错误码对应的处理建议**——不再只照抄库那句"可能是临时 Google
|
| 241 |
+
服务问题"(那是 gemini-webapi 自己的猜测,对 1096 这类码并不准确):
|
| 242 |
+
|
| 243 |
+
| 错误码 | 含义与处理 |
|
| 244 |
+
| --- | --- |
|
| 245 |
+
| `1013` | 上游随机性临时错误,稍等重试 |
|
| 246 |
+
| `1037` | 该模型配额用完,换模型或等重置 |
|
| 247 |
+
| `1050` | 会话中途换了模型导致不一致,整个会话保持同一模型 |
|
| 248 |
+
| `1052` | 模型不可用或请求结构过期,换模型 / 升级库 |
|
| 249 |
+
| `1060` | 出口 IP 被临时限制,换代理或网络 |
|
| 250 |
+
| `1096` | **后端/会话层面的临时故障**,常见诱因是**上下文过长**、会话过期、网络/地区问题。不要连续重试(可能触发限流) |
|
| 251 |
+
| 其它 | 上游未知码,稍后重试;反复出现就带上码去提 issue |
|
| 252 |
+
|
| 253 |
+
针对 1096 最常见的诱因,服务端会**主动裁剪历史**:`prepare_conversation()` 先调
|
| 254 |
+
`trim_messages()`,把上下文压到 `MAX_CONTEXT_CHARS`(默认 60000 字符)以内,从**最早**的
|
| 255 |
+
消息开始丢弃,始终保留 system 消息与最后一条消息;发生裁剪时会打 warning。
|
| 256 |
- **两份文档首尾相接 = 上游重试把两次生成拼了。** 如果回复里出现**两份完整的 HTML 文档**
|
| 257 |
(标题、配色都不同)首尾相接、第一份在中间被截断,那不是截断,是 gemini-webapi 的
|
| 258 |
`@running(retry=5)` 干的:它装饰在 `_generate`(流式生成的核心,`generate_content` 与
|
|
|
|
| 328 |
| 回复里只有"我创建了…"的描述、完全没有代码 | 模型把源码放进了 Canvas / Artifact 面板,本 API 拿不到——见第 9 节 |
|
| 329 |
| 代码输出到一半就断了 | 先看结尾有没有 `[代码块未闭合…]` 或 `[响应在上游中断…]`:**有**说明上游确实截断了,可调大 `GEMINI_TIMEOUT` / `GEMINI_WATCHDOG_TIMEOUT`;**没有**说明是客户端显示不全,去看客户端自己的渲染/长度限制 |
|
| 330 |
| 回复里有两份文档首尾相接(标题/配色不同) | 上游中途重试导致两次生成被拼接——已通过 `current_retry=0` 关掉库的内部重试(见第 8 节)。若仍出现,检查日志里的 `Streaming aborted after … chars` |
|
| 331 |
+
| 提示里出现 `错误码 1096` | 上游后端/会话层面的临时故障,常见诱因是上下文过长。已自动裁剪历史(`MAX_CONTEXT_CHARS`);可再调小,或开新对话。**不要连续重试** |
|
| 332 |
+
| 提示里出现 `错误码 1037` | 该模型配额用完,换模型或等配额重置 |
|
| 333 |
+
| 长对话后开始频繁报错 | 上下文过长,调小 `MAX_CONTEXT_CHARS`(默认 60000),或让客户端少带历史 |
|
main.py
CHANGED
|
@@ -222,6 +222,10 @@ INLINE_CODE_HINT = os.environ.get("INLINE_CODE_HINT", "true").lower() == "true"
|
|
| 222 |
EXTRA_SYSTEM_PROMPT = os.environ.get("EXTRA_SYSTEM_PROMPT", "").strip()
|
| 223 |
# 回复疑似在句子中间被截断时,是否在末尾追加一句软提示(含"回复继续"的建议)
|
| 224 |
TRUNCATION_NOTICE = os.environ.get("TRUNCATION_NOTICE", "true").lower() == "true"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
SYSTEM_PREFIX = "\n\n".join(
|
| 227 |
part for part in (DEFAULT_INLINE_CODE_HINT if INLINE_CODE_HINT else "", EXTRA_SYSTEM_PROMPT) if part
|
|
@@ -782,6 +786,44 @@ def resolve_model_for_client(client, requested_model: str):
|
|
| 782 |
return None
|
| 783 |
|
| 784 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
# Prepare conversation history from OpenAI messages format
|
| 786 |
def prepare_conversation(messages: List[Message]) -> tuple:
|
| 787 |
"""
|
|
@@ -792,6 +834,9 @@ def prepare_conversation(messages: List[Message]) -> tuple:
|
|
| 792 |
Returns:
|
| 793 |
A tuple containing the constructed conversation string and a list of paths to temporary image files.
|
| 794 |
"""
|
|
|
|
|
|
|
|
|
|
| 795 |
conversation = ""
|
| 796 |
temp_files = []
|
| 797 |
|
|
@@ -1019,6 +1064,39 @@ def record_response_stats(**kwargs) -> None:
|
|
| 1019 |
LAST_RESPONSE_STATS["at"] = int(time.time())
|
| 1020 |
|
| 1021 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1022 |
def get_image_signature(url: str) -> str:
|
| 1023 |
"""
|
| 1024 |
Generate a HMAC-SHA256 signature for the image URL using the persistent SIGNATURE_SECRET.
|
|
@@ -1306,13 +1384,15 @@ async def create_chat_completion(
|
|
| 1306 |
yield make_chunk({"content": "\n</think>\n\n"})
|
| 1307 |
|
| 1308 |
# 明确告知调用方这是被截断的回复(finish_reason=length),
|
| 1309 |
-
# 而不是伪装成正常结束。
|
| 1310 |
detail = f"{type(e).__name__}: {e}"
|
| 1311 |
if len(detail) > 200:
|
| 1312 |
detail = detail[:200] + "..."
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
|
|
|
|
|
|
|
| 1316 |
yield make_chunk({}, finish_reason="length")
|
| 1317 |
yield "data: [DONE]\n\n"
|
| 1318 |
record_response_stats(
|
|
|
|
| 222 |
EXTRA_SYSTEM_PROMPT = os.environ.get("EXTRA_SYSTEM_PROMPT", "").strip()
|
| 223 |
# 回复疑似在句子中间被截断时,是否在末尾追加一句软提示(含"回复继续"的建议)
|
| 224 |
TRUNCATION_NOTICE = os.environ.get("TRUNCATION_NOTICE", "true").lower() == "true"
|
| 225 |
+
# 单次请求喂给上游的上下文上限(字符数,0 = 不限制)。
|
| 226 |
+
# 上游对超长上下文会返回模糊错误(例如错误码 1096),所以超长时从最早的对话开始丢弃,
|
| 227 |
+
# 始终保留 system 消息与最后一条消息。长对话(比如连续续写长文)靠它避免整段请求失败。
|
| 228 |
+
MAX_CONTEXT_CHARS = int(os.environ.get("MAX_CONTEXT_CHARS", "60000"))
|
| 229 |
|
| 230 |
SYSTEM_PREFIX = "\n\n".join(
|
| 231 |
part for part in (DEFAULT_INLINE_CODE_HINT if INLINE_CODE_HINT else "", EXTRA_SYSTEM_PROMPT) if part
|
|
|
|
| 786 |
return None
|
| 787 |
|
| 788 |
|
| 789 |
+
def trim_messages(messages: List[Message]) -> List[Message]:
|
| 790 |
+
"""把喂给上游的上下文压到 MAX_CONTEXT_CHARS 以内。
|
| 791 |
+
|
| 792 |
+
上游对超长上下文会返回模糊错误(例如错误码 1096)。对话越长(比如连续续写长文)越容易
|
| 793 |
+
撞上,所以这里从**最早**的消息开始丢弃,始终保留 system 消息和最后一条消息——最后一条
|
| 794 |
+
是本次提问,丢了就没意义了。被裁剪时打一条 warning,便于排查。
|
| 795 |
+
"""
|
| 796 |
+
if MAX_CONTEXT_CHARS <= 0 or not messages:
|
| 797 |
+
return messages
|
| 798 |
+
|
| 799 |
+
systems = [m for m in messages if m.role == "system"]
|
| 800 |
+
others = [m for m in messages if m.role != "system"]
|
| 801 |
+
|
| 802 |
+
kept: List[Message] = []
|
| 803 |
+
total = sum(len(str(m.content)) for m in systems)
|
| 804 |
+
|
| 805 |
+
# 从后往前收,收不下就停;第一条(即最后一条消息)无论多大都保留
|
| 806 |
+
for message in reversed(others):
|
| 807 |
+
size = len(str(message.content))
|
| 808 |
+
if kept and total + size > MAX_CONTEXT_CHARS:
|
| 809 |
+
break
|
| 810 |
+
kept.append(message)
|
| 811 |
+
total += size
|
| 812 |
+
kept.reverse()
|
| 813 |
+
|
| 814 |
+
if len(kept) < len(others):
|
| 815 |
+
logger.warning(
|
| 816 |
+
"Context trimmed to stay under MAX_CONTEXT_CHARS: kept %s of %s non-system messages "
|
| 817 |
+
"(~%s chars, limit %s).",
|
| 818 |
+
len(kept),
|
| 819 |
+
len(others),
|
| 820 |
+
total,
|
| 821 |
+
MAX_CONTEXT_CHARS,
|
| 822 |
+
)
|
| 823 |
+
|
| 824 |
+
return systems + kept
|
| 825 |
+
|
| 826 |
+
|
| 827 |
# Prepare conversation history from OpenAI messages format
|
| 828 |
def prepare_conversation(messages: List[Message]) -> tuple:
|
| 829 |
"""
|
|
|
|
| 834 |
Returns:
|
| 835 |
A tuple containing the constructed conversation string and a list of paths to temporary image files.
|
| 836 |
"""
|
| 837 |
+
# 先按上限裁剪历史,避免超长上下文让上游返回模糊错误(如 1096)
|
| 838 |
+
messages = trim_messages(messages)
|
| 839 |
+
|
| 840 |
conversation = ""
|
| 841 |
temp_files = []
|
| 842 |
|
|
|
|
| 1064 |
LAST_RESPONSE_STATS["at"] = int(time.time())
|
| 1065 |
|
| 1066 |
|
| 1067 |
+
# 上游错误码 -> 可执行的建议。gemini-webapi 只映射了其中一部分,其余会走兜底分支并附上
|
| 1068 |
+
# 一句"可能是临时 Google 服务问题"——那是库自己的猜测,对 1096 这类码并不准确,所以这里
|
| 1069 |
+
# 自己补一张表,把"怎么办"直接告诉调用方。
|
| 1070 |
+
_ERROR_CODE_HINTS = {
|
| 1071 |
+
"1013": "上游随机性的临时错误,稍等片刻重试通常就好。",
|
| 1072 |
+
"1037": "该模型的用量配额已用完。换一个模型(如 Flash),或等配额重置。",
|
| 1073 |
+
"1050": "同一个会话里换了模型,导致模型与历史不一致。整个会话请保持用同一个模型。",
|
| 1074 |
+
"1052": "模型当前不可用,或请求结构已过期。可以换模型,或升级 gemini-webapi。",
|
| 1075 |
+
"1060": "你的出口 IP 被 Google 临时限制。换代理/网络,或过一段时间再试。",
|
| 1076 |
+
"1096": "上游后端/会话层面的临时故障,常见诱因是上下文过长、会话过期或网络/地区问题。"
|
| 1077 |
+
"建议:开新对话或缩短历史(可调小 MAX_CONTEXT_CHARS);不要连续重试,可能触发限流。",
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
_ERROR_CODE_RE = re.compile(r"code:\s*(\d+)")
|
| 1081 |
+
|
| 1082 |
+
|
| 1083 |
+
def describe_upstream_error(error_text: str) -> str:
|
| 1084 |
+
"""把上游错误文本转成一句可执行的建议;识别不出错误码时返回空串。"""
|
| 1085 |
+
match = _ERROR_CODE_RE.search(error_text or "")
|
| 1086 |
+
if not match:
|
| 1087 |
+
return ""
|
| 1088 |
+
|
| 1089 |
+
code = match.group(1)
|
| 1090 |
+
hint = _ERROR_CODE_HINTS.get(code)
|
| 1091 |
+
if hint:
|
| 1092 |
+
return f"错误码 {code}:{hint}"
|
| 1093 |
+
|
| 1094 |
+
return (
|
| 1095 |
+
f"错误码 {code}:这是上游的未知错误码(gemini-webapi 未收录)。"
|
| 1096 |
+
"可稍后重试;若反复出现,带上这个码去 gemini-webapi 提 issue。"
|
| 1097 |
+
)
|
| 1098 |
+
|
| 1099 |
+
|
| 1100 |
def get_image_signature(url: str) -> str:
|
| 1101 |
"""
|
| 1102 |
Generate a HMAC-SHA256 signature for the image URL using the persistent SIGNATURE_SECRET.
|
|
|
|
| 1384 |
yield make_chunk({"content": "\n</think>\n\n"})
|
| 1385 |
|
| 1386 |
# 明确告知调用方这是被截断的回复(finish_reason=length),
|
| 1387 |
+
# 而不是伪装成正常结束。识别得出错误码时再补一句"怎么办"。
|
| 1388 |
detail = f"{type(e).__name__}: {e}"
|
| 1389 |
if len(detail) > 200:
|
| 1390 |
detail = detail[:200] + "..."
|
| 1391 |
+
hint = describe_upstream_error(detail)
|
| 1392 |
+
notice = f"\n\n[响应在上游中断,以上内容可能不完整。原因: {detail}"
|
| 1393 |
+
if hint:
|
| 1394 |
+
notice += f"。{hint}"
|
| 1395 |
+
yield make_chunk({"content": notice + "]"})
|
| 1396 |
yield make_chunk({}, finish_reason="length")
|
| 1397 |
yield "data: [DONE]\n\n"
|
| 1398 |
record_response_stats(
|
tests/run_all.py
CHANGED
|
@@ -17,6 +17,7 @@ TESTS = [
|
|
| 17 |
("test_streaming.py", "流式下发与思考链路计时验证"),
|
| 18 |
("test_streaming_fidelity.py", "流式内容保真度验证(长文档 / 断流 / 代码块)"),
|
| 19 |
("test_upstream_retry.py", "上游重试行为验证(防止两次生成被拼接)"),
|
|
|
|
| 20 |
("test_system_prompt.py", "系统指令注入验证(防止代码被丢进 Artifact)"),
|
| 21 |
("test_admin_panel.py", "管理面板与状态接口一致性验证"),
|
| 22 |
]
|
|
|
|
| 17 |
("test_streaming.py", "流式下发与思考链路计时验证"),
|
| 18 |
("test_streaming_fidelity.py", "流式内容保真度验证(长文档 / 断流 / 代码块)"),
|
| 19 |
("test_upstream_retry.py", "上游重试行为验证(防止两次生成被拼接)"),
|
| 20 |
+
("test_upstream_errors.py", "上游错误码建议与上下文裁剪验证"),
|
| 21 |
("test_system_prompt.py", "系统指令注入验证(防止代码被丢进 Artifact)"),
|
| 22 |
("test_admin_panel.py", "管理面板与状态接口一致性验证"),
|
| 23 |
]
|
tests/test_streaming_fidelity.py
CHANGED
|
@@ -80,6 +80,7 @@ HTML_DOC = """<!DOCTYPE html>
|
|
| 80 |
CHUNK_SIZE = 7
|
| 81 |
FAIL_AFTER = None # 设成整数则在上游吐了这么多分片后抛错
|
| 82 |
FAIL_AT_END = False # True 表示吐完全部分片后再抛错(模拟收尾前突然断流)
|
|
|
|
| 83 |
ACTIVE_DOC = None # None 表示用 HTML_DOC;否则用这个自定义内容
|
| 84 |
|
| 85 |
# 正文里有需要被还原的转义 HTML,代码块里也有——后者必须原样保留
|
|
@@ -161,7 +162,7 @@ class FakeClient:
|
|
| 161 |
yield FakeChunk(text_delta=piece)
|
| 162 |
|
| 163 |
if FAIL_AT_END:
|
| 164 |
-
raise RuntimeError("upstream stream broke right before finishing")
|
| 165 |
|
| 166 |
async def fetch_latest_chat_response(self, cid):
|
| 167 |
return FakeResponse()
|
|
@@ -363,6 +364,28 @@ def main():
|
|
| 363 |
check("29 记录了 finish_reason", stats.get("finish_reason") == "stop", str(stats.get("finish_reason")))
|
| 364 |
check("30 记录了模型名与时间戳", stats.get("model") == "gemini-3.8-flash" and isinstance(stats.get("at"), int), str(stats))
|
| 365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
server.should_exit = True
|
| 367 |
time.sleep(0.5)
|
| 368 |
|
|
|
|
| 80 |
CHUNK_SIZE = 7
|
| 81 |
FAIL_AFTER = None # 设成整数则在上游吐了这么多分片后抛错
|
| 82 |
FAIL_AT_END = False # True 表示吐完全部分片后再抛错(模拟收尾前突然断流)
|
| 83 |
+
FAIL_EXCEPTION = None # 设置后用这个异常替换默认的 RuntimeError
|
| 84 |
ACTIVE_DOC = None # None 表示用 HTML_DOC;否则用这个自定义内容
|
| 85 |
|
| 86 |
# 正文里有需要被还原的转义 HTML,代码块里也有——后者必须原样保留
|
|
|
|
| 162 |
yield FakeChunk(text_delta=piece)
|
| 163 |
|
| 164 |
if FAIL_AT_END:
|
| 165 |
+
raise FAIL_EXCEPTION or RuntimeError("upstream stream broke right before finishing")
|
| 166 |
|
| 167 |
async def fetch_latest_chat_response(self, cid):
|
| 168 |
return FakeResponse()
|
|
|
|
| 364 |
check("29 记录了 finish_reason", stats.get("finish_reason") == "stop", str(stats.get("finish_reason")))
|
| 365 |
check("30 记录了模型名与时间戳", stats.get("model") == "gemini-3.8-flash" and isinstance(stats.get("at"), int), str(stats))
|
| 366 |
|
| 367 |
+
# ---------------------------------------------------------------- 上游错误码 -> 处理建议
|
| 368 |
+
# 上游返回 1096 时,提示里要带上"怎么办",而不是只照抄库那句不准确的猜测
|
| 369 |
+
from gemini_webapi import APIError
|
| 370 |
+
|
| 371 |
+
global FAIL_EXCEPTION
|
| 372 |
+
ACTIVE_DOC = "短回复"
|
| 373 |
+
FAIL_EXCEPTION = APIError(
|
| 374 |
+
"Failed to generate contents (stream). Unknown API error code: 1096. "
|
| 375 |
+
"This might be a temporary Google service issue."
|
| 376 |
+
)
|
| 377 |
+
FAIL_AT_END = True
|
| 378 |
+
content_1096, _, _ = collect_stream(base, payload)
|
| 379 |
+
ACTIVE_DOC = None
|
| 380 |
+
FAIL_AT_END = False
|
| 381 |
+
FAIL_EXCEPTION = None
|
| 382 |
+
check(
|
| 383 |
+
"31 错误提示里带上了 1096 的处理建议",
|
| 384 |
+
"错误码 1096" in content_1096 and "上下文过长" in content_1096,
|
| 385 |
+
repr(content_1096[-260:]),
|
| 386 |
+
)
|
| 387 |
+
check("32 同时记录了错误到诊断信息", "1096" in str(app_main.LAST_RESPONSE_STATS.get("error", "")), str(app_main.LAST_RESPONSE_STATS.get("error"))[:120])
|
| 388 |
+
|
| 389 |
server.should_exit = True
|
| 390 |
time.sleep(0.5)
|
| 391 |
|
tests/test_upstream_errors.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""上游错误码建议与上下文裁剪的验证。
|
| 2 |
+
|
| 3 |
+
背景:上游对超长上下文会返回模糊错误(例如错误码 1096)。gemini-webapi 只映射了
|
| 4 |
+
1013/1037/1050/1052/1060,其余走兜底分支并附一句"可能是临时 Google 服务问题"——那是库
|
| 5 |
+
自己的猜测,对 1096 并不准确(1096 属于后端/会话层面的临时故障,常见诱因是上下文过长)。
|
| 6 |
+
所以本服务自己补了一张"怎么办"的表,并在超长时裁剪历史。
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import tempfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 15 |
+
sys.path.insert(0, str(ROOT))
|
| 16 |
+
|
| 17 |
+
os.environ["SECURE_1PSID"] = "PSID-ERR"
|
| 18 |
+
os.environ["SECURE_1PSIDTS"] = "TS-ERR"
|
| 19 |
+
os.environ["GEMINI_COOKIE_PATH"] = tempfile.mkdtemp(prefix="gs-err-")
|
| 20 |
+
|
| 21 |
+
import main as app_main # noqa: E402
|
| 22 |
+
|
| 23 |
+
PASS, FAIL = [], []
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def check(name, cond, extra=""):
|
| 27 |
+
(PASS if cond else FAIL).append(name)
|
| 28 |
+
print(("[OK] " if cond else "[FAIL] ") + name + (f" {extra}" if extra and not cond else ""))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main():
|
| 32 |
+
# ---------------------------------------------------------------- 错误码建议
|
| 33 |
+
code_1096 = (
|
| 34 |
+
"APIError: Failed to generate contents (stream). Unknown API error code: 1096. "
|
| 35 |
+
"This might be a temporary Google service issue."
|
| 36 |
+
)
|
| 37 |
+
hint = app_main.describe_upstream_error(code_1096)
|
| 38 |
+
check("1 认得 1096", "错误码 1096" in hint, hint)
|
| 39 |
+
check("2 并给出可执行建议(上下文/新对话)", "上下文过长" in hint and "新对话" in hint, hint)
|
| 40 |
+
check("3 明确提示不要连续重试", "不要连续重试" in hint, hint)
|
| 41 |
+
|
| 42 |
+
check("4 认得 1037(配额)", "配额" in app_main.describe_upstream_error("error code: 1037"))
|
| 43 |
+
check("5 认得 1060(IP 被限)", "IP" in app_main.describe_upstream_error("error code: 1060"))
|
| 44 |
+
check("6 认得 1050(换模型)", "模型" in app_main.describe_upstream_error("error code: 1050"))
|
| 45 |
+
|
| 46 |
+
unknown = app_main.describe_upstream_error("APIError: ... error code: 9999 ...")
|
| 47 |
+
check("7 未知错误码给出兜底建议", "9999" in unknown and "issue" in unknown, unknown)
|
| 48 |
+
check("8 没有错误码时不瞎猜", app_main.describe_upstream_error("UsageLimitExceededError: 配额用完") == "")
|
| 49 |
+
check("9 空输入不报错", app_main.describe_upstream_error("") == "")
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------- 上下文裁剪
|
| 52 |
+
check("10 默认上限为正数", app_main.MAX_CONTEXT_CHARS > 0, str(app_main.MAX_CONTEXT_CHARS))
|
| 53 |
+
|
| 54 |
+
system = app_main.Message(role="system", content="S" * 100)
|
| 55 |
+
history = [app_main.Message(role="user", content="X" * 1000) for _ in range(100)]
|
| 56 |
+
kept = app_main.trim_messages([system, *history])
|
| 57 |
+
check("11 超长时确实裁剪了", len(kept) < 101, str(len(kept)))
|
| 58 |
+
check("12 system 消息始终保留", kept[0].role == "system", kept[0].role)
|
| 59 |
+
check("13 最后一条消息始终保留", kept[-1] is history[-1])
|
| 60 |
+
check("14 裁剪后总量不超上限(最后一条例外)", sum(len(str(m.content)) for m in kept) <= app_main.MAX_CONTEXT_CHARS + 1000)
|
| 61 |
+
|
| 62 |
+
short = [app_main.Message(role="user", content="hi")]
|
| 63 |
+
check("15 短对话原样返回", app_main.trim_messages(short) == short)
|
| 64 |
+
check("16 空列表原样返回", app_main.trim_messages([]) == [])
|
| 65 |
+
|
| 66 |
+
# 单条就超限时也要保留(否则请求没意义)
|
| 67 |
+
huge = [app_main.Message(role="user", content="Y" * (app_main.MAX_CONTEXT_CHARS * 2))]
|
| 68 |
+
check("17 单条超限也保留", len(app_main.trim_messages(huge)) == 1)
|
| 69 |
+
|
| 70 |
+
# 关掉上限时不裁剪
|
| 71 |
+
saved = app_main.MAX_CONTEXT_CHARS
|
| 72 |
+
try:
|
| 73 |
+
app_main.MAX_CONTEXT_CHARS = 0
|
| 74 |
+
check("18 上限为 0 时不裁剪", len(app_main.trim_messages([system, *history])) == 101)
|
| 75 |
+
finally:
|
| 76 |
+
app_main.MAX_CONTEXT_CHARS = saved
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------- 接线:prepare_conversation 会裁剪
|
| 79 |
+
saved = app_main.MAX_CONTEXT_CHARS
|
| 80 |
+
try:
|
| 81 |
+
app_main.MAX_CONTEXT_CHARS = 2000
|
| 82 |
+
conversation, _ = app_main.prepare_conversation([system, *history])
|
| 83 |
+
check("19 prepare_conversation 应用了裁剪", len(conversation) < 100000, str(len(conversation)))
|
| 84 |
+
check("20 系统指令仍在最前面", conversation.startswith("System: "), repr(conversation[:30]))
|
| 85 |
+
finally:
|
| 86 |
+
app_main.MAX_CONTEXT_CHARS = saved
|
| 87 |
+
|
| 88 |
+
print(f"\n通过 {len(PASS)} / {len(PASS) + len(FAIL)}")
|
| 89 |
+
if FAIL:
|
| 90 |
+
print("失败项:")
|
| 91 |
+
for name in FAIL:
|
| 92 |
+
print(" -", name)
|
| 93 |
+
return 1 if FAIL else 0
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
sys.exit(main())
|