Files
openswarm/backend/tests/test_anthropic_proxy.py

412 lines
15 KiB
Python

"""Tests for `backend.apps.agents.anthropic_proxy`.
The proxy splits Anthropic-format traffic between the OpenSwarm Pro
cloud proxy (for Claude models on a Pro subscription) and 9Router
(everything else).
Coverage targets:
- `_is_claude_model` parameterized over expected matches/non-matches
- `_pick_upstream`:
- openswarm-pro + Claude → Pro proxy with bearer token
- non-Claude model → 9Router with `x-api-key: 9router`
- Claude + own_key → 9Router (Pro fallthrough)
- `_healthcheck` returns 200
- non-streaming proxy:
- body round-trips
- JSON content-type bodies parsed; non-JSON wrapped as `{"raw": ...}`
- hop headers / x-api-key / authorization stripped before forward
- timeout → 504, generic exception → 502
- streaming proxy:
- returns StreamingResponse with chunks from upstream
- `stream:true` honored
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from backend.apps.agents import anthropic_proxy as proxy_mod
from backend.apps.agents.anthropic_proxy import (
_HOP_HEADERS,
_is_claude_model,
_pick_upstream,
)
from backend.apps.settings.models import AppSettings
# ---------------------------------------------------------------------------
# _is_claude_model
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"model,expected",
[
("claude-sonnet-4-6", True),
("claude/claude-3", True),
("claude-opus-4-6", True),
("sonnet", True),
("opus", True),
("haiku", True),
("cc/claude-sonnet-4-6", True),
("CLAUDE-haiku-4-5", True), # case insensitive
(" sonnet ", True), # whitespace trimmed
("cx/gpt-5.4", False),
("gc/gemini-3-pro-preview", False),
("gpt-5.4", False),
("gemini-2.5-pro", False),
("", False),
],
)
def test_is_claude_model(model: str, expected: bool):
assert _is_claude_model(model) is expected
# ---------------------------------------------------------------------------
# _pick_upstream
# ---------------------------------------------------------------------------
def test_pick_upstream_claude_with_openswarm_pro_returns_proxy_with_bearer():
s = AppSettings(
connection_mode="openswarm-pro",
openswarm_bearer_token="bearer-x",
openswarm_proxy_url="https://api.openswarm.com",
)
with patch("backend.apps.settings.settings.load_settings", return_value=s):
base, headers = _pick_upstream("claude-sonnet-4-6")
assert base == "https://api.openswarm.com"
assert headers == {"Authorization": "Bearer bearer-x"}
def test_pick_upstream_claude_pro_strips_trailing_slash():
s = AppSettings(
connection_mode="openswarm-pro",
openswarm_bearer_token="bearer-x",
openswarm_proxy_url="https://api.openswarm.com/",
)
with patch("backend.apps.settings.settings.load_settings", return_value=s):
base, _ = _pick_upstream("sonnet")
assert base == "https://api.openswarm.com" # no trailing /
def test_pick_upstream_non_claude_returns_9router():
s = AppSettings(connection_mode="openswarm-pro", openswarm_bearer_token="bearer-x")
with patch("backend.apps.settings.settings.load_settings", return_value=s):
base, headers = _pick_upstream("cx/gpt-5.4")
assert base == "http://127.0.0.1:20128"
assert headers == {"x-api-key": "9router"}
def test_pick_upstream_claude_own_key_falls_back_to_9router():
"""When connection_mode != openswarm-pro, a Claude model still routes
through 9Router (the user might have a Claude subscription wired)."""
s = AppSettings(connection_mode="own_key", anthropic_api_key="sk-foo")
with patch("backend.apps.settings.settings.load_settings", return_value=s):
base, headers = _pick_upstream("claude-sonnet-4-6")
assert base == "http://127.0.0.1:20128"
assert headers == {"x-api-key": "9router"}
def test_pick_upstream_pro_without_bearer_falls_back_to_9router():
"""openswarm-pro mode but no bearer token → can't reach the Pro proxy,
fall through to 9Router."""
s = AppSettings(connection_mode="openswarm-pro") # no bearer
with patch("backend.apps.settings.settings.load_settings", return_value=s):
base, headers = _pick_upstream("claude-sonnet-4-6")
assert base == "http://127.0.0.1:20128"
assert headers == {"x-api-key": "9router"}
def test_pick_upstream_pro_default_proxy_url():
"""Empty `openswarm_proxy_url` defaults to https://api.openswarm.com."""
s = AppSettings(
connection_mode="openswarm-pro",
openswarm_bearer_token="bearer-x",
)
with patch("backend.apps.settings.settings.load_settings", return_value=s):
base, _ = _pick_upstream("claude-sonnet-4-6")
assert base == "https://api.openswarm.com"
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
def test_healthcheck_via_test_client(client):
"""GET on the proxy root must return 200 (CLI healthcheck path)."""
r = client.get("/api/anthropic-proxy")
assert r.status_code in (200, 307, 308)
if r.status_code == 200:
assert r.json() == {"ok": True}
def test_healthcheck_via_test_client_trailing_slash(client):
r = client.get("/api/anthropic-proxy/")
assert r.status_code == 200
assert r.json() == {"ok": True}
# ---------------------------------------------------------------------------
# Non-streaming proxy
# ---------------------------------------------------------------------------
def _make_async_client_mock(response):
"""Build a context-managed async client whose `request` returns
`response`. Mirrors `httpx.AsyncClient(...)` ergonomics."""
inst = MagicMock()
inst.request = AsyncMock(return_value=response)
inst.__aenter__ = AsyncMock(return_value=inst)
inst.__aexit__ = AsyncMock(return_value=False)
return inst
def test_proxy_non_streaming_routes_claude_to_pro_proxy(client):
"""Claude model + Pro mode → upstream URL is the Pro proxy with
bearer auth. Body and headers round-trip; x-api-key is stripped."""
s = AppSettings(
connection_mode="openswarm-pro",
openswarm_bearer_token="bearer-x",
openswarm_proxy_url="https://api.openswarm.com",
)
upstream_resp = httpx.Response(
status_code=200,
headers={"content-type": "application/json"},
content=b'{"id": "msg_1", "model": "claude-sonnet-4-6"}',
)
captured: dict[str, Any] = {}
async def fake_request(method, url, content=None, headers=None, params=None):
captured["method"] = method
captured["url"] = url
captured["body"] = content
captured["headers"] = headers or {}
return upstream_resp
fake_client = MagicMock()
fake_client.request = AsyncMock(side_effect=fake_request)
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.apps.settings.settings.load_settings", return_value=s), \
patch.object(proxy_mod.httpx, "AsyncClient", return_value=fake_client):
r = client.post(
"/api/anthropic-proxy/v1/messages",
json={"model": "claude-sonnet-4-6", "messages": [{"role": "user", "content": "hi"}]},
headers={"x-api-key": "should-not-leak", "x-extra": "passthrough"},
)
assert r.status_code == 200
assert r.json() == {"id": "msg_1", "model": "claude-sonnet-4-6"}
# Routed to Pro proxy
assert captured["url"] == "https://api.openswarm.com/v1/messages"
# Bearer auth attached
assert captured["headers"].get("Authorization") == "Bearer bearer-x"
# x-api-key NEVER reaches upstream
keys = {k.lower() for k in captured["headers"].keys()}
assert "x-api-key" not in keys
# Hop headers stripped
for hop in ("host", "content-length", "connection"):
assert hop not in keys
# Custom headers passed through
assert captured["headers"].get("x-extra") == "passthrough"
# Body round-tripped
assert json.loads(captured["body"]) == {
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hi"}],
}
def test_proxy_non_streaming_routes_non_claude_to_9router(client):
"""Non-Claude model → 9Router with x-api-key=9router header."""
s = AppSettings(connection_mode="openswarm-pro", openswarm_bearer_token="bearer-x")
upstream_resp = httpx.Response(
status_code=200,
headers={"content-type": "application/json"},
content=b'{"id": "msg_2"}',
)
captured: dict[str, Any] = {}
async def fake_request(method, url, content=None, headers=None, params=None):
captured["url"] = url
captured["headers"] = headers or {}
return upstream_resp
fake_client = MagicMock()
fake_client.request = AsyncMock(side_effect=fake_request)
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.apps.settings.settings.load_settings", return_value=s), \
patch.object(proxy_mod.httpx, "AsyncClient", return_value=fake_client):
r = client.post(
"/api/anthropic-proxy/v1/messages",
json={"model": "cx/gpt-5.4"},
)
assert r.status_code == 200
assert captured["url"] == "http://127.0.0.1:20128/v1/messages"
assert captured["headers"].get("x-api-key") == "9router"
assert "Authorization" not in captured["headers"]
def test_proxy_non_streaming_non_json_body_wrapped_in_raw(client):
"""Upstream returning text/plain → body wrapped as {"raw": "..."}."""
s = AppSettings()
upstream_resp = httpx.Response(
status_code=200,
headers={"content-type": "text/plain"},
content=b"hello",
)
fake_client = MagicMock()
fake_client.request = AsyncMock(return_value=upstream_resp)
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.apps.settings.settings.load_settings", return_value=s), \
patch.object(proxy_mod.httpx, "AsyncClient", return_value=fake_client):
r = client.post(
"/api/anthropic-proxy/v1/messages",
json={"model": "cx/gpt-5.4"},
)
assert r.status_code == 200
assert r.json() == {"raw": "hello"}
def test_proxy_non_streaming_timeout_returns_504(client):
s = AppSettings()
fake_client = MagicMock()
fake_client.request = AsyncMock(side_effect=httpx.TimeoutException("timed out"))
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.apps.settings.settings.load_settings", return_value=s), \
patch.object(proxy_mod.httpx, "AsyncClient", return_value=fake_client):
r = client.post(
"/api/anthropic-proxy/v1/messages",
json={"model": "cx/gpt-5.4"},
)
assert r.status_code == 504
assert r.json()["error"] == "upstream timeout"
def test_proxy_non_streaming_generic_exception_returns_502(client):
s = AppSettings()
fake_client = MagicMock()
fake_client.request = AsyncMock(side_effect=RuntimeError("kaboom"))
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.apps.settings.settings.load_settings", return_value=s), \
patch.object(proxy_mod.httpx, "AsyncClient", return_value=fake_client):
r = client.post(
"/api/anthropic-proxy/v1/messages",
json={"model": "cx/gpt-5.4"},
)
assert r.status_code == 502
assert "kaboom" in r.json()["error"]
def test_proxy_non_streaming_non_json_body_doesnt_break_routing(client):
"""If the body isn't valid JSON, model is "" → routes to 9Router
via the non-Claude branch. Must not crash."""
s = AppSettings()
upstream_resp = httpx.Response(status_code=200, content=b"{}",
headers={"content-type": "application/json"})
fake_client = MagicMock()
fake_client.request = AsyncMock(return_value=upstream_resp)
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.apps.settings.settings.load_settings", return_value=s), \
patch.object(proxy_mod.httpx, "AsyncClient", return_value=fake_client):
r = client.post(
"/api/anthropic-proxy/v1/messages",
content=b"not-json-at-all",
headers={"content-type": "application/json"},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Streaming proxy
# ---------------------------------------------------------------------------
def test_proxy_streaming_returns_chunks(client):
"""`stream:true` body → StreamingResponse with raw chunks from
upstream's aiter_raw."""
s = AppSettings()
upstream = MagicMock()
upstream.status_code = 200
upstream.headers = {"content-type": "text/event-stream"}
async def aiter_raw():
yield b"event: message\n"
yield b"data: hello\n\n"
upstream.aiter_raw = aiter_raw
upstream.aclose = AsyncMock()
fake_client = MagicMock()
fake_client.build_request = MagicMock(return_value=MagicMock())
fake_client.send = AsyncMock(return_value=upstream)
fake_client.aclose = AsyncMock()
with patch("backend.apps.settings.settings.load_settings", return_value=s), \
patch.object(proxy_mod.httpx, "AsyncClient", return_value=fake_client):
r = client.post(
"/api/anthropic-proxy/v1/messages",
json={"model": "cx/gpt-5.4", "stream": True},
)
assert r.status_code == 200
body = r.content
assert b"event: message" in body
assert b"data: hello" in body
fake_client.send.assert_awaited_once()
def test_proxy_streaming_stream_false_takes_non_streaming_path(client):
"""Body with `stream:false` → goes through the non-streaming branch
(no StreamingResponse)."""
s = AppSettings()
upstream_resp = httpx.Response(
status_code=200,
headers={"content-type": "application/json"},
content=b'{"ok": true}',
)
fake_client = MagicMock()
fake_client.request = AsyncMock(return_value=upstream_resp)
fake_client.send = AsyncMock(side_effect=AssertionError("send must NOT be called"))
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.apps.settings.settings.load_settings", return_value=s), \
patch.object(proxy_mod.httpx, "AsyncClient", return_value=fake_client):
r = client.post(
"/api/anthropic-proxy/v1/messages",
json={"model": "cx/gpt-5.4", "stream": False},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Constants sanity
# ---------------------------------------------------------------------------
def test_hop_headers_lowercase():
"""Hop-by-hop headers must be lowercased so the comparison in
proxy() works (`k.lower() in _HOP_HEADERS`)."""
for h in _HOP_HEADERS:
assert h == h.lower()