test_openclaw_proxy_requests.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. from __future__ import annotations
  2. import gzip
  3. from pathlib import Path
  4. from typing import Any
  5. import httpx
  6. import pytest
  7. pytest.importorskip("fastapi.testclient")
  8. from fastapi import FastAPI
  9. from fastapi.testclient import TestClient
  10. from agency_swarm import run_fastapi
  11. from agency_swarm.integrations import openclaw as openclaw_mod
  12. from agency_swarm.integrations.openclaw import (
  13. OpenClawIntegrationConfig,
  14. OpenClawRuntime,
  15. attach_openclaw_to_fastapi,
  16. normalize_openclaw_responses_request,
  17. )
  18. from tests.integration.fastapi._openclaw_test_support import _build_openclaw_config
  19. def test_openclaw_config_manual_construction_defaults_to_full_tool_mode(tmp_path: Path) -> None:
  20. home_dir = tmp_path / "openclaw"
  21. config = OpenClawIntegrationConfig(
  22. autostart=False,
  23. host="127.0.0.1",
  24. port=18789,
  25. gateway_token="gateway-token",
  26. home_dir=home_dir,
  27. state_dir=home_dir / "state",
  28. config_path=home_dir / "openclaw.json",
  29. log_path=home_dir / "logs" / "openclaw-gateway.log",
  30. startup_timeout_seconds=5.0,
  31. proxy_timeout_seconds=30.0,
  32. default_model="openclaw:main",
  33. provider_model="openai/gpt-5.4-mini",
  34. gateway_command="openclaw gateway",
  35. )
  36. assert config.tool_mode == "full"
  37. def test_openclaw_health_returns_runtime_snapshot(tmp_path: Path) -> None:
  38. runtime = OpenClawRuntime(
  39. OpenClawIntegrationConfig(
  40. autostart=False,
  41. host="127.0.0.1",
  42. port=18789,
  43. gateway_token="gateway-token",
  44. home_dir=tmp_path / "openclaw",
  45. state_dir=tmp_path / "openclaw" / "state",
  46. config_path=tmp_path / "openclaw" / "openclaw.json",
  47. log_path=tmp_path / "openclaw" / "logs" / "openclaw-gateway.log",
  48. startup_timeout_seconds=5.0,
  49. proxy_timeout_seconds=30.0,
  50. default_model="openclaw:main",
  51. provider_model="openai/gpt-5.4-mini",
  52. gateway_command="openclaw gateway",
  53. )
  54. )
  55. payload = runtime.health()
  56. assert payload["running"] is False
  57. assert payload["upstream_base_url"] == "http://127.0.0.1:18789"
  58. assert payload["home_dir"].endswith("openclaw")
  59. assert payload["state_dir"].endswith("openclaw/state")
  60. def test_openclaw_proxy_mount_paths_exist(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
  61. monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: True)
  62. app = FastAPI()
  63. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  64. client = TestClient(app)
  65. health = client.get("/openclaw/health")
  66. assert health.status_code == 200
  67. assert health.json()["upstream_base_url"] == "http://127.0.0.1:18789"
  68. responses = client.post("/openclaw/v1/responses", json={})
  69. assert responses.status_code == 400
  70. openapi = client.get("/openapi.json")
  71. assert openapi.status_code == 200
  72. paths = openapi.json()["paths"]
  73. assert "/openclaw/v1/responses" in paths
  74. assert "/openclaw/health" in paths
  75. def test_openclaw_health_reports_unhealthy_when_upstream_is_down(
  76. monkeypatch: pytest.MonkeyPatch,
  77. tmp_path: Path,
  78. ) -> None:
  79. monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: False)
  80. app = FastAPI()
  81. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  82. client = TestClient(app)
  83. health = client.get("/openclaw/health")
  84. assert health.status_code == 503
  85. assert health.json() == {"ok": False, "upstream_base_url": "http://127.0.0.1:18789"}
  86. def test_openclaw_proxy_filters_request_keys_and_normalizes_payload(
  87. monkeypatch: pytest.MonkeyPatch,
  88. tmp_path: Path,
  89. ) -> None:
  90. captured: dict[str, Any] = {}
  91. class _FakeAsyncClient:
  92. def __init__(self, *args: Any, **kwargs: Any) -> None:
  93. return None
  94. async def __aenter__(self) -> _FakeAsyncClient:
  95. return self
  96. async def __aexit__(self, exc_type, exc, tb) -> None:
  97. return None
  98. async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
  99. captured["url"] = url
  100. captured["headers"] = headers
  101. captured["json"] = json
  102. return httpx.Response(
  103. status_code=200,
  104. content=gzip.compress(b'{"ok": true}'),
  105. headers={
  106. "content-type": "application/json",
  107. "retry-after": "3",
  108. "x-request-id": "req-non-stream",
  109. "content-encoding": "gzip",
  110. },
  111. )
  112. monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
  113. app = FastAPI()
  114. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  115. client = TestClient(app)
  116. response = client.post(
  117. "/openclaw/v1/responses",
  118. json={
  119. "model": "openclaw:main",
  120. "input": [{"role": "user", "content": [{"text": "hello"}]}],
  121. "stream": False,
  122. "tools": [
  123. {"type": "function", "function": {"name": "calc", "parameters": {"type": "object"}}},
  124. {
  125. "type": "function",
  126. "name": "summarize",
  127. "description": "Summarize text",
  128. "parameters": {"type": "object", "properties": {"text": {"type": "string"}}},
  129. "strict": True,
  130. },
  131. ],
  132. "tool_choice": {"type": "function", "name": "calc"},
  133. "metadata": {"attempt": 1, "scope": {"a": 1}, "label": "ok"},
  134. "include": ["response.output_text"],
  135. "parallel_tool_calls": True,
  136. },
  137. )
  138. assert response.status_code == 200
  139. assert response.headers["retry-after"] == "3"
  140. assert response.headers["x-request-id"] == "req-non-stream"
  141. assert response.headers.get("content-encoding") is None
  142. forwarded = captured["json"]
  143. assert set(forwarded.keys()) <= {
  144. "model",
  145. "input",
  146. "instructions",
  147. "tools",
  148. "tool_choice",
  149. "stream",
  150. "max_output_tokens",
  151. "max_tool_calls",
  152. "user",
  153. "temperature",
  154. "top_p",
  155. "metadata",
  156. "store",
  157. "previous_response_id",
  158. "reasoning",
  159. "truncation",
  160. }
  161. assert "include" not in forwarded
  162. assert "parallel_tool_calls" not in forwarded
  163. assert forwarded["model"] == "openai/gpt-5.4-mini"
  164. assert forwarded["input"] == [
  165. {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}
  166. ]
  167. assert forwarded["tools"] == [
  168. {"type": "function", "function": {"name": "calc", "parameters": {"type": "object"}}},
  169. {
  170. "type": "function",
  171. "function": {
  172. "name": "summarize",
  173. "description": "Summarize text",
  174. "parameters": {"type": "object", "properties": {"text": {"type": "string"}}},
  175. "strict": True,
  176. },
  177. },
  178. ]
  179. assert forwarded["tool_choice"] == {"type": "function", "function": {"name": "calc"}}
  180. assert forwarded["metadata"] == {"attempt": "1", "scope": '{"a": 1}', "label": "ok"}
  181. assert captured["headers"]["Authorization"] == "Bearer gateway-token"
  182. assert captured["url"] == "http://127.0.0.1:18789/v1/responses"
  183. def test_openclaw_proxy_forwards_encrypted_reasoning_include(
  184. monkeypatch: pytest.MonkeyPatch,
  185. tmp_path: Path,
  186. ) -> None:
  187. captured: dict[str, Any] = {}
  188. class _FakeAsyncClient:
  189. def __init__(self, *args: Any, **kwargs: Any) -> None:
  190. return None
  191. async def __aenter__(self) -> _FakeAsyncClient:
  192. return self
  193. async def __aexit__(self, exc_type, exc, tb) -> None:
  194. return None
  195. async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
  196. captured["json"] = json
  197. return httpx.Response(status_code=200, json={"ok": True})
  198. monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
  199. app = FastAPI()
  200. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  201. client = TestClient(app)
  202. response = client.post(
  203. "/openclaw/v1/responses",
  204. json={
  205. "model": "openclaw:main",
  206. "input": "hello",
  207. "include": ["response.output_text", "reasoning.encrypted_content"],
  208. "store": False,
  209. },
  210. )
  211. assert response.status_code == 200
  212. assert captured["json"]["include"] == ["reasoning.encrypted_content"]
  213. assert captured["json"]["store"] is False
  214. def test_openclaw_proxy_preserves_full_history_without_synthesizing_session_fields(
  215. monkeypatch: pytest.MonkeyPatch,
  216. tmp_path: Path,
  217. ) -> None:
  218. captured: dict[str, Any] = {}
  219. class _FakeAsyncClient:
  220. def __init__(self, *args: Any, **kwargs: Any) -> None:
  221. return None
  222. async def __aenter__(self) -> _FakeAsyncClient:
  223. return self
  224. async def __aexit__(self, exc_type, exc, tb) -> None:
  225. return None
  226. async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
  227. captured["json"] = json
  228. return httpx.Response(status_code=200, json={"ok": True})
  229. monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
  230. app = FastAPI()
  231. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  232. client = TestClient(app)
  233. response = client.post(
  234. "/openclaw/v1/responses",
  235. json={
  236. "model": "openclaw:main",
  237. "input": [
  238. {"role": "user", "content": [{"text": "Hi"}]},
  239. {"role": "assistant", "content": [{"text": "Hello"}]},
  240. {"role": "user", "content": [{"text": "Continue with the same chat"}]},
  241. ],
  242. "stream": False,
  243. },
  244. )
  245. assert response.status_code == 200
  246. forwarded = captured["json"]
  247. assert forwarded["input"] == [
  248. {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hi"}]},
  249. {"type": "message", "role": "assistant", "content": [{"type": "input_text", "text": "Hello"}]},
  250. {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Continue with the same chat"}]},
  251. ]
  252. assert "user" not in forwarded
  253. assert "previous_response_id" not in forwarded
  254. def test_openclaw_proxy_rejects_unsupported_tool_types(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
  255. called = {"upstream": False}
  256. class _FakeAsyncClient:
  257. def __init__(self, *args: Any, **kwargs: Any) -> None:
  258. return None
  259. async def __aenter__(self) -> _FakeAsyncClient:
  260. return self
  261. async def __aexit__(self, exc_type, exc, tb) -> None:
  262. return None
  263. async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
  264. called["upstream"] = True
  265. return httpx.Response(status_code=200, json={"ok": True})
  266. monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
  267. app = FastAPI()
  268. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  269. client = TestClient(app)
  270. response = client.post(
  271. "/openclaw/v1/responses",
  272. json={
  273. "model": "openclaw:main",
  274. "input": "hello",
  275. "tools": [{"type": "web_search"}],
  276. },
  277. )
  278. assert response.status_code == 400
  279. assert "not supported by OpenClaw" in response.json()["detail"]
  280. assert called["upstream"] is False
  281. def test_openclaw_proxy_rejects_non_list_tools(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
  282. called = {"upstream": False}
  283. class _FakeAsyncClient:
  284. def __init__(self, *args: Any, **kwargs: Any) -> None:
  285. return None
  286. async def __aenter__(self) -> _FakeAsyncClient:
  287. return self
  288. async def __aexit__(self, exc_type, exc, tb) -> None:
  289. return None
  290. async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
  291. called["upstream"] = True
  292. return httpx.Response(status_code=200, json={"ok": True})
  293. monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
  294. app = FastAPI()
  295. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  296. client = TestClient(app)
  297. response = client.post(
  298. "/openclaw/v1/responses",
  299. json={
  300. "model": "openclaw:main",
  301. "input": "hello",
  302. "tools": {"type": "function", "name": "calc"},
  303. },
  304. )
  305. assert response.status_code == 400
  306. assert "tools must be a list" in response.json()["detail"]
  307. assert called["upstream"] is False
  308. def test_cancel_endpoint_behavior_remains_on_existing_agency_route(
  309. agency_factory,
  310. tmp_path: Path,
  311. ) -> None:
  312. app = run_fastapi(agencies={"test_agency": agency_factory}, return_app=True, app_token_env="")
  313. assert app is not None
  314. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  315. client = TestClient(app)
  316. cancel = client.post("/test_agency/cancel_response_stream", json={"run_id": "missing-run"})
  317. assert cancel.status_code == 404
  318. assert "not found" in cancel.json()["detail"].lower()
  319. proxy_cancel = client.post("/openclaw/cancel_response_stream", json={"run_id": "missing-run"})
  320. assert proxy_cancel.status_code == 404
  321. def test_openclaw_proxy_uses_app_token_auth_when_attached_to_run_fastapi(
  322. monkeypatch: pytest.MonkeyPatch,
  323. agency_factory,
  324. tmp_path: Path,
  325. ) -> None:
  326. monkeypatch.setenv("APP_TOKEN", "secret-token")
  327. monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: True)
  328. class _FakeAsyncClient:
  329. def __init__(self, *args: Any, **kwargs: Any) -> None:
  330. return None
  331. async def __aenter__(self) -> _FakeAsyncClient:
  332. return self
  333. async def __aexit__(self, exc_type, exc, tb) -> None:
  334. return None
  335. async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
  336. return httpx.Response(
  337. status_code=200,
  338. content=b'{"ok": true}',
  339. headers={"content-type": "application/json"},
  340. )
  341. monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
  342. app = run_fastapi(agencies={"secure": agency_factory}, return_app=True, app_token_env="APP_TOKEN")
  343. assert app is not None
  344. attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
  345. client = TestClient(app)
  346. unauthorized = client.post("/openclaw/v1/responses", json={"model": "openclaw:main", "input": "hello"})
  347. assert unauthorized.status_code in (401, 403)
  348. health_unauthorized = client.get("/openclaw/health")
  349. assert health_unauthorized.status_code in (401, 403)
  350. authorized = client.post(
  351. "/openclaw/v1/responses",
  352. json={"model": "openclaw:main", "input": "hello"},
  353. headers={"Authorization": "Bearer secret-token"},
  354. )
  355. assert authorized.status_code == 200
  356. health_authorized = client.get("/openclaw/health", headers={"Authorization": "Bearer secret-token"})
  357. assert health_authorized.status_code == 200
  358. assert health_authorized.json() == {"ok": True, "upstream_base_url": "http://127.0.0.1:18789"}
  359. def test_openclaw_normalization_validation_error_paths() -> None:
  360. with pytest.raises(ValueError, match="model is required"):
  361. normalize_openclaw_responses_request({"input": "hello"})
  362. with pytest.raises(ValueError, match="input is required"):
  363. normalize_openclaw_responses_request({"model": "openclaw:main"})
  364. with pytest.raises(ValueError, match="input must be a string or list"):
  365. normalize_openclaw_responses_request({"model": "openclaw:main", "input": {"bad": "shape"}})
  366. with pytest.raises(ValueError, match="input list items must be JSON objects"):
  367. normalize_openclaw_responses_request({"model": "openclaw:main", "input": ["bad"]})
  368. with pytest.raises(ValueError, match="input message role must be a non-empty string"):
  369. normalize_openclaw_responses_request(
  370. {"model": "openclaw:main", "input": [{"type": "message", "content": "missing role"}]}
  371. )
  372. with pytest.raises(ValueError, match="input message content must be a string or list"):
  373. normalize_openclaw_responses_request(
  374. {"model": "openclaw:main", "input": [{"role": "user", "content": {"bad": "shape"}}]}
  375. )
  376. normalized = normalize_openclaw_responses_request(
  377. {
  378. "model": "openclaw:main",
  379. "input": "hello",
  380. "tool_choice": "unsupported",
  381. "metadata": "bad-metadata",
  382. "include": "reasoning.encrypted_content",
  383. }
  384. )
  385. assert "tool_choice" not in normalized
  386. assert "metadata" not in normalized
  387. assert "include" not in normalized
  388. def test_openclaw_header_helpers() -> None:
  389. assert openclaw_mod._make_upstream_headers("") == {"Content-Type": "application/json"}
  390. assert openclaw_mod._make_upstream_headers("token") == {
  391. "Content-Type": "application/json",
  392. "Authorization": "Bearer token",
  393. }
  394. upstream = httpx.Response(
  395. status_code=200,
  396. content=gzip.compress(b"ok"),
  397. headers={
  398. "content-type": "application/json",
  399. "x-request-id": "req-1",
  400. "content-encoding": "gzip",
  401. "content-length": "2",
  402. },
  403. )
  404. assert "content-encoding" in openclaw_mod._passthrough_response_headers(upstream, decoded_body=False)
  405. assert "content-encoding" not in openclaw_mod._passthrough_response_headers(upstream, decoded_body=True)