Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 20 additions & 5 deletions contextual_orchestrator/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2036,8 +2036,10 @@ def _is_omit_equivalent_control(key: str, value: Any) -> bool:
return False


def _is_request_too_large_error(exc: BaseException) -> bool:
"""Recognize request-size rejection through a bounded exception chain."""
def _is_request_too_large_error(
exc: BaseException, *, follow_chain: bool = True
) -> bool:
"""Recognize a request-size rejection, optionally following its cause chain."""
current: BaseException | None = exc
seen: set[int] = set()
for _ in range(_PROVIDER_ERROR_CHAIN_LIMIT):
Expand All @@ -2053,6 +2055,8 @@ def _is_request_too_large_error(exc: BaseException) -> bool:
)
):
return True
if not follow_chain:
return False
if current.__cause__ is not None:
current = current.__cause__
elif current.__suppress_context__:
Expand All @@ -2068,7 +2072,11 @@ def _is_passthrough_failover_error(
"""Recognize failures proving that a passthrough request was not accepted."""
if isinstance(exc, _LocalProviderAdmissionTimeout):
return True
if _is_request_too_large_error(exc):
if review_free_request and not isinstance(
exc, (ProviderUpstreamError, urllib.error.HTTPError)
):
return False
if _is_request_too_large_error(exc, follow_chain=not review_free_request):
return True
current: BaseException | None = exc
seen: set[int] = set()
Expand Down Expand Up @@ -2114,6 +2122,8 @@ def _is_passthrough_failover_error(
and current.errno == socket.EAI_AGAIN
):
return True
if review_free_request:
return False
if current.__cause__ is not None:
current = current.__cause__
elif current.__suppress_context__:
Expand Down Expand Up @@ -6392,8 +6402,13 @@ def proxy_completion(
)
if _is_ambiguous_passthrough_transport_failure(exc) or (
review_free_request
and classified.provider_status is None
and classified.retryable
and (
(classified.provider_status is None and classified.retryable)
or (
not isinstance(exc, (ProviderUpstreamError, urllib.error.HTTPError))
and (exc.__cause__ is not None or exc.__context__ is not None)
)
)
):
# The candidate may already have applied the request.
# Review-free completions have no idempotency proof,
Expand Down
29 changes: 29 additions & 0 deletions tests/test_passthrough_provider_failover.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,35 @@ def test_free_review_dns_failure_does_not_authorize_replay(wrapped: bool) -> Non
assert [agent_id for agent_id, _ in client.calls] == ["primary_agent"]


@pytest.mark.parametrize("status", [404, 413])
def test_free_review_wrapped_rejection_does_not_authorize_replay(status: int) -> None:
"""A prior rejection in an exception chain cannot prove this send was rejected."""
body = {"error": {"code": "model_not_found"}} if status == 404 else None
with _http_error(status, body) as prior:
failure = RuntimeError("synthetic current send outcome unknown")
failure.__cause__ = prior
client = SequencedProxyClient({
"primary_agent": failure,
"fallback_agent": {"model": "fallback-model", "choices": []},
})
orchestrator = _build(client)
orchestrator.agents = [
replace(agent, tags=(*agent.tags, "cost:free", "review"))
for agent in orchestrator.agents
]

with pytest.raises(ProviderUpstreamError) as caught:
orchestrator.proxy_completion({
"model": TaskOrchestrator.FREE_MODEL,
"messages": [{"role": "user", "content": "synthetic"}],
})

assert (caught.value.client_status, caught.value.error_code, caught.value.retryable) == (
502, "provider_outcome_unknown", False,
)
assert [agent_id for agent_id, _ in client.calls] == ["primary_agent"]


def test_virtual_passthrough_keeps_non_size_tool_errors_sticky() -> None:
"""A generic provider invalid_tools response must not hide a bad request."""
failure = _invalid_tools_error()
Expand Down