longxing commited on
Commit
5ae00c0
·
1 Parent(s): 840a690

上游错误码给出可执行建议;新增上下文裁剪以规避 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 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),合计 193 项断言:
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 # 30 项:长文档一字不差、断流不丢缓冲、代码块不被改写、围栏未闭合与散文中途收尾判定截断、最近回复诊断
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
- yield make_chunk(
1314
- {"content": f"\n\n[响应在上游中断,以上内容可能不完整。原因: {detail}]"}
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())