open-swe/tests/middleware/test_model_fallback_middleware.py

235 lines
8.7 KiB
Python
Raw Permalink Normal View History

"""Tests for ModelFallbackMiddleware."""
from __future__ import annotations
from unittest.mock import MagicMock
import anthropic
import httpx
import openai
import pytest
from langchain_core.messages import AIMessage
from agent.middleware.model_fallback import (
ModelFallbackMiddleware,
_should_fallback,
)
def _anthropic_overloaded() -> anthropic.APIStatusError:
request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
response = httpx.Response(
529,
request=request,
json={"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}},
)
body = response.json()
return anthropic.APIStatusError("Overloaded", response=response, body=body)
def _openai_5xx() -> openai.APIStatusError:
request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions")
response = httpx.Response(503, request=request, json={"error": {"message": "unavailable"}})
return openai.APIStatusError("unavailable", response=response, body=response.json())
def _anthropic_model_not_available_error() -> anthropic.BadRequestError:
body = {
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "In order to access this model, your organization or workspace must have data retention enabled.",
"details": {"error_code": "model_not_available"},
},
"request_id": "req_test",
}
request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
response = httpx.Response(400, request=request, json=body)
return anthropic.BadRequestError("model unavailable", response=response, body=body)
def _make_request() -> MagicMock:
request = MagicMock()
request.override = MagicMock(return_value=MagicMock(name="overridden_request"))
return request
class TestShouldFallback:
def test_anthropic_529_overload_falls_back(self) -> None:
assert _should_fallback(_anthropic_overloaded()) is True
def test_openai_503_falls_back(self) -> None:
assert _should_fallback(_openai_5xx()) is True
def test_anthropic_rate_limit_falls_back(self) -> None:
request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
response = httpx.Response(429, request=request, json={"error": {}})
exc = anthropic.RateLimitError("rate", response=response, body={})
assert _should_fallback(exc) is True
def test_anthropic_400_does_not_fall_back(self) -> None:
request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
response = httpx.Response(400, request=request, json={"error": {}})
exc = anthropic.BadRequestError("bad", response=response, body={})
assert _should_fallback(exc) is False
feat: port model-fallback resilience from upstream (#1694, #1695) (#161) * feat: port plan-review & workflow-approval UX (#135) Port six upstream commits onto dev: - c03a6be7 (already ported): keep plan guidance high-level - 546042a4: add workflow approval UI with diff preview, approval URLs, web review links, and polling for approval status during active runs - 216cf181: remove workflow token elevation; approved pushes pass through directly without proxy token rewriting - 3dbc0282: preserve plan redirects after login by accepting relative same-origin redirect_to values and rejecting blocked paths - bb104d93: submit plan comments with cmd+enter - 90cb6caa: terse Slack replies, shared content via save_plan outside plan mode (PLAN_STATUS_SHARED), reject shared-content mutations Refs: #135 * feat: port durable dispatch hardening and startup latency improvements Port five upstream PRs onto dev: - #1621 / #1658: durable dispatch with loopback webhook defense, create_durable_run helper, _config_with_prepare_run_id, degradation to None for relative/loopback completion webhook URLs - #1696: run-level completion webhook deduplication (replace claim-then-post with post-then-flag per run_id), DeferredErrorModel for graph-factory resilience, ToolRetryMiddleware for task subagents, TimeoutWrapupMiddleware for all three graphs - #1697: lazy-load __init__.py for agent.middleware, agent.tools, agent.dashboard (PEP 562); defer heavy imports (exa_py in web_search, agent.webapp in request_pr_review, deepagents in sandbox.py); add ttl_cache.py with stale-while-revalidate for tool loaders Refs: #137 * fix: restore login page render and clear CI lint/format The plan-review port removed the authRedirectUrl import from login.tsx but left its call site, crashing the login page at runtime (blank page, no 'Sign in to open-swe'). Pass the relative path straight to loginUrl, matching the plan route and the backend relative-redirect handling. Also drop an unused os import in the guard test and reformat workflow_push_guard.py to satisfy ruff. * feat: port model-fallback resilience from upstream (#1694, #1695) - Add httpx.TransportError to the transient-exception set so incomplete chunked reads on streamed responses trigger a fallback instead of cancelling the run (#1694 / c9f6dd86). - Rewrite fallback to alternate primary/fallback with exponential backoff instead of a single failover, so the agent survives multi-minute gateway outages spanning both providers (#1695 / c9a9a7cd). - Default backoff schedule (0, 5, 15, 30, 45) reaches past the gateway's ~30s recovery window; jittered ±25%. - On exhaustion, surface a terminal AIMessage explaining the outage instead of crashing — progress is checkpointed so the user can retrigger to continue. - Preserve fork conventions: Bedrock ClientError retryability check, sync wrap_model_call (using time.sleep instead of asyncio.sleep), and existing access-error surfacing for Anthropic, OpenAI, and Bedrock (botocore) provider errors. - Update triage ledger (c9f6dd86, c9a9a7cd → landed) and re-render triage.md. Refs: #139 * fix: align workflow-push-guard tests with dev's transient-elevation impl The dev merge auto-combined dev's elevation tests with the stale passthrough tests inherited from the durable-dispatch branch; the passthrough tests contradict dev's restored _run_with_workflow_token impl. Take dev's test file. * fix: drop dead ttl_cache module; make fallback backoff jitter two-sided ttl_cache.py was re-introduced via the dev merge but dev/#160 deliberately removed it as dead code (no agent importer). Remove it to match dev. Also make _jittered_delay symmetric (±25%) to match its docstring. * chore(upstream-sync): triage 4 new upstream commits (#1708-#1713) Synced ledger to upstream/main (71e3b818). New rows all deferred: - #1708 add GPT-5.6 OpenAI models (FLAG-HUMAN: fork picker is Bedrock/Fireworks-only) - #1709 stale admin model defaults after upgrades - #1710 bump langchain-fireworks 1.4.4 - #1713 align reviewer eval with published findings --------- Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com> Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-07-09 18:28:21 -04:00
def test_httpx_remote_protocol_error_falls_back(self) -> None:
exc = httpx.RemoteProtocolError(
"peer closed connection without sending complete message body (incomplete chunked read)"
)
assert _should_fallback(exc) is True
def test_value_error_does_not_fall_back(self) -> None:
assert _should_fallback(ValueError("nope")) is False
class TestModelFallbackMiddleware:
@pytest.mark.asyncio
async def test_async_falls_over_on_overloaded(self) -> None:
fallback_model = MagicMock(name="fallback_model")
middleware = ModelFallbackMiddleware(fallback_model)
calls: list[object] = []
good_response = MagicMock(result=[AIMessage(content="ok from fallback")])
async def handler(req: object) -> object:
calls.append(req)
if len(calls) == 1:
raise _anthropic_overloaded()
return good_response
request = _make_request()
result = await middleware.awrap_model_call(request, handler)
assert result is good_response
assert len(calls) == 2
request.override.assert_called_once_with(model=fallback_model)
assert calls[1] is request.override.return_value
feat: port model-fallback resilience from upstream (#1694, #1695) (#161) * feat: port plan-review & workflow-approval UX (#135) Port six upstream commits onto dev: - c03a6be7 (already ported): keep plan guidance high-level - 546042a4: add workflow approval UI with diff preview, approval URLs, web review links, and polling for approval status during active runs - 216cf181: remove workflow token elevation; approved pushes pass through directly without proxy token rewriting - 3dbc0282: preserve plan redirects after login by accepting relative same-origin redirect_to values and rejecting blocked paths - bb104d93: submit plan comments with cmd+enter - 90cb6caa: terse Slack replies, shared content via save_plan outside plan mode (PLAN_STATUS_SHARED), reject shared-content mutations Refs: #135 * feat: port durable dispatch hardening and startup latency improvements Port five upstream PRs onto dev: - #1621 / #1658: durable dispatch with loopback webhook defense, create_durable_run helper, _config_with_prepare_run_id, degradation to None for relative/loopback completion webhook URLs - #1696: run-level completion webhook deduplication (replace claim-then-post with post-then-flag per run_id), DeferredErrorModel for graph-factory resilience, ToolRetryMiddleware for task subagents, TimeoutWrapupMiddleware for all three graphs - #1697: lazy-load __init__.py for agent.middleware, agent.tools, agent.dashboard (PEP 562); defer heavy imports (exa_py in web_search, agent.webapp in request_pr_review, deepagents in sandbox.py); add ttl_cache.py with stale-while-revalidate for tool loaders Refs: #137 * fix: restore login page render and clear CI lint/format The plan-review port removed the authRedirectUrl import from login.tsx but left its call site, crashing the login page at runtime (blank page, no 'Sign in to open-swe'). Pass the relative path straight to loginUrl, matching the plan route and the backend relative-redirect handling. Also drop an unused os import in the guard test and reformat workflow_push_guard.py to satisfy ruff. * feat: port model-fallback resilience from upstream (#1694, #1695) - Add httpx.TransportError to the transient-exception set so incomplete chunked reads on streamed responses trigger a fallback instead of cancelling the run (#1694 / c9f6dd86). - Rewrite fallback to alternate primary/fallback with exponential backoff instead of a single failover, so the agent survives multi-minute gateway outages spanning both providers (#1695 / c9a9a7cd). - Default backoff schedule (0, 5, 15, 30, 45) reaches past the gateway's ~30s recovery window; jittered ±25%. - On exhaustion, surface a terminal AIMessage explaining the outage instead of crashing — progress is checkpointed so the user can retrigger to continue. - Preserve fork conventions: Bedrock ClientError retryability check, sync wrap_model_call (using time.sleep instead of asyncio.sleep), and existing access-error surfacing for Anthropic, OpenAI, and Bedrock (botocore) provider errors. - Update triage ledger (c9f6dd86, c9a9a7cd → landed) and re-render triage.md. Refs: #139 * fix: align workflow-push-guard tests with dev's transient-elevation impl The dev merge auto-combined dev's elevation tests with the stale passthrough tests inherited from the durable-dispatch branch; the passthrough tests contradict dev's restored _run_with_workflow_token impl. Take dev's test file. * fix: drop dead ttl_cache module; make fallback backoff jitter two-sided ttl_cache.py was re-introduced via the dev merge but dev/#160 deliberately removed it as dead code (no agent importer). Remove it to match dev. Also make _jittered_delay symmetric (±25%) to match its docstring. * chore(upstream-sync): triage 4 new upstream commits (#1708-#1713) Synced ledger to upstream/main (71e3b818). New rows all deferred: - #1708 add GPT-5.6 OpenAI models (FLAG-HUMAN: fork picker is Bedrock/Fireworks-only) - #1709 stale admin model defaults after upgrades - #1710 bump langchain-fireworks 1.4.4 - #1713 align reviewer eval with published findings --------- Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com> Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-07-09 18:28:21 -04:00
@pytest.mark.asyncio
async def test_async_falls_over_on_stream_transport_error(self) -> None:
fallback_model = MagicMock(name="fallback_model")
middleware = ModelFallbackMiddleware(fallback_model)
calls: list[object] = []
good_response = MagicMock(result=[AIMessage(content="ok from fallback")])
async def handler(req: object) -> object:
calls.append(req)
if len(calls) == 1:
raise httpx.RemoteProtocolError(
"peer closed connection without sending complete message body "
"(incomplete chunked read)"
)
return good_response
request = _make_request()
result = await middleware.awrap_model_call(request, handler)
assert result is good_response
assert len(calls) == 2
request.override.assert_called_once_with(model=fallback_model)
assert calls[1] is request.override.return_value
@pytest.mark.asyncio
async def test_async_propagates_non_transient_error(self) -> None:
middleware = ModelFallbackMiddleware(MagicMock())
calls: list[object] = []
async def handler(req: object) -> object:
calls.append(req)
raise ValueError("not transient")
with pytest.raises(ValueError, match="not transient"):
await middleware.awrap_model_call(_make_request(), handler)
assert len(calls) == 1
@pytest.mark.asyncio
async def test_async_surfaces_model_unavailable_error(self) -> None:
middleware = ModelFallbackMiddleware(MagicMock())
async def handler(_req: object) -> object:
raise _anthropic_model_not_available_error()
result = await middleware.awrap_model_call(_make_request(), handler)
assert isinstance(result, AIMessage)
assert "selected Anthropic model is not available" in result.text
assert "data retention enabled" in result.text
@pytest.mark.asyncio
feat: port model-fallback resilience from upstream (#1694, #1695) (#161) * feat: port plan-review & workflow-approval UX (#135) Port six upstream commits onto dev: - c03a6be7 (already ported): keep plan guidance high-level - 546042a4: add workflow approval UI with diff preview, approval URLs, web review links, and polling for approval status during active runs - 216cf181: remove workflow token elevation; approved pushes pass through directly without proxy token rewriting - 3dbc0282: preserve plan redirects after login by accepting relative same-origin redirect_to values and rejecting blocked paths - bb104d93: submit plan comments with cmd+enter - 90cb6caa: terse Slack replies, shared content via save_plan outside plan mode (PLAN_STATUS_SHARED), reject shared-content mutations Refs: #135 * feat: port durable dispatch hardening and startup latency improvements Port five upstream PRs onto dev: - #1621 / #1658: durable dispatch with loopback webhook defense, create_durable_run helper, _config_with_prepare_run_id, degradation to None for relative/loopback completion webhook URLs - #1696: run-level completion webhook deduplication (replace claim-then-post with post-then-flag per run_id), DeferredErrorModel for graph-factory resilience, ToolRetryMiddleware for task subagents, TimeoutWrapupMiddleware for all three graphs - #1697: lazy-load __init__.py for agent.middleware, agent.tools, agent.dashboard (PEP 562); defer heavy imports (exa_py in web_search, agent.webapp in request_pr_review, deepagents in sandbox.py); add ttl_cache.py with stale-while-revalidate for tool loaders Refs: #137 * fix: restore login page render and clear CI lint/format The plan-review port removed the authRedirectUrl import from login.tsx but left its call site, crashing the login page at runtime (blank page, no 'Sign in to open-swe'). Pass the relative path straight to loginUrl, matching the plan route and the backend relative-redirect handling. Also drop an unused os import in the guard test and reformat workflow_push_guard.py to satisfy ruff. * feat: port model-fallback resilience from upstream (#1694, #1695) - Add httpx.TransportError to the transient-exception set so incomplete chunked reads on streamed responses trigger a fallback instead of cancelling the run (#1694 / c9f6dd86). - Rewrite fallback to alternate primary/fallback with exponential backoff instead of a single failover, so the agent survives multi-minute gateway outages spanning both providers (#1695 / c9a9a7cd). - Default backoff schedule (0, 5, 15, 30, 45) reaches past the gateway's ~30s recovery window; jittered ±25%. - On exhaustion, surface a terminal AIMessage explaining the outage instead of crashing — progress is checkpointed so the user can retrigger to continue. - Preserve fork conventions: Bedrock ClientError retryability check, sync wrap_model_call (using time.sleep instead of asyncio.sleep), and existing access-error surfacing for Anthropic, OpenAI, and Bedrock (botocore) provider errors. - Update triage ledger (c9f6dd86, c9a9a7cd → landed) and re-render triage.md. Refs: #139 * fix: align workflow-push-guard tests with dev's transient-elevation impl The dev merge auto-combined dev's elevation tests with the stale passthrough tests inherited from the durable-dispatch branch; the passthrough tests contradict dev's restored _run_with_workflow_token impl. Take dev's test file. * fix: drop dead ttl_cache module; make fallback backoff jitter two-sided ttl_cache.py was re-introduced via the dev merge but dev/#160 deliberately removed it as dead code (no agent importer). Remove it to match dev. Also make _jittered_delay symmetric (±25%) to match its docstring. * chore(upstream-sync): triage 4 new upstream commits (#1708-#1713) Synced ledger to upstream/main (71e3b818). New rows all deferred: - #1708 add GPT-5.6 OpenAI models (FLAG-HUMAN: fork picker is Bedrock/Fireworks-only) - #1709 stale admin model defaults after upgrades - #1710 bump langchain-fireworks 1.4.4 - #1713 align reviewer eval with published findings --------- Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com> Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-07-09 18:28:21 -04:00
async def test_async_retries_primary_after_fallback_failure(self) -> None:
"""If the fallback also fails transiently, retry the primary instead of crashing."""
fallback_model = MagicMock(name="fallback_model")
middleware = ModelFallbackMiddleware(fallback_model, backoff_schedule=(0.0, 0.0, 0.0))
calls: list[object] = []
good_response = MagicMock(result=[AIMessage(content="ok from primary retry")])
async def handler(req: object) -> object:
calls.append(req)
if len(calls) <= 2: # primary fails, then fallback fails
raise _openai_5xx()
return good_response
request = _make_request()
result = await middleware.awrap_model_call(request, handler)
assert result is good_response
assert len(calls) == 3
# Attempts alternate primary -> fallback -> primary.
assert calls[0] is request
assert calls[1] is request.override.return_value
assert calls[2] is request
@pytest.mark.asyncio
async def test_async_exhaustion_returns_outage_message(self) -> None:
"""After exhausting all attempts, the run ends with a visible message, not a crash."""
middleware = ModelFallbackMiddleware(MagicMock(), backoff_schedule=(0.0, 0.0))
calls: list[object] = []
async def handler(req: object) -> object:
calls.append(req)
raise _openai_5xx()
result = await middleware.awrap_model_call(_make_request(), handler)
assert len(calls) == 3
assert isinstance(result, AIMessage)
assert "retrigger" in result.text
@pytest.mark.asyncio
async def test_async_exhaustion_raises_when_message_disabled(self) -> None:
middleware = ModelFallbackMiddleware(
MagicMock(), backoff_schedule=(0.0,), surface_outage_message=False
)
calls: list[object] = []
async def handler(req: object) -> object:
calls.append(req)
raise _openai_5xx()
with pytest.raises(openai.APIStatusError):
await middleware.awrap_model_call(_make_request(), handler)
assert len(calls) == 2
def test_sync_falls_over_on_overloaded(self) -> None:
fallback_model = MagicMock(name="fallback_model")
middleware = ModelFallbackMiddleware(fallback_model)
calls: list[object] = []
good_response = MagicMock(result=[AIMessage(content="ok")])
def handler(req: object) -> object:
calls.append(req)
if len(calls) == 1:
raise _anthropic_overloaded()
return good_response
request = _make_request()
result = middleware.wrap_model_call(request, handler)
assert result is good_response
assert len(calls) == 2
request.override.assert_called_once_with(model=fallback_model)