test_fastapi_client_config.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. """End-to-end integration tests for FastAPI `client_config` behavior."""
  2. from __future__ import annotations
  3. import json
  4. import threading
  5. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  6. from types import SimpleNamespace
  7. import pytest
  8. pytest.importorskip("fastapi.testclient")
  9. from fastapi.testclient import TestClient
  10. from openai import AsyncOpenAI
  11. from agency_swarm import Agency, Agent, run_fastapi
  12. pytest.importorskip("agents")
  13. from agents import OpenAIChatCompletionsModel
  14. class _ChatCompletionsStubHandler(BaseHTTPRequestHandler):
  15. """A tiny local stub for OpenAI Chat Completions API."""
  16. # Shared state set by the fixture.
  17. expected_api_key: str = ""
  18. requests_seen: list[dict] = []
  19. def log_message(self, *_args, **_kwargs): # noqa: D401, N802
  20. """Silence default HTTP server logging in test output."""
  21. def do_POST(self): # noqa: N802
  22. if self.path != "/v1/chat/completions":
  23. self.send_response(404)
  24. self.end_headers()
  25. return
  26. auth = self.headers.get("authorization") or self.headers.get("Authorization")
  27. self.__class__.requests_seen.append(
  28. {
  29. "path": self.path,
  30. "authorization": auth,
  31. "x-agency-id": self.headers.get("x-agency-id"),
  32. "x-sandbox-id": self.headers.get("x-sandbox-id"),
  33. "x-user-id": self.headers.get("x-user-id"),
  34. }
  35. )
  36. # Minimal Chat Completions response shape.
  37. response = {
  38. "id": "chatcmpl_test_1",
  39. "object": "chat.completion",
  40. "created": 0,
  41. "model": "gpt-4o-mini",
  42. "choices": [
  43. {
  44. "index": 0,
  45. "message": {"role": "assistant", "content": "hello from stub"},
  46. "finish_reason": "stop",
  47. }
  48. ],
  49. "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
  50. }
  51. payload = json.dumps(response).encode("utf-8")
  52. self.send_response(200)
  53. self.send_header("Content-Type", "application/json")
  54. self.send_header("Content-Length", str(len(payload)))
  55. self.end_headers()
  56. self.wfile.write(payload)
  57. @pytest.fixture
  58. def openai_stub_base_url() -> str:
  59. """Start a local HTTP server that mimics OpenAI's /v1/chat/completions endpoint."""
  60. handler = _ChatCompletionsStubHandler
  61. handler.expected_api_key = "sk-test"
  62. handler.requests_seen = []
  63. server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
  64. thread = threading.Thread(target=server.serve_forever, daemon=True)
  65. thread.start()
  66. host, port = server.server_address
  67. try:
  68. yield f"http://{host}:{port}"
  69. finally:
  70. server.shutdown()
  71. server.server_close()
  72. thread.join(timeout=2)
  73. def test_client_config_overrides_openai_client_base_url_and_key(openai_stub_base_url: str) -> None:
  74. """FastAPI request `client_config` routes the model call to the provided base_url."""
  75. def create_agency(load_threads_callback=None, save_threads_callback=None):
  76. # The request's client_config should override this client during the request.
  77. original_client = AsyncOpenAI(api_key="sk-original", base_url="http://example.invalid")
  78. agent = Agent(
  79. name="TestAgent",
  80. instructions="You are a test agent.",
  81. model=OpenAIChatCompletionsModel(model="gpt-4o-mini", openai_client=original_client),
  82. )
  83. return Agency(
  84. agent,
  85. load_threads_callback=load_threads_callback,
  86. save_threads_callback=save_threads_callback,
  87. )
  88. app = run_fastapi(
  89. agencies={"test_agency": create_agency},
  90. return_app=True,
  91. app_token_env="", # disable auth for test
  92. enable_agui=False,
  93. )
  94. client = TestClient(app)
  95. res = client.post(
  96. "/test_agency/get_response",
  97. json={
  98. "message": "hi",
  99. # The OpenAI SDK joins "<base_url> + /chat/completions", so use a v1 base URL.
  100. "client_config": {"base_url": f"{openai_stub_base_url}/v1", "api_key": "sk-test"},
  101. },
  102. )
  103. assert res.status_code == 200
  104. assert res.json()["response"] == "hello from stub"
  105. # Prove the request hit our stub and used the overridden API key.
  106. seen = _ChatCompletionsStubHandler.requests_seen
  107. assert len(seen) == 1
  108. assert seen[0]["path"] == "/v1/chat/completions"
  109. assert seen[0]["authorization"] == "Bearer sk-test"
  110. def test_client_config_merges_default_headers_and_allows_overrides(openai_stub_base_url: str) -> None:
  111. """Request-level default_headers merge with existing client headers (request wins)."""
  112. def create_agency(load_threads_callback=None, save_threads_callback=None):
  113. original_client = AsyncOpenAI(
  114. api_key="sk-test",
  115. base_url=f"{openai_stub_base_url}/v1",
  116. default_headers={
  117. "x-agency-id": "agency-orig",
  118. "x-sandbox-id": "sandbox-orig",
  119. },
  120. )
  121. agent = Agent(
  122. name="TestAgent",
  123. instructions="You are a test agent.",
  124. model=OpenAIChatCompletionsModel(model="gpt-4o-mini", openai_client=original_client),
  125. )
  126. return Agency(
  127. agent,
  128. load_threads_callback=load_threads_callback,
  129. save_threads_callback=save_threads_callback,
  130. )
  131. app = run_fastapi(
  132. agencies={"test_agency": create_agency},
  133. return_app=True,
  134. app_token_env="", # disable auth for test
  135. enable_agui=False,
  136. )
  137. client = TestClient(app)
  138. res = client.post(
  139. "/test_agency/get_response",
  140. json={
  141. "message": "hi",
  142. "client_config": {
  143. # No base_url/api_key override: use the existing client's values.
  144. "default_headers": {
  145. "x-sandbox-id": "sandbox-override",
  146. "x-user-id": "user-123",
  147. }
  148. },
  149. },
  150. )
  151. assert res.status_code == 200
  152. assert res.json()["response"] == "hello from stub"
  153. seen = _ChatCompletionsStubHandler.requests_seen
  154. assert len(seen) == 1
  155. assert seen[0]["authorization"] == "Bearer sk-test"
  156. assert seen[0]["x-agency-id"] == "agency-orig"
  157. assert seen[0]["x-sandbox-id"] == "sandbox-override"
  158. assert seen[0]["x-user-id"] == "user-123"
  159. def test_client_config_is_scoped_to_single_request(openai_stub_base_url: str) -> None:
  160. """Request-level overrides should not persist across requests."""
  161. cached_agency = None
  162. def create_agency(load_threads_callback=None, save_threads_callback=None):
  163. nonlocal cached_agency
  164. if cached_agency is None:
  165. original_client = AsyncOpenAI(
  166. api_key="sk-original",
  167. base_url=f"{openai_stub_base_url}/v1",
  168. )
  169. agent = Agent(
  170. name="TestAgent",
  171. instructions="You are a test agent.",
  172. model=OpenAIChatCompletionsModel(model="gpt-4o-mini", openai_client=original_client),
  173. )
  174. cached_agency = Agency(
  175. agent,
  176. load_threads_callback=load_threads_callback,
  177. save_threads_callback=save_threads_callback,
  178. )
  179. return cached_agency
  180. app = run_fastapi(
  181. agencies={"test_agency": create_agency},
  182. return_app=True,
  183. app_token_env="", # disable auth for test
  184. enable_agui=False,
  185. )
  186. client = TestClient(app)
  187. res = client.post(
  188. "/test_agency/get_response",
  189. json={"message": "hi", "client_config": {"api_key": "sk-test"}},
  190. )
  191. assert res.status_code == 200
  192. assert res.json()["response"] == "hello from stub"
  193. res = client.post("/test_agency/get_response", json={"message": "hi again"})
  194. assert res.status_code == 200
  195. assert res.json()["response"] == "hello from stub"
  196. seen = _ChatCompletionsStubHandler.requests_seen
  197. assert len(seen) == 2
  198. assert seen[0]["authorization"] == "Bearer sk-test"
  199. assert seen[1]["authorization"] == "Bearer sk-original"
  200. def test_client_config_passes_request_scoped_upload_client(
  201. openai_stub_base_url: str,
  202. monkeypatch: pytest.MonkeyPatch,
  203. ) -> None:
  204. """file_urls uploads should receive request-level OpenAI client overrides."""
  205. captured: dict[str, object] = {}
  206. async def _fake_upload_from_urls(_file_urls, allowed_local_dirs=None, openai_client=None):
  207. del allowed_local_dirs
  208. assert openai_client is not None
  209. captured["api_key"] = openai_client.api_key
  210. captured["base_url"] = str(openai_client.base_url)
  211. captured["headers"] = dict(openai_client.default_headers or {})
  212. return {"doc.txt": "file-123"}
  213. monkeypatch.setattr(
  214. "agency_swarm.integrations.fastapi_utils.endpoint_handlers.upload_from_urls",
  215. _fake_upload_from_urls,
  216. )
  217. async def _noop_prepare_and_attach_files(*_args, **_kwargs):
  218. return None
  219. monkeypatch.setattr(
  220. "agency_swarm.agent.attachment_manager.AttachmentManager.prepare_and_attach_files",
  221. _noop_prepare_and_attach_files,
  222. )
  223. def create_agency(load_threads_callback=None, save_threads_callback=None):
  224. original_client = AsyncOpenAI(api_key="sk-original", base_url=f"{openai_stub_base_url}/v1")
  225. agent = Agent(
  226. name="TestAgent",
  227. instructions="You are a test agent.",
  228. model=OpenAIChatCompletionsModel(model="gpt-4o-mini", openai_client=original_client),
  229. )
  230. return Agency(
  231. agent,
  232. load_threads_callback=load_threads_callback,
  233. save_threads_callback=save_threads_callback,
  234. )
  235. app = run_fastapi(
  236. agencies={"test_agency": create_agency},
  237. return_app=True,
  238. app_token_env="",
  239. enable_agui=False,
  240. )
  241. client = TestClient(app)
  242. res = client.post(
  243. "/test_agency/get_response",
  244. json={
  245. "message": "hi",
  246. "file_urls": {"doc.txt": "https://example.com/doc.txt"},
  247. "client_config": {
  248. "base_url": f"{openai_stub_base_url}/v1",
  249. "api_key": "sk-request",
  250. "default_headers": {"x-request-id": "req-1"},
  251. },
  252. },
  253. )
  254. assert res.status_code == 200
  255. assert res.json()["response"] == "hello from stub"
  256. assert res.json()["file_ids_map"] == {"doc.txt": "file-123"}
  257. assert captured["api_key"] == "sk-request"
  258. assert str(captured["base_url"]).startswith(f"{openai_stub_base_url}/v1")
  259. assert isinstance(captured["headers"], dict)
  260. assert captured["headers"]["x-request-id"] == "req-1"
  261. def test_default_headers_only_uses_existing_upload_auth(
  262. openai_stub_base_url: str,
  263. monkeypatch: pytest.MonkeyPatch,
  264. ) -> None:
  265. """default_headers-only client_config should preserve baseline upload auth."""
  266. captured: dict[str, object] = {}
  267. async def _fake_upload_from_urls(_file_urls, allowed_local_dirs=None, openai_client=None):
  268. del allowed_local_dirs
  269. assert openai_client is not None
  270. captured["api_key"] = openai_client.api_key
  271. captured["base_url"] = str(openai_client.base_url)
  272. captured["headers"] = dict(openai_client.default_headers or {})
  273. return {"doc.txt": "file-123"}
  274. monkeypatch.setattr(
  275. "agency_swarm.integrations.fastapi_utils.endpoint_handlers.upload_from_urls",
  276. _fake_upload_from_urls,
  277. )
  278. async def _noop_prepare_and_attach_files(*_args, **_kwargs):
  279. return None
  280. monkeypatch.setattr(
  281. "agency_swarm.agent.attachment_manager.AttachmentManager.prepare_and_attach_files",
  282. _noop_prepare_and_attach_files,
  283. )
  284. def create_agency(load_threads_callback=None, save_threads_callback=None):
  285. original_client = AsyncOpenAI(
  286. api_key="sk-agent",
  287. base_url=f"{openai_stub_base_url}/v1",
  288. default_headers={"x-agency-id": "agency-orig"},
  289. )
  290. agent = Agent(
  291. name="TestAgent",
  292. instructions="You are a test agent.",
  293. model=OpenAIChatCompletionsModel(model="gpt-4o-mini", openai_client=original_client),
  294. )
  295. return Agency(
  296. agent,
  297. load_threads_callback=load_threads_callback,
  298. save_threads_callback=save_threads_callback,
  299. )
  300. app = run_fastapi(
  301. agencies={"test_agency": create_agency},
  302. return_app=True,
  303. app_token_env="",
  304. enable_agui=False,
  305. )
  306. client = TestClient(app)
  307. res = client.post(
  308. "/test_agency/get_response",
  309. json={
  310. "message": "hi",
  311. "file_urls": {"doc.txt": "https://example.com/doc.txt"},
  312. "client_config": {"default_headers": {"x-request-id": "req-1"}},
  313. },
  314. )
  315. assert res.status_code == 200
  316. assert res.json()["response"] == "hello from stub"
  317. assert res.json()["file_ids_map"] == {"doc.txt": "file-123"}
  318. assert captured["api_key"] == "sk-agent"
  319. assert str(captured["base_url"]).startswith(f"{openai_stub_base_url}/v1")
  320. assert isinstance(captured["headers"], dict)
  321. assert captured["headers"]["x-agency-id"] == "agency-orig"
  322. assert captured["headers"]["x-request-id"] == "req-1"
  323. def test_client_config_does_not_leak_codex_base_url_into_anthropic_litellm(
  324. monkeypatch: pytest.MonkeyPatch,
  325. ) -> None:
  326. """Anthropic LiteLLM runs must ignore a forwarded Codex OAuth base_url."""
  327. pytest.importorskip("agents.extensions.models.litellm_model")
  328. from agents.extensions.models.litellm_model import LitellmModel
  329. captured: dict[str, object] = {}
  330. async def fake_get_response(self, message, **kwargs):
  331. del message, kwargs
  332. captured["model"] = self.model
  333. return SimpleNamespace(final_output="ok")
  334. monkeypatch.setattr(Agent, "get_response", fake_get_response)
  335. def create_agency(load_threads_callback=None, save_threads_callback=None):
  336. agent = Agent(
  337. name="TestAgent",
  338. instructions="You are a test agent.",
  339. model="litellm/anthropic/claude-sonnet-4",
  340. )
  341. return Agency(
  342. agent,
  343. load_threads_callback=load_threads_callback,
  344. save_threads_callback=save_threads_callback,
  345. )
  346. app = run_fastapi(
  347. agencies={"test_agency": create_agency},
  348. return_app=True,
  349. app_token_env="",
  350. enable_agui=False,
  351. )
  352. client = TestClient(app)
  353. res = client.post(
  354. "/test_agency/get_response",
  355. json={
  356. "message": "hi",
  357. "client_config": {
  358. "base_url": "https://chatgpt.com/backend-api/codex",
  359. "api_key": "sk-openai-gateway",
  360. "litellm_keys": {"anthropic": "sk-ant"},
  361. },
  362. },
  363. )
  364. assert res.status_code == 200
  365. assert res.json()["response"] == "ok"
  366. model = captured["model"]
  367. assert isinstance(model, LitellmModel)
  368. assert model.model == "anthropic/claude-sonnet-4"
  369. assert model.base_url is None
  370. assert model.api_key == "sk-ant"
  371. def test_client_config_litellm_keys_dropped_when_litellm_unavailable(
  372. openai_stub_base_url: str,
  373. monkeypatch: pytest.MonkeyPatch,
  374. caplog: pytest.LogCaptureFixture,
  375. ) -> None:
  376. """Forwarding `litellm_keys` to a bridge without litellm should warn-and-drop, not 422."""
  377. from agency_swarm.integrations.fastapi_utils import request_models
  378. monkeypatch.setattr(request_models, "_LITELLM_INSTALLED", False)
  379. def create_agency(load_threads_callback=None, save_threads_callback=None):
  380. original_client = AsyncOpenAI(api_key="sk-original", base_url="http://example.invalid")
  381. agent = Agent(
  382. name="TestAgent",
  383. instructions="You are a test agent.",
  384. model=OpenAIChatCompletionsModel(model="gpt-4o-mini", openai_client=original_client),
  385. )
  386. return Agency(
  387. agent,
  388. load_threads_callback=load_threads_callback,
  389. save_threads_callback=save_threads_callback,
  390. )
  391. app = run_fastapi(
  392. agencies={"test_agency": create_agency},
  393. return_app=True,
  394. app_token_env="",
  395. enable_agui=False,
  396. )
  397. client = TestClient(app)
  398. with caplog.at_level("WARNING", logger=request_models.__name__):
  399. res = client.post(
  400. "/test_agency/get_response",
  401. json={
  402. "message": "hi",
  403. "client_config": {
  404. "base_url": f"{openai_stub_base_url}/v1",
  405. "api_key": "sk-test",
  406. "litellm_keys": {"anthropic": "sk-ant-xxx"},
  407. },
  408. },
  409. )
  410. assert res.status_code == 200
  411. assert res.json()["response"] == "hello from stub"
  412. assert any("litellm is not installed" in record.message for record in caplog.records)
  413. seen = _ChatCompletionsStubHandler.requests_seen
  414. assert len(seen) == 1
  415. assert seen[0]["authorization"] == "Bearer sk-test"