Spaces:
Running
Running
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",
]
|