File size: 10,267 Bytes
2415446
a1bab2d
 
2415446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1bab2d
2415446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
"""Credential-safe diagnostics shared across product boundaries."""
from __future__ import annotations


import json
import re
import traceback
from dataclasses import dataclass
from typing import Any

from .failures import ExecutionFailure

ERROR_DETAIL_DISPLAY_CAP_BYTES = 16_384
_MAX_CAUSE_CHAIN_DEPTH = 4
_UPSTREAM_BODY_ATTR = "_fcc_upstream_error_body"
_UPSTREAM_BODY_TRUNCATED_ATTR = "_fcc_upstream_error_body_truncated"

_SECRET_TEXT_REPLACEMENTS = (
    (
        re.compile(
            r"(?i)(?P<prefix>[\"']?authorization[\"']?\s*[:=]\s*)"
            r"(?P<quote>[\"']?)(?:(?:bearer|basic)\s+)?"
            r"[^\"'\s,;&}\]]+(?P=quote)"
        ),
        r"\g<prefix>\g<quote><redacted>\g<quote>",
    ),
    (
        re.compile(
            r"(?i)(?P<prefix>[\"']?(?:api[_-]?key|access[_-]?token|"
            r"refresh[_-]?token|token|client[_-]?secret|secret|password)"
            r"[\"']?\s*[:=]\s*)(?P<quote>[\"']?)"
            r"[^\"'\s,;&}\]]+(?P=quote)"
        ),
        r"\g<prefix>\g<quote><redacted>\g<quote>",
    ),
    (re.compile(r"(?i)(bearer\s+)[^\s,;]+"), r"\1<redacted>"),
    (
        re.compile(
            r"(?i)(?<![a-z0-9])(?:sk-[a-z0-9._-]{8,}|"
            r"nvapi-[a-z0-9._-]{8,}|hf_[a-z0-9_-]{8,}|"
            r"gsk_[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{8,}|"
            r"gh[pousr]_[a-z0-9]{8,}|AIza[a-z0-9_-]{20,})"
            r"(?![a-z0-9])"
        ),
        "<redacted>",
    ),
)


@dataclass(frozen=True, slots=True)
class UpstreamErrorDetail:
    """Sanitized diagnostic detail extracted from an upstream exception."""


    status_code: int | None = None
    body_text: str | None = None
    exception_text: str | None = None
    cause_chain_text: str | None = None
    category_hint: str | None = None
    body_truncated: bool = False


def redact_sensitive_error_text(text: str) -> str:
    """Redact recognizable credentials while preserving diagnostic context."""
    sanitized = text
    for pattern, replacement in _SECRET_TEXT_REPLACEMENTS:
        sanitized = pattern.sub(replacement, sanitized)
    return sanitized


def safe_exception_message(
    exc: BaseException,
    *,
    fallback: str = "Provider request failed unexpectedly.",
) -> str:
    """Return a redacted, non-empty exception message."""
    message = redact_sensitive_error_text(str(exc).strip())
    return message or fallback


def format_user_error_preview(exc: BaseException, *, max_len: int = 200) -> str:
    """Return a short redacted exception preview for chat surfaces."""
    return safe_exception_message(exc)[:max_len]


def attach_upstream_error_body(
    exc: Exception,
    body: bytes | str,
    *,
    truncated: bool = False,
) -> None:
    """Attach a bounded streamed response body for later safe formatting."""
    setattr(exc, _UPSTREAM_BODY_ATTR, body)
    setattr(exc, _UPSTREAM_BODY_TRUNCATED_ATTR, truncated)


def exception_cause_types(exc: BaseException) -> tuple[str, ...]:
    """Return exception cause type names without logging their contents."""
    return tuple(type(cause).__name__ for cause in _exception_causes(exc))


def redacted_exception_traceback(exc: BaseException) -> str:
    """Format a traceback while redacting recognizable credentials."""
    return redact_sensitive_error_text("".join(traceback.format_exception(exc)))


def extract_upstream_error_detail(exc: Exception) -> UpstreamErrorDetail:
    """Extract bounded, redacted body, exception, and cause-chain details."""
    raw_body = getattr(exc, _UPSTREAM_BODY_ATTR, None)
    body_truncated = bool(getattr(exc, _UPSTREAM_BODY_TRUNCATED_ATTR, False))
    if raw_body is None:
        raw_body = getattr(exc, "body", None)
    if raw_body is None:
        raw_body = _body_from_response(exc)

    body_text = _normalize_body_text(raw_body)
    if body_text is not None:
        body_text = redact_sensitive_error_text(body_text)
        body_text, capped = _cap_text_bytes(body_text)
        body_truncated = body_truncated or capped

    exception_text = str(exc).strip() or None
    if exception_text is not None:
        exception_text = redact_sensitive_error_text(exception_text)
        exception_text, _ = _cap_text_bytes(exception_text)

    return UpstreamErrorDetail(
        status_code=_status_code_from_exception(exc),
        body_text=body_text,
        exception_text=exception_text,
        cause_chain_text=_exception_cause_chain_text(exc),
        category_hint=_category_hint_from_body(raw_body, body_text),
        body_truncated=body_truncated,
    )


