Skip to content
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a62e1ca
feat(errors): classify provider failures and enrich telemetry evidence
seonghobae Aug 26, 2026
7101227
fix: preserve provider error and telemetry contracts
seonghobae Aug 26, 2026
b17d600
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 26, 2026
ed0690e
fix(routing): honor nonretryable provider failures
seonghobae Aug 26, 2026
44d38cc
fix: preserve provider error bodies across classification
seonghobae Aug 26, 2026
bcd3273
fix: classify exhausted passthrough providers
seonghobae Aug 26, 2026
032ad41
fix: initialize provider retry failure state
seonghobae Aug 26, 2026
b425e86
Merge remote-tracking branch 'origin/main' into repair/pr879-current
seonghobae Aug 26, 2026
b6ba256
fix(telemetry): normalize trace provider names
seonghobae Aug 26, 2026
6482cb9
fix(telemetry): complete provider failure evidence
seonghobae Aug 26, 2026
f2c444a
fix(errors): suppress raw passthrough causes
seonghobae Aug 26, 2026
ecf8c1e
fix(errors): preserve capability and DNS taxonomy
seonghobae Aug 26, 2026
be6972c
fix(telemetry): bound and complete provider evidence
seonghobae Aug 26, 2026
6a17460
Merge main and resolve conflicts
seonghobae Aug 27, 2026
ce537b1
fix(server): restore structured trace contracts after main merge
seonghobae Aug 27, 2026
93f2b86
fix(errors): preserve passthrough failover on classified retries
seonghobae Aug 28, 2026
e6d9e93
Merge main into provider error taxonomy branch
seonghobae Aug 28, 2026
2b1829a
fix: avoid replaying ambiguous passthrough failures
seonghobae Aug 28, 2026
e27d297
merge(main): reconcile provider error telemetry
seonghobae Aug 28, 2026
ec17d4e
fix(errors): scrub extended control characters
seonghobae Aug 28, 2026
a49a39c
chore: run one-shot conflict resolver for PR #879
seonghobae Aug 29, 2026
f064beb
chore: rerun hardened conflict inventory for PR #879
seonghobae Aug 29, 2026
ef6faa1
ci: semantically resolve PR 879 against protected main
seonghobae Aug 29, 2026
da8393c
ci: isolate PR 879 semantic conflict resolver
seonghobae Aug 29, 2026
c48f77b
ci: execute isolated PR 879 conflict resolver
seonghobae Aug 29, 2026
48133a7
chore: merge protected main into PR #879
github-actions[bot] Aug 29, 2026
2649fc7
ci: retrigger protected checks for PR #879
seonghobae Aug 29, 2026
ff1783c
ci: remove transient PR #879 check trigger
seonghobae Aug 29, 2026
e084a2f
ci: rerun PR #879 against provider-diverse Strix
seonghobae Aug 29, 2026
3fa7d7e
ci: remove transient PR #879 auto-pool trigger
seonghobae Aug 29, 2026
2572c8d
chore: add bounded PR #879 trace contract repair
seonghobae Aug 29, 2026
87bf8d1
chore: run bounded PR #879 trace contract repair
seonghobae Aug 29, 2026
c2e8042
fix: preserve structured and tool trace contracts
github-actions[bot] Aug 29, 2026
0fe060f
ci: run final protected checks for PR #879
seonghobae Aug 29, 2026
fbfef43
ci: remove final PR #879 check trigger
seonghobae Aug 29, 2026
fd828d9
chore: add bounded PR #879 taxonomy evidence repair
seonghobae Aug 29, 2026
63560e8
chore: run bounded PR #879 taxonomy evidence repair
seonghobae Aug 29, 2026
7ba4bb9
fix: preserve provider taxonomy and batch trace evidence
github-actions[bot] Aug 29, 2026
59f80a4
docs: record provider taxonomy and trace evidence contracts
seonghobae Aug 29, 2026
2bd84b7
Merge branch 'main' into fix/provider-error-taxonomy-telemetry
seonghobae Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.d/provider-error-taxonomy-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Classify provider failures by actionable HTTP/network cause without exposing provider-controlled diagnostics; preserve request-size taxonomy in telemetry, and attach measured shared provider-batch latency to each affected workflow trace row without claiming unavailable per-request timing precision.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

### Fixed

