mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 16:13:15 +00:00
* 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>
234 lines
8.7 KiB
Python
234 lines
8.7 KiB
Python
"""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
|
|
|
|
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
|
|
|
|
@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
|
|
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)
|