def format_execution_failure_message(
    failure: ExecutionFailure,
    detail: UpstreamErrorDetail,
    *,
    upstream_name: str,
    request_id: str | None = None,
) -> str:
    """Build a copyable, redacted diagnostic for a finalized execution failure."""
    stable_message = failure.message
    has_upstream_detail = detail.status_code is not None or detail.body_text is not None
    if not has_upstream_detail:
        lines = [stable_message]
        if detail.exception_text and detail.exception_text != stable_message:
            lines.extend(("", "Provider exception:", detail.exception_text))
        if detail.cause_chain_text:
            lines.extend(("", "Caused by:", detail.cause_chain_text))
        _append_request_id_lines(lines, request_id)
        return "\n".join(lines)

    if detail.status_code == 405:
        lines = [
            f"Upstream provider {upstream_name} rejected the request method "
            "or endpoint (HTTP 405)."
        ]
    elif detail.status_code is not None:
        lines = [
            f"Upstream provider {upstream_name} returned HTTP {detail.status_code}."
        ]
    else:
        lines = [f"Upstream provider {upstream_name} returned an error."]

    lines.append(f"Category: {detail.category_hint or failure.kind.value}")
    if stable_message and stable_message != lines[0]:
        lines.append(f"Mapped message: {stable_message}")
    lines.extend(("", "Upstream error:"))
    lines.append(detail.body_text or "(empty upstream error body)")
    if _body_truncation_line_needed(detail):
        lines.append(f"... [truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes]")
    _append_request_id_lines(lines, request_id)
    return "\n".join(lines)


def _body_truncation_line_needed(detail: UpstreamErrorDetail) -> bool:
    """Return whether a separate truncation marker is needed."""
    return detail.body_truncated and (
        detail.body_text is None
        or f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes"
        not in detail.body_text
    )


def _status_code_from_exception(exc: Exception) -> int | None:
    status = getattr(exc, "status_code", None)
    if isinstance(status, int):
        return status
    response = getattr(exc, "response", None)
    response_status = getattr(response, "status_code", None)
    return response_status if isinstance(response_status, int) else None


def _body_from_response(exc: Exception) -> Any:
    response = getattr(exc, "response", None)
    if response is None:
        return None
    try:
        return response.json()
    except Exception:
        pass
    try:
        return response.text
    except Exception:
        return None


def _normalize_body_text(body: Any) -> str | None:
    if body is None:
        return None
    if isinstance(body, bytes):
        text = body.decode("utf-8", errors="replace")
    elif isinstance(body, str):
        text = body
    else:
        try:
            return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
        except TypeError:
            text = str(body)
    stripped = text.strip()
    if not stripped:
        return None
    try:
        parsed = json.loads(stripped)
    except ValueError:
        return stripped
    return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))


def _cap_text_bytes(text: str) -> tuple[str, bool]:
    encoded = text.encode("utf-8", errors="replace")
    if len(encoded) <= ERROR_DETAIL_DISPLAY_CAP_BYTES:
        return text, False
    capped = encoded[:ERROR_DETAIL_DISPLAY_CAP_BYTES].decode("utf-8", errors="replace")
    return (
        f"{capped}\n... [truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes]",
        True,
    )


def _exception_causes(exc: BaseException) -> tuple[BaseException, ...]:
    causes: list[BaseException] = []
    seen = {id(exc)}
    current: BaseException | None = exc
    while current is not None and len(causes) < _MAX_CAUSE_CHAIN_DEPTH:
        next_exc = current.__cause__ or current.__context__
        if next_exc is None or id(next_exc) in seen:
            break
        seen.add(id(next_exc))
        causes.append(next_exc)
        current = next_exc
    return tuple(causes)


def _exception_cause_chain_text(exc: BaseException) -> str | None:
    lines: list[str] = []
    for cause in _exception_causes(exc):
        raw_text = str(cause).strip()
        lines.append(
            f"{type(cause).__name__}: {redact_sensitive_error_text(raw_text)}"
            if raw_text
            else type(cause).__name__
        )
    if not lines:
        return None
    text, _ = _cap_text_bytes("\n".join(lines))
    return text


def _category_hint_from_body(body: Any, body_text: str | None) -> str | None:
    parsed = body
    if isinstance(parsed, bytes):
        parsed = parsed.decode("utf-8", errors="replace")
    if isinstance(parsed, str):
        try:
            parsed = json.loads(parsed)
        except ValueError:
            parsed = None
    if isinstance(parsed, dict):
        error = parsed.get("error")
        if isinstance(error, dict):
            for key in ("type", "code"):
                value = error.get(key)
                if isinstance(value, str) and value.strip():
                    return value.strip()
        for key in ("type", "code"):
            value = parsed.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
    if (
        body_text
        and "model" in body_text.lower()
        and "unsupported" in body_text.lower()
    ):
        return "upstream_model_error"
    return None


def _append_request_id_lines(lines: list[str], request_id: str | None) -> None:
    if request_id:
        lines.extend(("", f"Request ID: {request_id}"))