- Provider/model failures no longer collapse into a generic `internal_error`.
A typed provider-error taxonomy (`contextual_orchestrator.provider_errors`)
classifies every upstream HTTP status, network, TLS, and transport failure
into OpenAI-compatible error codes (`rate_limit_exceeded`,
`authentication_error`, `model_not_found`, `provider_timeout`, ...) with the
client status, retryability, and one bounded redacted message (CWE-209).
Chat, passthrough, stream, and batch transports all surface the classified
cause; a fully-failed agent pool surfaces the final classified failure
after measured failover instead of an opaque collapse. Server error
payloads carry actionable next-step guidance per failure family.
- Telemetry spans now carry concrete GenAI semantic-convention evidence:
`gen_ai.usage.input_tokens/output_tokens/total_tokens` from provider-reported
counts, served `gen_ai.response.model`, `gen_ai.response.finish_reasons`,
request latency, and classified `error.type` plus upstream status on
failures — replacing exception-class-only error labels. Chat, streaming,
and passthrough responses share this evidence path, and finish-reason arrays
are bounded to the OpenTelemetry default span-attribute budget.
- Orchestration traces now include per-step telemetry evidence: streamed,
batched, routed, and conducted steps record `model`, `provider`, and
`latency_ms` alongside usage so workflow runs answer which model served a
step, how long it took, and what it cost.
- Runtime agent create/PATCH now accepts and persists the explicit
`stream_usage_supported` capability, and the admin-safe agent view exposes it.
- Require `--allow-public-bind` for every non-loopback address, not only wildcard
Expand Down
10 changes: 5 additions & 5 deletions admin_ui/src/stories/Configure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import Accessibility from "./assets/accessibility.png";
import Theming from "./assets/theming.png";
import AddonLibrary from "./assets/addon-library.png";

