File size: 10,596 Bytes
9d0fd45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Upstream failure β†’ one actionable sentence for the browser.

A public demo has two audiences for the same exception: the visitor, who needs
to know whether to retry or give up, and the operator, who needs to know which
Space Variable or Secret is wrong. Neither is served by a traceback, and a
traceback is also the most likely place for a credential to leak.
"""

from __future__ import annotations

import asyncio
import re

APODEX_PLATFORM_URL = "https://platform.apodex.ai/"
UPSTREAM_QUOTA_MESSAGE = (
    "The demo quota has been exhausted. Please use "
    f"[Apodex Platform]({APODEX_PLATFORM_URL}) to experience the full product."
)


#: Statuses that actually mean "you have used this up", as opposed to "you have
#: configured this wrong". Every other 4xx in ``_STATUS_MESSAGES`` is an operator
#: fault β€” a bad key (401), a bad base URL (404), a model without tool calling
#: (400/422) β€” and appending a quota notice to those tells the visitor to go buy
#: a product when the real fix is a Space Secret, while burying the one message
#: that would have led the operator to it.
_QUOTA_STATUSES: frozenset[int] = frozenset({402, 429})


def _with_platform_cta(status: int, classified: tuple[str, str]) -> tuple[str, str]:
    """Append the platform call to action to a genuine exhaustion message."""
    reason, message = classified
    if status in _QUOTA_STATUSES:
        message = f"{message}\n\n{UPSTREAM_QUOTA_MESSAGE}"
    return reason, message

#: HTTP status β†’ (short reason slug, message). Ordered by how often a
#: misconfigured Space hits them.
_STATUS_MESSAGES: dict[int, tuple[str, str]] = {
    400: (
        "upstream_bad_request",
        "The model endpoint rejected the request (400). If the model does not "
        "support tool/function calling, this demo cannot run on it.",
    ),
    401: (
        "upstream_unauthorized",
        "The model endpoint rejected the credentials (401). Check the "
        "OPENAI_API_KEY Secret.",
    ),
    403: (
        "upstream_forbidden",
        "The model endpoint refused access (403). Check that the API key may "
        "use this model.",
    ),
    404: (
        "upstream_not_found",
        "The model endpoint returned 404. Check OPENAI_BASE_URL (it must be "
        "the API base ending in '/v1', not a model web page) and that "
        "OPENAI_MODEL names a model the endpoint serves.",
    ),
    408: ("upstream_timeout", "The model endpoint timed out (408). Try again."),
    413: (
        "upstream_payload_too_large",
        "The request was too large for the model endpoint (413). Try a shorter "
        "task.",
    ),
    422: (
        "upstream_unprocessable",
        "The model endpoint could not process the request (422). It may not "
        "support tool/function calling.",
    ),
    429: (
        "upstream_rate_limited",
        "The model endpoint is rate limited (429). Please retry shortly.",
    ),
    500: ("upstream_server_error", "The model endpoint failed (500). Please retry."),
    502: ("upstream_bad_gateway", "The model endpoint is unreachable (502). Please retry."),
    503: (
        "upstream_unavailable",
        "The model endpoint is unavailable (503) β€” it may still be starting up.",
    ),
    504: ("upstream_gateway_timeout", "The model endpoint timed out (504). Please retry."),
}

_GENERIC_UPSTREAM = (
    "upstream_error",
    "The model endpoint could not complete the request.",
)

_STATUS_RE = re.compile(r"\b([45]\d{2})\b")

#: Provider exception *class names* β†’ the status they correspond to. The agent
#: loop reports a failed attempt by class name only (``LLMAttemptContext.
#: error_type``), so this is the sole way to classify a failure that the
#: workflow already absorbed into a best-effort answer.
_ERROR_NAME_STATUS: dict[str, int] = {
    "BadRequestError": 400,
    "AuthenticationError": 401,
    "PermissionDeniedError": 403,
    "NotFoundError": 404,
    "ConflictError": 409,
    "UnprocessableEntityError": 422,
    "RateLimitError": 429,
    "InternalServerError": 500,
    "APIStatusError": 500,
}

_ERROR_NAME_SLUGS: dict[str, tuple[str, str]] = {
    "APITimeoutError": (
        "upstream_timeout",
        "The model endpoint did not respond in time. Please retry.",
    ),
    "APIConnectionError": (
        "upstream_unreachable",
        "The model endpoint could not be reached. Check OPENAI_BASE_URL and "
        "that the endpoint is running.",
    ),
    "APIConnectionTimeoutError": (
        "upstream_unreachable",
        "The model endpoint could not be reached in time. Check OPENAI_BASE_URL.",
    ),
    # The next two are *not* endpoint faults, and saying "the endpoint could
    # not complete the request" for them sends the operator to inspect a
    # healthy service. Both are raised by the loop's own stream watchdogs
    # (``frontier_agent.core.errors``).
    "LLMReasoningRunaway": (
        "model_reasoning_runaway",
        "The model spent its whole output budget on internal reasoning without "
        "answering, so the reply was stopped and resampled. The endpoint is "
        "healthy; retry, or give the task a larger output budget.",
    ),
    "LLMStreamStalled": (
        "upstream_stream_stalled",
        "The model endpoint accepted the request and then went silent "
        "mid-response. This usually means a saturated or dropped connection "
        "upstream, not a bad request.",
    ),
}

#: The subset of :data:`_ERROR_NAME_SLUGS` raised by the loop rather than by the
#: provider SDK. :func:`classify_error` has to consult these before its generic
#: status/timeout branches; the SDK names are left to those branches so the two
#: functions keep agreeing on them.
_WATCHDOG_ERROR_NAMES: frozenset[str] = frozenset({
    "LLMReasoningRunaway", "LLMStreamStalled",
})


def classify_error_name(name: str, detail: str = "") -> tuple[str, str]:
    """Classify a failure known only by its exception class name.

    Used when the workflow has already swallowed the provider error and
    returned a placeholder answer: the demo still has to tell the operator that
    the endpoint β€” not the model β€” is the problem.
    """
    name = str(name or "").strip()
    if name in _ERROR_NAME_SLUGS:
        return _ERROR_NAME_SLUGS[name]
    status = _ERROR_NAME_STATUS.get(name)
    if status is None:
        match = _STATUS_RE.search(detail)
        if match:
            status = int(match.group(1))
    if status is not None:
        return _with_platform_cta(
            status, _STATUS_MESSAGES.get(status, _GENERIC_UPSTREAM),
        )
    return _GENERIC_UPSTREAM


def classify_error(exc: BaseException) -> tuple[str, str]:
    """Return ``(reason_slug, user_message)`` for ``exc``."""
    from frontier_agent.core.errors import LLMError

    if isinstance(exc, asyncio.CancelledError):
        return "cancelled", "The run was cancelled."

    # ``LLMCallExhausted`` wraps the provider error it gave up on; the wrapped
    # one carries the status code worth reporting.
    root = getattr(exc, "last_exc", None) or exc

    # The loop's own watchdogs, before the generic branches below: neither is a
    # provider status, and ``LLMStreamStalled`` *is* a ``TimeoutError``, so the
    # timeout branch would otherwise absorb it and blame a slow endpoint for a
    # mid-stream black-hole.
    for candidate in (exc, root):
        slug = _ERROR_NAME_SLUGS.get(type(candidate).__name__)
        if slug and type(candidate).__name__ in _WATCHDOG_ERROR_NAMES:
            return slug

    status = _status_of(root)
    if status is not None:
        return _with_platform_cta(
            status, _STATUS_MESSAGES.get(status, _GENERIC_UPSTREAM),
        )

    if isinstance(root, (asyncio.TimeoutError, TimeoutError)):
        return (
            "upstream_timeout",
            "The model endpoint did not respond in time. Please retry.",
        )

    if _is_connection_error(root):
        return (
            "upstream_unreachable",
            "The model endpoint could not be reached. Check OPENAI_BASE_URL and "
            "that the endpoint is running.",
        )

    if isinstance(exc, LLMError) or isinstance(root, LLMError):
        reason = str(getattr(exc, "reason", "") or "").strip()
        return (
            f"llm_error{f':{reason}' if reason else ''}",
            "The model call failed" + (f" ({reason})." if reason else "."),
        )

    name = type(exc).__name__
    if name == "TaskWallTimeExceeded":
        return (
            "timeout",
            "The run exceeded this demo's time budget and was stopped.",
        )
    if name == "SandboxUnavailableError":
        return (
            "sandbox_unavailable",
            "The agent's filesystem sandbox is not available in this "
            "deployment; SANDBOX_BACKEND must be 'container' inside the Space "
            "image.",
        )

    return name, "The run failed before producing an answer."


def describe_error(exc: BaseException) -> str:
    """The user-facing sentence only."""
    return classify_error(exc)[1]


def error_reason(exc: BaseException) -> str:
    """The short machine-readable slug only."""
    return classify_error(exc)[0]


def _status_of(exc: BaseException) -> int | None:
    """Best-effort HTTP status for a provider exception.

    ``openai`` and ``httpx`` expose it differently, and some gateways only put
    it in the message text, so all three are checked.
    """
    for attribute in ("status_code", "status", "code"):
        value = getattr(exc, attribute, None)
        if isinstance(value, int) and 400 <= value <= 599:
            return value
    response = getattr(exc, "response", None)
    status = getattr(response, "status_code", None)
    if isinstance(status, int) and 400 <= status <= 599:
        return status
    # Only trust the message when the exception looks like an HTTP failure β€”
    # a bare "500" inside arbitrary text is not a status code.
    text = str(exc)
    if any(token in type(exc).__name__ for token in ("Status", "HTTP", "APIError")):
        match = _STATUS_RE.search(text)
        if match:
            return int(match.group(1))
    return None


def _is_connection_error(exc: BaseException) -> bool:
    if isinstance(exc, (ConnectionError, OSError)):
        return True
    name = type(exc).__name__
    return name in {
        "APIConnectionError", "ConnectError", "ConnectTimeout",
        "ReadError", "RemoteProtocolError", "APIConnectionTimeoutError",
    }


__all__ = [
    "classify_error",
    "classify_error_name",
    "describe_error",
    "error_reason",
]