| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499 |
- from __future__ import annotations
- import gzip
- from pathlib import Path
- from typing import Any
- import httpx
- import pytest
- pytest.importorskip("fastapi.testclient")
- from fastapi import FastAPI
- from fastapi.testclient import TestClient
- from agency_swarm import run_fastapi
- from agency_swarm.integrations import openclaw as openclaw_mod
- from agency_swarm.integrations.openclaw import (
- OpenClawIntegrationConfig,
- OpenClawRuntime,
- attach_openclaw_to_fastapi,
- normalize_openclaw_responses_request,
- )
- from tests.integration.fastapi._openclaw_test_support import _build_openclaw_config
- def test_openclaw_config_manual_construction_defaults_to_full_tool_mode(tmp_path: Path) -> None:
- home_dir = tmp_path / "openclaw"
- config = OpenClawIntegrationConfig(
- autostart=False,
- host="127.0.0.1",
- port=18789,
- gateway_token="gateway-token",
- home_dir=home_dir,
- state_dir=home_dir / "state",
- config_path=home_dir / "openclaw.json",
- log_path=home_dir / "logs" / "openclaw-gateway.log",
- startup_timeout_seconds=5.0,
- proxy_timeout_seconds=30.0,
- default_model="openclaw:main",
- provider_model="openai/gpt-5.4-mini",
- gateway_command="openclaw gateway",
- )
- assert config.tool_mode == "full"
- def test_openclaw_health_returns_runtime_snapshot(tmp_path: Path) -> None:
- runtime = OpenClawRuntime(
- OpenClawIntegrationConfig(
- autostart=False,
- host="127.0.0.1",
- port=18789,
- gateway_token="gateway-token",
- home_dir=tmp_path / "openclaw",
- state_dir=tmp_path / "openclaw" / "state",
- config_path=tmp_path / "openclaw" / "openclaw.json",
- log_path=tmp_path / "openclaw" / "logs" / "openclaw-gateway.log",
- startup_timeout_seconds=5.0,
- proxy_timeout_seconds=30.0,
- default_model="openclaw:main",
- provider_model="openai/gpt-5.4-mini",
- gateway_command="openclaw gateway",
- )
- )
- payload = runtime.health()
- assert payload["running"] is False
- assert payload["upstream_base_url"] == "http://127.0.0.1:18789"
- assert payload["home_dir"].endswith("openclaw")
- assert payload["state_dir"].endswith("openclaw/state")
- def test_openclaw_proxy_mount_paths_exist(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: True)
- app = FastAPI()
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- health = client.get("/openclaw/health")
- assert health.status_code == 200
- assert health.json()["upstream_base_url"] == "http://127.0.0.1:18789"
- responses = client.post("/openclaw/v1/responses", json={})
- assert responses.status_code == 400
- openapi = client.get("/openapi.json")
- assert openapi.status_code == 200
- paths = openapi.json()["paths"]
- assert "/openclaw/v1/responses" in paths
- assert "/openclaw/health" in paths
- def test_openclaw_health_reports_unhealthy_when_upstream_is_down(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
- ) -> None:
- monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: False)
- app = FastAPI()
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- health = client.get("/openclaw/health")
- assert health.status_code == 503
- assert health.json() == {"ok": False, "upstream_base_url": "http://127.0.0.1:18789"}
- def test_openclaw_proxy_filters_request_keys_and_normalizes_payload(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
- ) -> None:
- captured: dict[str, Any] = {}
- class _FakeAsyncClient:
- def __init__(self, *args: Any, **kwargs: Any) -> None:
- return None
- async def __aenter__(self) -> _FakeAsyncClient:
- return self
- async def __aexit__(self, exc_type, exc, tb) -> None:
- return None
- async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
- captured["url"] = url
- captured["headers"] = headers
- captured["json"] = json
- return httpx.Response(
- status_code=200,
- content=gzip.compress(b'{"ok": true}'),
- headers={
- "content-type": "application/json",
- "retry-after": "3",
- "x-request-id": "req-non-stream",
- "content-encoding": "gzip",
- },
- )
- monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
- app = FastAPI()
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- response = client.post(
- "/openclaw/v1/responses",
- json={
- "model": "openclaw:main",
- "input": [{"role": "user", "content": [{"text": "hello"}]}],
- "stream": False,
- "tools": [
- {"type": "function", "function": {"name": "calc", "parameters": {"type": "object"}}},
- {
- "type": "function",
- "name": "summarize",
- "description": "Summarize text",
- "parameters": {"type": "object", "properties": {"text": {"type": "string"}}},
- "strict": True,
- },
- ],
- "tool_choice": {"type": "function", "name": "calc"},
- "metadata": {"attempt": 1, "scope": {"a": 1}, "label": "ok"},
- "include": ["response.output_text"],
- "parallel_tool_calls": True,
- },
- )
- assert response.status_code == 200
- assert response.headers["retry-after"] == "3"
- assert response.headers["x-request-id"] == "req-non-stream"
- assert response.headers.get("content-encoding") is None
- forwarded = captured["json"]
- assert set(forwarded.keys()) <= {
- "model",
- "input",
- "instructions",
- "tools",
- "tool_choice",
- "stream",
- "max_output_tokens",
- "max_tool_calls",
- "user",
- "temperature",
- "top_p",
- "metadata",
- "store",
- "previous_response_id",
- "reasoning",
- "truncation",
- }
- assert "include" not in forwarded
- assert "parallel_tool_calls" not in forwarded
- assert forwarded["model"] == "openai/gpt-5.4-mini"
- assert forwarded["input"] == [
- {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}
- ]
- assert forwarded["tools"] == [
- {"type": "function", "function": {"name": "calc", "parameters": {"type": "object"}}},
- {
- "type": "function",
- "function": {
- "name": "summarize",
- "description": "Summarize text",
- "parameters": {"type": "object", "properties": {"text": {"type": "string"}}},
- "strict": True,
- },
- },
- ]
- assert forwarded["tool_choice"] == {"type": "function", "function": {"name": "calc"}}
- assert forwarded["metadata"] == {"attempt": "1", "scope": '{"a": 1}', "label": "ok"}
- assert captured["headers"]["Authorization"] == "Bearer gateway-token"
- assert captured["url"] == "http://127.0.0.1:18789/v1/responses"
- def test_openclaw_proxy_forwards_encrypted_reasoning_include(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
- ) -> None:
- captured: dict[str, Any] = {}
- class _FakeAsyncClient:
- def __init__(self, *args: Any, **kwargs: Any) -> None:
- return None
- async def __aenter__(self) -> _FakeAsyncClient:
- return self
- async def __aexit__(self, exc_type, exc, tb) -> None:
- return None
- async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
- captured["json"] = json
- return httpx.Response(status_code=200, json={"ok": True})
- monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
- app = FastAPI()
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- response = client.post(
- "/openclaw/v1/responses",
- json={
- "model": "openclaw:main",
- "input": "hello",
- "include": ["response.output_text", "reasoning.encrypted_content"],
- "store": False,
- },
- )
- assert response.status_code == 200
- assert captured["json"]["include"] == ["reasoning.encrypted_content"]
- assert captured["json"]["store"] is False
- def test_openclaw_proxy_preserves_full_history_without_synthesizing_session_fields(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
- ) -> None:
- captured: dict[str, Any] = {}
- class _FakeAsyncClient:
- def __init__(self, *args: Any, **kwargs: Any) -> None:
- return None
- async def __aenter__(self) -> _FakeAsyncClient:
- return self
- async def __aexit__(self, exc_type, exc, tb) -> None:
- return None
- async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
- captured["json"] = json
- return httpx.Response(status_code=200, json={"ok": True})
- monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
- app = FastAPI()
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- response = client.post(
- "/openclaw/v1/responses",
- json={
- "model": "openclaw:main",
- "input": [
- {"role": "user", "content": [{"text": "Hi"}]},
- {"role": "assistant", "content": [{"text": "Hello"}]},
- {"role": "user", "content": [{"text": "Continue with the same chat"}]},
- ],
- "stream": False,
- },
- )
- assert response.status_code == 200
- forwarded = captured["json"]
- assert forwarded["input"] == [
- {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hi"}]},
- {"type": "message", "role": "assistant", "content": [{"type": "input_text", "text": "Hello"}]},
- {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Continue with the same chat"}]},
- ]
- assert "user" not in forwarded
- assert "previous_response_id" not in forwarded
- def test_openclaw_proxy_rejects_unsupported_tool_types(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- called = {"upstream": False}
- class _FakeAsyncClient:
- def __init__(self, *args: Any, **kwargs: Any) -> None:
- return None
- async def __aenter__(self) -> _FakeAsyncClient:
- return self
- async def __aexit__(self, exc_type, exc, tb) -> None:
- return None
- async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
- called["upstream"] = True
- return httpx.Response(status_code=200, json={"ok": True})
- monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
- app = FastAPI()
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- response = client.post(
- "/openclaw/v1/responses",
- json={
- "model": "openclaw:main",
- "input": "hello",
- "tools": [{"type": "web_search"}],
- },
- )
- assert response.status_code == 400
- assert "not supported by OpenClaw" in response.json()["detail"]
- assert called["upstream"] is False
- def test_openclaw_proxy_rejects_non_list_tools(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- called = {"upstream": False}
- class _FakeAsyncClient:
- def __init__(self, *args: Any, **kwargs: Any) -> None:
- return None
- async def __aenter__(self) -> _FakeAsyncClient:
- return self
- async def __aexit__(self, exc_type, exc, tb) -> None:
- return None
- async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
- called["upstream"] = True
- return httpx.Response(status_code=200, json={"ok": True})
- monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
- app = FastAPI()
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- response = client.post(
- "/openclaw/v1/responses",
- json={
- "model": "openclaw:main",
- "input": "hello",
- "tools": {"type": "function", "name": "calc"},
- },
- )
- assert response.status_code == 400
- assert "tools must be a list" in response.json()["detail"]
- assert called["upstream"] is False
- def test_cancel_endpoint_behavior_remains_on_existing_agency_route(
- agency_factory,
- tmp_path: Path,
- ) -> None:
- app = run_fastapi(agencies={"test_agency": agency_factory}, return_app=True, app_token_env="")
- assert app is not None
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- cancel = client.post("/test_agency/cancel_response_stream", json={"run_id": "missing-run"})
- assert cancel.status_code == 404
- assert "not found" in cancel.json()["detail"].lower()
- proxy_cancel = client.post("/openclaw/cancel_response_stream", json={"run_id": "missing-run"})
- assert proxy_cancel.status_code == 404
- def test_openclaw_proxy_uses_app_token_auth_when_attached_to_run_fastapi(
- monkeypatch: pytest.MonkeyPatch,
- agency_factory,
- tmp_path: Path,
- ) -> None:
- monkeypatch.setenv("APP_TOKEN", "secret-token")
- monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: True)
- class _FakeAsyncClient:
- def __init__(self, *args: Any, **kwargs: Any) -> None:
- return None
- async def __aenter__(self) -> _FakeAsyncClient:
- return self
- async def __aexit__(self, exc_type, exc, tb) -> None:
- return None
- async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response:
- return httpx.Response(
- status_code=200,
- content=b'{"ok": true}',
- headers={"content-type": "application/json"},
- )
- monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient)
- app = run_fastapi(agencies={"secure": agency_factory}, return_app=True, app_token_env="APP_TOKEN")
- assert app is not None
- attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path))
- client = TestClient(app)
- unauthorized = client.post("/openclaw/v1/responses", json={"model": "openclaw:main", "input": "hello"})
- assert unauthorized.status_code in (401, 403)
- health_unauthorized = client.get("/openclaw/health")
- assert health_unauthorized.status_code in (401, 403)
- authorized = client.post(
- "/openclaw/v1/responses",
- json={"model": "openclaw:main", "input": "hello"},
- headers={"Authorization": "Bearer secret-token"},
- )
- assert authorized.status_code == 200
- health_authorized = client.get("/openclaw/health", headers={"Authorization": "Bearer secret-token"})
- assert health_authorized.status_code == 200
- assert health_authorized.json() == {"ok": True, "upstream_base_url": "http://127.0.0.1:18789"}
- def test_openclaw_normalization_validation_error_paths() -> None:
- with pytest.raises(ValueError, match="model is required"):
- normalize_openclaw_responses_request({"input": "hello"})
- with pytest.raises(ValueError, match="input is required"):
- normalize_openclaw_responses_request({"model": "openclaw:main"})
- with pytest.raises(ValueError, match="input must be a string or list"):
- normalize_openclaw_responses_request({"model": "openclaw:main", "input": {"bad": "shape"}})
- with pytest.raises(ValueError, match="input list items must be JSON objects"):
- normalize_openclaw_responses_request({"model": "openclaw:main", "input": ["bad"]})
- with pytest.raises(ValueError, match="input message role must be a non-empty string"):
- normalize_openclaw_responses_request(
- {"model": "openclaw:main", "input": [{"type": "message", "content": "missing role"}]}
- )
- with pytest.raises(ValueError, match="input message content must be a string or list"):
- normalize_openclaw_responses_request(
- {"model": "openclaw:main", "input": [{"role": "user", "content": {"bad": "shape"}}]}
- )
- normalized = normalize_openclaw_responses_request(
- {
- "model": "openclaw:main",
- "input": "hello",
- "tool_choice": "unsupported",
- "metadata": "bad-metadata",
- "include": "reasoning.encrypted_content",
- }
- )
- assert "tool_choice" not in normalized
- assert "metadata" not in normalized
- assert "include" not in normalized
- def test_openclaw_header_helpers() -> None:
- assert openclaw_mod._make_upstream_headers("") == {"Content-Type": "application/json"}
- assert openclaw_mod._make_upstream_headers("token") == {
- "Content-Type": "application/json",
- "Authorization": "Bearer token",
- }
- upstream = httpx.Response(
- status_code=200,
- content=gzip.compress(b"ok"),
- headers={
- "content-type": "application/json",
- "x-request-id": "req-1",
- "content-encoding": "gzip",
- "content-length": "2",
- },
- )
- assert "content-encoding" in openclaw_mod._passthrough_response_headers(upstream, decoded_body=False)
- assert "content-encoding" not in openclaw_mod._passthrough_response_headers(upstream, decoded_body=True)
|