export const RightArrow = () => <svg
viewBox="0 0 14 14"
width="8px"
height="14px"
style={{
export const RightArrow = () => <svg
viewBox="0 0 14 14"
width="8px"
height="14px"
style={{
marginLeft: '4px',
display: 'inline-block',
shapeRendering: 'inherit',
Expand Down
226 changes: 196 additions & 30 deletions contextual_orchestrator/orchestrator.py
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.

Large diffs are not rendered by default.

275 changes: 275 additions & 0 deletions contextual_orchestrator/provider_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
"""Provider failure taxonomy: typed, caller-actionable model errors.

Every upstream provider/model failure is classified into one OpenAI-compatible
error surface so callers learn *which* model failed, *why*, and *whether to
retry* — instead of receiving one opaque ``internal_error`` for every cause.

The classification derives from the upstream HTTP status (RFC 9110 semantics)
and the OpenAI error-code conventions used across compatible providers.
Provider response bodies are never surfaced raw: only a bounded, control-free
message field is kept, because provider diagnostics can embed secrets,
prompts, or internal topology (CWE-209).

References
----------
- Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics*
(RFC 9110). IETF. https://doi.org/10.17487/RFC9110
- OpenAI. (2026). *Error codes*. https://platform.openai.com/docs/guides/error-codes
"""
Comment on lines +1 to +18

@devin-ai-integration devin-ai-integration Bot Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Research artifact is missing

This substantive gateway feature adds no academic paper or linked summary. AGENTS.md requires research grounding for substantive feature PRs.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


from __future__ import annotations

import json as _json
import re as _re
import socket
import ssl
import urllib.error
from typing import Any

__all__ = [
"MAX_PROVIDER_ERROR_BODY_BYTES",
"MAX_SAFE_MESSAGE_CHARS",
"PROVIDER_STATUS_SURFACES",
"ProviderUpstreamError",
"classify_provider_failure",
"provider_error_body",
"safe_provider_message",
]

#: Upper bound for any provider-supplied message that reaches a caller.
MAX_SAFE_MESSAGE_CHARS = 300

#: Maximum provider response bytes inspected for one caller-safe diagnostic.
MAX_PROVIDER_ERROR_BODY_BYTES = 65_536

#: Provider-controlled text containing any likely secret, request content, URL,
#: or network address is discarded wholesale. Partial masking is unsafe because
#: adjacent diagnostic text can still identify credentials or private topology.
_SENSITIVE_PROVIDER_MESSAGE = _re.compile(
r"(?ix)(?:"
r"https?://|"
r"(?:^|[^0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:[^0-9]|$)|"
r"\b(?:api[_ -]?key|authorization|bearer|password|secret|token|prompt|input|messages?)\b"
r")"
)

#: Upstream HTTP status -> ``(client_status, error_code, retryable)`` surface.
#: The client status is what this gateway returns; ``error_code`` follows the
#: OpenAI-compatible vocabulary; ``retryable`` mirrors the gateway's own
#: transient-retry policy so callers can make consistent retry decisions.
PROVIDER_STATUS_SURFACES: dict[int, tuple[int, str, bool]] = {
400: (400, "invalid_request_error", False),
401: (401, "authentication_error", False),
402: (402, "payment_required", False),
403: (403, "permission_error", False),
404: (404, "model_not_found", False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Missing batch resources become missing models

Any 404 from batch polling or output retrieval becomes model_not_found. Missing batch resources tell callers to replace a valid model.

Prompt for agents
Make 404 classification transport or operation aware. The global PROVIDER_STATUS_SURFACES entry cannot distinguish a missing chat model from a missing batch, file, job, or passthrough resource. Preserve model_not_found for provider calls where 404 identifies the requested model, and use an accurate resource/API failure for batch lifecycle and non-model endpoints. Add tests for 404 during batch creation/polling/output-file retrieval as well as chat model lookup.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

405: (502, "api_error", False),
408: (504, "provider_timeout", True),
409: (409, "conflict", True),
410: (404, "model_not_found", False),
413: (413, "request_too_large", False),
415: (400, "invalid_request_error", False),
422: (400, "invalid_request_error", False),
425: (429, "rate_limit_exceeded", True),
429: (429, "rate_limit_exceeded", True),
500: (502, "api_error", True),
501: (502, "api_error", False),
502: (502, "api_error", True),
503: (503, "service_unavailable", True),
504: (504, "provider_timeout", True),
529: (503, "service_unavailable", True),
}
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.

#: Fallback surface for an upstream status that has no explicit mapping.
_UNMAPPED_UPSTREAM_SURFACE: tuple[int, str, bool] = (502, "api_error", False)


def provider_error_body(exc: urllib.error.HTTPError) -> bytes:
"""Read and cache one bounded upstream error body for all classifiers."""
cache_key = "_contextual_orchestrator_provider_error_body"
cached = getattr(exc, cache_key, None)
if isinstance(cached, bytes):
return cached
body = exc.read(MAX_PROVIDER_ERROR_BODY_BYTES + 1)[:MAX_PROVIDER_ERROR_BODY_BYTES]
try:
setattr(exc, cache_key, body)
except (AttributeError, TypeError): # pragma: no cover - HTTPError is mutable
pass
return body
Comment thread
seonghobae marked this conversation as resolved.
Comment on lines +87 to +98

@devin-ai-integration devin-ai-integration Bot Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Shared error bodies remain reusable

Retry, tool-limit, and public-message classifiers reuse one bounded cached body. Classifier order no longer consumes evidence needed downstream.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



def safe_provider_message(exc: BaseException) -> str | None:
"""Extract one bounded, control-free diagnostic sentence from a failure.

An HTTP error contributes only JSON ``error.message``-style fields. For
non-HTTP exceptions, this low-level helper returns a bounded first argument
or exception type for internal diagnostics; caller-facing classification
deliberately replaces those values with package-owned sentences.
Provider diagnostics can embed URLs, secrets, prompts, or internal topology,
so raw exception text never reaches API callers.
Control characters are collapsed so no body can smuggle log-formatting
or header content downstream.
"""
Comment thread
seonghobae marked this conversation as resolved.
if isinstance(exc, urllib.error.HTTPError):
try:
payload = _json.loads(
provider_error_body(exc).decode("utf-8", errors="replace")
)
Comment thread
seonghobae marked this conversation as resolved.
except Exception: # noqa: BLE001 - bodies are untrusted input
return None
Comment thread
seonghobae marked this conversation as resolved.
raw: Any = None
if isinstance(payload, dict):
error_field = payload.get("error")
if isinstance(error_field, dict):
raw = error_field.get("message") or error_field.get("code")
elif isinstance(error_field, str):
raw = error_field
else:
raw = payload.get("message") or payload.get("detail") or payload.get("code")
if not isinstance(raw, (str, int, float)) or raw == "":
return None
else:
first_arg = next((part for part in exc.args[:1] if isinstance(part, str)), "")
raw = first_arg or type(exc).__name__
collapsed = "".join(
char
if char == "\t" or not (ord(char) < 0x20 or 0x7F <= ord(char) <= 0x9F)
else " "
for char in str(raw)
).strip()
Comment thread
seonghobae marked this conversation as resolved.
collapsed = collapsed[:MAX_SAFE_MESSAGE_CHARS]
if not collapsed or _SENSITIVE_PROVIDER_MESSAGE.search(collapsed):
return None
return collapsed


class ProviderUpstreamError(RuntimeError):
"""One classified upstream model/provider failure.

Attributes carry the evidence a caller needs to act: which agent/model
failed, the upstream status when one existed, the OpenAI-compatible code,
whether retrying can help, and one bounded redacted message.
"""

def __init__(
self,
*,
agent_id: str,
model: str,
error_code: str,
message: str,
client_status: int,
provider_status: int | None = None,
retryable: bool = False,
transport: str = "chat",
) -> None:
self.agent_id = agent_id
self.model = model
self.error_code = error_code
self.client_status = client_status
self.provider_status = provider_status
self.retryable = retryable
self.transport = transport
super().__init__(message)

@property
def detail(self) -> dict[str, Any]:
"""Return the structured evidence attached to API error payloads."""
return {
"agent_id": self.agent_id,
"model": self.model,
"provider_status": self.provider_status,
"retryable": self.retryable,
"transport": self.transport,
}


def classify_provider_failure(
exc: BaseException | None,
*,
agent_id: str,
model: str,
transport: str = "chat",
) -> ProviderUpstreamError:
"""Map one provider-call exception onto the caller-facing error taxonomy.

Messages stay package-owned and bounded: only an upstream JSON body's
``error.message``-style field may pass through (redacted); every other
cause gets a fixed sentence so raw provider diagnostics (URLs, prompts,
secrets) can never reach callers or logs.
"""
if isinstance(exc, ProviderUpstreamError):
return exc
if isinstance(exc, urllib.error.HTTPError):
status = exc.code
client_status, error_code, retryable = PROVIDER_STATUS_SURFACES.get(
status, _UNMAPPED_UPSTREAM_SURFACE
)
return ProviderUpstreamError(
agent_id=agent_id,
model=model,
error_code=error_code,
message=(
safe_provider_message(exc)
or f"provider rejected the request with HTTP {status}"
),
client_status=client_status,
provider_status=status,
retryable=retryable,
transport=transport,
)
if isinstance(exc, ssl.SSLCertVerificationError):
return ProviderUpstreamError(
agent_id=agent_id,
model=model,
error_code="tls_verification_failed",
message=f"provider {agent_id} endpoint failed TLS certificate verification",
client_status=502,
provider_status=None,
retryable=False,
transport=transport,
)
if isinstance(exc, ssl.SSLError):
return ProviderUpstreamError(
agent_id=agent_id,
model=model,
error_code="tls_failure",
message=f"a TLS error interrupted the provider {agent_id} connection",
client_status=502,
provider_status=None,
retryable=True,
transport=transport,
)
dns_error = exc if isinstance(exc, socket.gaierror) else getattr(exc, "__cause__", None)
if isinstance(dns_error, socket.gaierror):
return ProviderUpstreamError(
agent_id=agent_id,
model=model,
error_code="provider_connection_error",
message=f"the provider {agent_id} host could not be resolved",
client_status=502,
provider_status=None,
retryable=dns_error.errno == socket.EAI_AGAIN,
transport=transport,
)
if isinstance(exc, (urllib.error.URLError, TimeoutError, ConnectionError, socket.timeout)):
return ProviderUpstreamError(
agent_id=agent_id,
model=model,
error_code="provider_connection_error",
message=f"the provider {agent_id} connection failed or did not finish in time",
client_status=502,
provider_status=None,
retryable=True,
transport=transport,
)
return ProviderUpstreamError(
agent_id=agent_id,
model=model,
error_code="api_error",
message=f"provider {agent_id} request failed",
client_status=502,
provider_status=None,
retryable=False,
transport=transport,
Comment thread
seonghobae marked this conversation as resolved.
)
Loading
Loading