-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopencode_errors.py
More file actions
285 lines (244 loc) · 14.9 KB
/
Copy pathopencode_errors.py
File metadata and controls
285 lines (244 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
"""Классификация ошибок провайдера и санитайзинг причин (issue #53).
Выделено из opencode_runtime.py: распознавание лимитов/аккаунт-ошибок провайдера,
скрабинг секретов/PII для публичного отчёта, чтение «хвоста» ошибок из логов
opencode. Листовой модуль — зависит только от stdlib (importирует ничего из
runtime/session/process), поэтому его свободно тянут остальные модули.
"""
import json
import re
import sys
from pathlib import Path
OPENCODE_LOG_DIR = Path.home() / ".local" / "share" / "opencode" / "log"
_PROVIDER_RETRYABLE_LIMIT_ERROR_MARKERS = (
"http 429",
"too many requests",
"rate limit",
"rate_limit",
"usage limit",
"quota",
)
_PROVIDER_PERMANENT_ACCOUNT_ERROR_MARKERS = (
"requires a subscription",
"upgrade for access",
"upgrade for higher limits",
"insufficient credit",
"insufficient credits",
"billing",
"payment method",
)
_PROVIDER_LIMIT_ERROR_MARKERS = (
_PROVIDER_RETRYABLE_LIMIT_ERROR_MARKERS
+ _PROVIDER_PERMANENT_ACCOUNT_ERROR_MARKERS
)
# issue #124: POST /message не ответил (ReadTimeout), сессия закрылась по idle, и
# копия НЕ оставила файла модели — ответа провайдера не было вовсе. Единственный
# источник правды для текста причины: ставит его benchmark_report.run_copy (только
# там известен work_dir, т.е. факт наличия результата), а public_reason ниже
# распознаёт как отдельную категорию — иначе причина свелась бы к общему «ошибка
# провайдера» и диагностика первопричины пустого успеха не дошла бы до дашборда.
HUNG_POST_REASON = ("зависший POST /message: ответа провайдера не было, "
"сессия закрылась без результата")
# issue #161: OpenCode закончил assistant turn по finish=length. Это не сетевой
# сбой и не «провайдер не ответил»: usage уже получен, но внутренний per-step
# budget OpenCode исчерпан до tool call/финального результата.
OUTPUT_LENGTH_REASON = "лимит ответа OpenCode исчерпан"
# Опциональный watchdog для unattended-прогонов. Категория локальная и безопасна
# для публичного отчёта; числовой порог добавляется к ней в opencode_session.
FIRST_ACTION_TIMEOUT_REASON = "агент не начал действие до first-action timeout"
# issue #158: Python-клиент бенчмарка обращается к локальному opencode serve,
# поэтому транспортная ошибка POST/SSE достоверно означает потерю локального
# канала к serve. Причину внешнего обрыва (интернет, crash serve, firewall) сам
# httpx определить не может; подробность остаётся в приватном run.log.
NETWORK_ERROR_REASON = "локальный обрыв сети: потеряна связь с opencode serve"
# issue #170: ZAI 429 code 1311 — модель не входит в подписку провайдера.
# Permanent (не retryable), НЕ путать с quota (code=3) или account-error.
MODEL_NOT_IN_PLAN_REASON = "модель не в подписке"
# Маркеры ZAI code 1311: текст ошибки + числовой код.
# Не дублируем существующее "requires a subscription" из
# _PROVIDER_PERMANENT_ACCOUNT_ERROR_MARKERS — ZAI шлёт другой текст.
_MODEL_NOT_IN_PLAN_MARKERS = (
"does not yet include access to",
"does not include access to",
)
_MODEL_NOT_IN_PLAN_CODE = "1311"
# Шаблоны секрето-/PII-подобных фрагментов, которые нельзя выпускать в публичный
# отчёт. Полный текст причины при этом остаётся в приватном run.log.
_SECRET_PATTERNS = (
re.compile(r"[Bb]earer\s+\S+"), # Bearer <token>
re.compile(r"\b(?:sk|key|pk|tok|ghp|xoxb)[-_][A-Za-z0-9\-_]{6,}"), # api keys
re.compile(r"\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b"), # email
re.compile(r"https?://\S+"), # URL (могут нести query/токены)
re.compile(r"\b[A-Za-z0-9_\-]{20,}\b"), # длинные токено-подобные строки
)
_LOCAL_REASON_PREFIXES = (
"сбой ",
"opencode serve не поднялся",
)
def _is_provider_limit_error(text: str) -> bool:
lowered = text.lower()
return any(marker in lowered for marker in _PROVIDER_LIMIT_ERROR_MARKERS)
def _is_retryable_limit_error(text: str) -> bool:
lowered = text.lower()
return any(marker in lowered for marker in _PROVIDER_RETRYABLE_LIMIT_ERROR_MARKERS)
def _is_account_error(text: str) -> bool:
lowered = text.lower()
return any(m in lowered for m in _PROVIDER_PERMANENT_ACCOUNT_ERROR_MARKERS)
def _is_model_not_in_plan(text: str) -> bool:
"""ZAI code 1311: модель не входит в текущую подписку провайдера (issue #170)."""
lowered = text.lower()
has_code = _MODEL_NOT_IN_PLAN_CODE in text
has_text = any(m in lowered for m in _MODEL_NOT_IN_PLAN_MARKERS)
return has_code and has_text
def _decode_json_string_field(raw: str, field: str) -> str | None:
match = re.search(fr'"{re.escape(field)}":"((?:\\.|[^"\\])*)"', raw)
if not match:
return None
encoded = match.group(1)
try:
return json.loads(f'"{encoded}"')
except json.JSONDecodeError:
return encoded
def _short_error_detail(text: str, limit: int = 180) -> str:
cleaned = " ".join(text.split())
if len(cleaned) <= limit:
return cleaned
return cleaned[:limit - 1] + "…"
def _scrub_secrets(text: str) -> str:
"""Вырезает секрето-/PII-подобные фрагменты, заменяя их на «[скрыто]»."""
for pattern in _SECRET_PATTERNS:
text = pattern.sub("[скрыто]", text)
return text
def public_reason(reason: str | None) -> str | None:
"""Санирует причину исхода для ПУБЛИЧНОГО отчёта (raw_json → дашборд).
Полная причина (с сырым телом провайдера и tail логов) остаётся в приватном
run.log. Наружу отдаём безопасный каркас: HTTP-код + распознанная категория, а
для нераспознанного — короткий хвост со скрабингом секретов/PII. Если категория
не ясна и текст подозрителен — отдаём только код/«ошибка провайдера», не сырьё.
"""
if not reason:
return None
if reason.startswith(NETWORK_ERROR_REASON):
# Собственный диагноз бенчмарка, а не тело провайдера. Публикуем только
# стабильную категорию: текст транспортного исключения остаётся в run.log.
return NETWORK_ERROR_REASON
if reason.startswith(MODEL_NOT_IN_PLAN_REASON):
# issue #170: собственный диагноз бенчмарка — публикуем категорию,
# детали провайдера (имя модели, код 1311) остаются в run.log.
return MODEL_NOT_IN_PLAN_REASON
if reason.startswith(_LOCAL_REASON_PREFIXES):
# Локальная инфраструктурная причина (запуск сервера, future, crash) не
# является телом провайдера; проверяем её до keyword-классификации, чтобы
# случайные слова вроде forbidden в пути не стали «ошибкой авторизации».
return _short_error_detail(_scrub_secrets(reason), limit=120)
if reason.startswith(HUNG_POST_REASON):
# issue #124: собственный диагноз бенчмарка, а не тело провайдера —
# публикуем как есть, чтобы «пустой успех» на дашборде имел причину.
return HUNG_POST_REASON
if reason.startswith(OUTPUT_LENGTH_REASON):
return _short_error_detail(reason.split(" | ", 1)[0], limit=160)
if reason.startswith(FIRST_ACTION_TIMEOUT_REASON):
return _short_error_detail(reason.split(" | ", 1)[0], limit=160)
# Таймаут-причины («нет ответа за 60с …») не содержат тела провайдера — но в
# хвост мог попасть provider-tail, поэтому всё равно скрабим.
code_match = re.search(r"HTTP\s+(\d+)", reason)
code = code_match.group(1) if code_match else None
prefix = f"HTTP {code}" if code else None
if code in ("401", "403") or "unauthorized" in reason.lower() \
or "forbidden" in reason.lower():
return f"{prefix}: ошибка авторизации" if prefix else "ошибка авторизации"
if _is_retryable_limit_error(reason):
return f"{prefix}: превышен лимит/квота" if prefix else "превышен лимит/квота"
if _is_account_error(reason):
return f"{prefix}: проблема аккаунта/биллинга" if prefix \
else "проблема аккаунта/биллинга"
if reason.startswith("нет ответа"):
# Чистый таймаут без provider-текста — оставляем как есть; но если к нему
# приклеен tail (через " | "), берём только безопасную головную часть.
return _scrub_secrets(reason.split(" | ", 1)[0])
# Категория не распознана. Если есть HTTP-код, НЕ публикуем тело провайдера:
# скраббер не является allowlist и не должен решать, какие поля безопасны.
if prefix:
return f"{prefix}: ошибка провайдера"
return "ошибка провайдера"
def _response_body_error(raw: str) -> str | None:
body = _decode_json_string_field(raw, "responseBody")
if not body:
return None
try:
payload = json.loads(body)
except json.JSONDecodeError:
return _short_error_detail(body)
if isinstance(payload, dict):
err = payload.get("error")
if isinstance(err, str):
return _short_error_detail(err)
if isinstance(err, dict):
msg = err.get("message") or err.get("error") or err.get("name")
if isinstance(msg, str):
return _short_error_detail(msg)
msg = payload.get("message") or payload.get("detail")
if isinstance(msg, str):
return _short_error_detail(msg)
return _short_error_detail(body)
def _log_line_has_agent(raw: str, agent: str) -> bool:
pattern = rf"(?<!\S)agent={re.escape(agent)}(?=\s|$)"
return re.search(pattern, raw) is not None
def _opencode_error_tail(session_id: str, lines: int = 8, *,
agent: str | None = None) -> str | None:
try:
log_files = sorted(OPENCODE_LOG_DIR.glob("*.log"),
key=lambda p: p.stat().st_mtime, reverse=True)
except OSError as exc:
# Не смогли прочитать каталог provider-логов: причина account/provider
# ошибки деградирует до обычного timeout. Оставляем след, чтобы это было
# видно, а не выглядело как «в логах ничего нет».
print(f"[opencode] не удалось прочитать каталог логов "
f"{OPENCODE_LOG_DIR}: {exc}", file=sys.stderr)
return None
found: list[str] = []
unread: list[str] = []
for log_file in log_files:
try:
with log_file.open(errors="replace") as fh:
for raw in fh:
raw = raw.rstrip("\n")
if not raw.startswith("ERROR") or session_id not in raw:
continue
if agent is not None and not _log_line_has_agent(raw, agent):
continue
status = re.search(r'statusCode["\s:=]+(\d+)', raw)
err_name = re.search(r'"name":"([^"]+)"', raw)
detail = re.search(r'"message":"([^"]{0,160})"', raw)
response_error = _response_body_error(raw)
parts = []
if status:
parts.append(f"HTTP {status.group(1)}")
if err_name:
parts.append(err_name.group(1))
detail_text = detail.group(1) if detail else None
if "Too Many Requests" in raw and not (
detail_text and "Too Many Requests" in detail_text
):
parts.append("Too Many Requests")
if detail_text:
parts.append(detail_text)
if response_error and response_error not in parts:
parts.append(response_error)
summary = " | ".join(parts) if parts else raw[:200]
if summary not in found:
found.append(summary)
except OSError as exc:
# Конкретный лог-файл не открылся (удалён/нет прав) — копим, чтобы не
# спамить stderr на каждый файл в цикле; сообщим один раз ниже.
unread.append(f"{log_file}: {exc}")
continue
if found:
break
# Логируем пропущенные файлы один раз и только если причину так и не нашли:
# иначе провайдерская причина могла потеряться именно в нечитаемом логе.
if unread and not found:
print(f"[opencode] не удалось прочитать логи ({len(unread)}): "
f"{'; '.join(unread)}", file=sys.stderr)
if not found:
return None
return "\n".join(found[-lines:])