Coverage for merco/core/llm/error_ui.py: 95%
86 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
1"""LLM error UI helpers — classification, rendering, retry feedback.
3Responsibility: what errors look like. NOT when to show them (callers decide).
4Zero side effects: no logging, no openai import (duck-typing only).
5"""
6from __future__ import annotations
8import re
9from contextlib import asynccontextmanager
10from dataclasses import dataclass
11from typing import AsyncIterator
14_SENSITIVE_KEYWORDS = ("api_key", "token", "secret", "authorization", "bearer")
17@dataclass
18class ErrorInfo:
19 """Classified error metadata."""
20 label: str # Short label e.g. "认证失败", "服务端错误 (502)"
21 hint: str # One-line user-facing hint
22 exc: Exception # Original exception
25def classify_error(exc: Exception) -> ErrorInfo:
26 """Classify an exception into ErrorInfo using duck-typing.
28 Reads ``exc.status_code`` if present; falls back to scanning str(exc) and
29 the exception class name. Does NOT import openai.
30 """
31 status = getattr(exc, "status_code", None)
32 body = str(exc).lower()
33 name = type(exc).__name__
35 # 401 / unauthorized / invalid/expired api key
36 if (
37 status == 401
38 or "unauthorized" in body
39 or "authentication" in body
40 or ("api key" in body and ("invalid" in body or "expired" in body))
41 ):
42 return ErrorInfo(label="认证失败", hint="API Key 无效或已过期,请检查配置。", exc=exc)
44 # 403 / forbidden / permission
45 if (
46 status == 403
47 or "forbidden" in body
48 or "permission" in body
49 or "access denied" in body
50 ):
51 return ErrorInfo(label="权限不足", hint="账户无权限访问该资源。", exc=exc)
53 # 404 / model not found
54 if (
55 status == 404
56 or ("not found" in body and "model" in body)
57 or "model not exist" in body
58 ):
59 return ErrorInfo(label="模型/接口不存在", hint="模型或接口不存在,检查模型名和 base_url。", exc=exc)
61 # 413 / context length
62 if (
63 status == 413
64 or "context length" in body
65 or "maximum context" in body
66 or "prompt too long" in body
67 or "too long" in body
68 ):
69 return ErrorInfo(label="请求过长", hint="上下文超过模型上限,正在压缩后重试…", exc=exc)
71 # 429 / rate limit
72 if (
73 status == 429
74 or "rate limit" in body
75 or "too many requests" in body
76 ):
77 return ErrorInfo(label="请求限流", hint="API 限流,稍后重试…", exc=exc)
79 # 5xx server errors
80 if isinstance(status, int) and 500 <= status <= 599:
81 return ErrorInfo(label=f"服务端错误 ({status})", hint="API 服务器异常,稍后重试…", exc=exc)
83 # 408 timeout
84 if isinstance(status, int) and status == 408:
85 return ErrorInfo(label="请求超时", hint="API 请求超时,稍后重试…", exc=exc)
87 # Timeout by class name or body
88 if "timeout" in name.lower() or "timeout" in body or "timed out" in body:
89 return ErrorInfo(label="请求超时", hint="API 响应超时,稍后重试…", exc=exc)
91 # Connection errors
92 if "connection" in name.lower() or "connect" in body or "network" in body:
93 return ErrorInfo(label="连接错误", hint="无法连接到 API 服务器,请检查网络或 base_url。", exc=exc)
95 # Other 4xx client errors
96 if isinstance(status, int) and 400 <= status < 500:
97 return ErrorInfo(label=f"请求错误 ({status})", hint="请求被服务端拒绝,稍后重试…", exc=exc)
99 # Fallback: class name or generic label
100 label = name or "调用失败"
101 return ErrorInfo(label=label, hint="API 调用失败,稍后重试…", exc=exc)
104def sanitize_message(exc: Exception, max_len: int = 300) -> str:
105 """Redact sensitive keywords (api_key/token/secret/authorization/bearer)
106 and truncate to max_len characters (appending '…' if truncated)."""
107 msg = str(exc)
109 for kw in _SENSITIVE_KEYWORDS:
110 if re.search(r'(?i)\b' + re.escape(kw) + r'\b', msg):
111 return "(包含敏感信息,已脱敏)"
113 if len(msg) > max_len:
114 return msg[:max_len].rstrip() + "…"
116 return msg
119def build_error_panel(info: ErrorInfo):
120 """Return a Rich Panel for an error. Red border, title '⚠ API 错误'.
122 Layout:
123 ❌ <label> (bold red)
124 <hint> (red)
125 <blank line>
126 <sanitized detail> (dim)
127 """
128 from rich.panel import Panel
129 from rich.text import Text
130 detail = sanitize_message(info.exc)
131 text = Text.from_markup(
132 f"[bold red]❌ {info.label}[/bold red]\n"
133 f"[red]{info.hint}[/red]\n\n"
134 f"[dim]{detail}[/dim]"
135 )
136 return Panel(
137 text, border_style="red",
138 title="⚠ API 错误", title_align="left", padding=(0, 1),
139 )
142def build_retry_line(info: ErrorInfo, attempt: int, max_attempts: int,
143 actions: list[str]) -> str:
144 """Return a yellow one-line retry status string.
146 Example: "↻ API 请求限流(第 1/3 次)— 等待 3s + 压缩上下文…"
147 """
148 action_str = " + ".join(actions) if actions else "立即重试"
149 return (f"[yellow]↻ API {info.label}(第 {attempt}/{max_attempts} 次)"
150 f"— {action_str}…[/yellow]")
153@asynccontextmanager
154async def retry_spinner(label: str, seconds: float, console):
155 """Async context manager showing a transient spinner during a wait.
157 Usage::
159 async with retry_spinner("请求限流", 3.0, console):
160 await asyncio.sleep(3.0)
162 The spinner displays e.g. "⠋ 等待 2.3s 冷却中…" in yellow, updates at 8fps,
163 and is transient (disappears on exit). If ``seconds <= 1`` it's a no-op
164 (the wait is too short to justify a spinner).
165 """
166 import asyncio
167 import itertools
168 from rich.live import Live
169 from rich.text import Text
171 if seconds <= 1:
172 yield
173 return
175 spinner = itertools.cycle("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
177 async def _tick(live, total):
178 waited = 0.0
179 try:
180 while waited < total:
181 await asyncio.sleep(0.1)
182 waited += 0.1
183 remain = max(0.0, total - waited)
184 live.update(Text.from_markup(
185 f"[yellow]{next(spinner)} 等待 {remain:.1f}s 冷却中…[/yellow]"
186 ))
187 except asyncio.CancelledError:
188 pass
190 with Live(Text.from_markup(f"[yellow]{next(spinner)} 等待 {seconds:.1f}s 冷却中…[/yellow]"),
191 console=console, refresh_per_second=8, transient=True) as live:
192 ticker = asyncio.create_task(_tick(live, seconds))
193 try:
194 yield
195 finally:
196 ticker.cancel()
197 try:
198 await ticker
199 except asyncio.CancelledError:
200 pass
203def error_message(info: ErrorInfo) -> str:
204 """Return a Rich-markup string starting with "❌ " for the final error.
206 Used as the agent's return value on terminal failure; the REPL detects
207 startswith("❌") and wraps it in a red Panel via Text.from_markup.
208 """
209 detail = sanitize_message(info.exc)
210 return (f"❌ [bold red]{info.label}[/bold red]:[red]{info.hint}[/red]\n"
211 f"[dim]{detail}[/dim]")