test_fastapi_metadata.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. """Integration tests for the FastAPI metadata endpoint."""
  2. import json
  3. from datetime import datetime
  4. from typing import cast
  5. import pytest
  6. pytest.importorskip("fastapi.testclient")
  7. from agents import (
  8. CodeInterpreterTool,
  9. FileSearchTool,
  10. RunContextWrapper,
  11. WebSearchTool,
  12. function_tool as sdk_function_tool,
  13. )
  14. from agents.tool_context import ToolContext
  15. from fastapi.testclient import TestClient
  16. from openai.types.responses.tool_param import CodeInterpreter
  17. from pydantic import BaseModel
  18. from agency_swarm import Agency, Agent, BaseTool, function_tool, run_fastapi
  19. from agency_swarm.integrations.fastapi_utils import endpoint_handlers, tool_endpoints
  20. from agency_swarm.tools import ToolFactory
  21. @pytest.fixture
  22. def agency_factory():
  23. """Provide a simple agency factory for FastAPI tests."""
  24. def create_agency(load_threads_callback=None, save_threads_callback=None):
  25. agent = Agent(name="MetadataAgent", instructions="Return metadata")
  26. return Agency(
  27. agent,
  28. load_threads_callback=load_threads_callback,
  29. save_threads_callback=save_threads_callback,
  30. )
  31. return create_agency
  32. def test_metadata_endpoint_includes_version(monkeypatch, agency_factory):
  33. """Verify that the metadata endpoint includes the agency-swarm version."""
  34. expected_version = "9.9.9"
  35. monkeypatch.setattr(endpoint_handlers, "_get_agency_swarm_version", lambda: expected_version)
  36. app = run_fastapi(agencies={"test_agency": agency_factory}, return_app=True, app_token_env="")
  37. client = TestClient(app)
  38. response = client.get("/test_agency/get_metadata")
  39. assert response.status_code == 200
  40. payload = response.json()
  41. assert payload["agency_swarm_version"] == expected_version
  42. assert "allowed_local_file_dirs" in payload
  43. def test_metadata_endpoint_omits_missing_version(monkeypatch, agency_factory):
  44. """Ensure missing version information is not added to the payload."""
  45. monkeypatch.setattr(endpoint_handlers, "_get_agency_swarm_version", lambda: None)
  46. app = run_fastapi(agencies={"test_agency": agency_factory}, return_app=True, app_token_env="")
  47. client = TestClient(app)
  48. response = client.get("/test_agency/get_metadata")
  49. assert response.status_code == 200
  50. payload = response.json()
  51. assert "agency_swarm_version" not in payload
  52. assert "allowed_local_file_dirs" in payload
  53. def test_metadata_includes_agent_capabilities():
  54. """Verify that metadata includes capabilities for each agent."""
  55. class CustomTool(BaseTool):
  56. """Custom tool for testing."""
  57. def run(self) -> str:
  58. return "custom"
  59. @function_tool
  60. def sample_function() -> str:
  61. """Sample function tool."""
  62. return "sample"
  63. def create_agency(load_threads_callback=None, save_threads_callback=None):
  64. # Agent with custom tools
  65. agent1 = Agent(name="ToolAgent", instructions="Test", tools=[CustomTool, sample_function])
  66. # Agent with hosted tools
  67. agent2 = Agent(
  68. name="HostedAgent",
  69. instructions="Test",
  70. tools=[
  71. FileSearchTool(vector_store_ids=["vs_123"]),
  72. CodeInterpreterTool(tool_config=CodeInterpreter()),
  73. WebSearchTool(),
  74. ],
  75. )
  76. agent3 = Agent(
  77. name="ReasoningAgent",
  78. instructions="Test",
  79. model="gpt-5.4-mini",
  80. )
  81. agent4 = Agent(
  82. name="FullAgent",
  83. instructions="Test",
  84. model="gpt-5.4-mini",
  85. tools=[CustomTool, FileSearchTool(vector_store_ids=["vs_456"])],
  86. )
  87. return Agency(
  88. agent1,
  89. communication_flows=[(agent1, agent2), (agent1, agent3), (agent1, agent4)],
  90. load_threads_callback=load_threads_callback,
  91. save_threads_callback=save_threads_callback,
  92. )
  93. app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="")
  94. client = TestClient(app)
  95. response = client.get("/test_agency/get_metadata")
  96. assert response.status_code == 200
  97. payload = response.json()
  98. assert "allowed_local_file_dirs" in payload
  99. # Find agents in nodes
  100. nodes = payload.get("nodes", [])
  101. assert len(nodes) > 0
  102. # Find specific agents and verify capabilities
  103. tool_agent = next((n for n in nodes if n["id"] == "ToolAgent"), None)
  104. assert tool_agent is not None
  105. assert "capabilities" in tool_agent["data"]
  106. assert "tools" in tool_agent["data"]["capabilities"]
  107. hosted_agent = next((n for n in nodes if n["id"] == "HostedAgent"), None)
  108. assert hosted_agent is not None
  109. assert "capabilities" in hosted_agent["data"]
  110. capabilities = set(hosted_agent["data"]["capabilities"])
  111. assert capabilities == {"file_search", "code_interpreter", "web_search", "reasoning"}
  112. assert "tools" not in capabilities
  113. reasoning_agent = next((n for n in nodes if n["id"] == "ReasoningAgent"), None)
  114. assert reasoning_agent is not None
  115. assert "capabilities" in reasoning_agent["data"]
  116. assert "reasoning" in reasoning_agent["data"]["capabilities"]
  117. full_agent = next((n for n in nodes if n["id"] == "FullAgent"), None)
  118. assert full_agent is not None
  119. assert "capabilities" in full_agent["data"]
  120. capabilities = set(full_agent["data"]["capabilities"])
  121. assert "tools" in capabilities
  122. assert "reasoning" in capabilities
  123. assert "file_search" in capabilities
  124. def test_metadata_capabilities_empty_for_basic_agent():
  125. """Agent with no tools only reports reasoning from the default model."""
  126. def create_agency(load_threads_callback=None, save_threads_callback=None):
  127. agent = Agent(name="BasicAgent", instructions="Basic agent with no tools")
  128. return Agency(agent, load_threads_callback=load_threads_callback, save_threads_callback=save_threads_callback)
  129. app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="")
  130. client = TestClient(app)
  131. response = client.get("/test_agency/get_metadata")
  132. assert response.status_code == 200
  133. payload = response.json()
  134. assert "allowed_local_file_dirs" in payload
  135. nodes = payload.get("nodes", [])
  136. basic_agent = next((n for n in nodes if n["id"] == "BasicAgent"), None)
  137. assert basic_agent is not None
  138. assert "capabilities" in basic_agent["data"]
  139. assert basic_agent["data"]["capabilities"] == ["reasoning"]
  140. def test_metadata_includes_allowed_local_file_dirs(tmp_path, agency_factory):
  141. """Metadata should expose the allowed local file directories configuration."""
  142. allowed_dir = tmp_path / "uploads"
  143. allowed_dir.mkdir(parents=True, exist_ok=True)
  144. app = run_fastapi(
  145. agencies={"test_agency": agency_factory},
  146. return_app=True,
  147. app_token_env="",
  148. allowed_local_file_dirs=[str(allowed_dir)],
  149. )
  150. client = TestClient(app)
  151. response = client.get("/test_agency/get_metadata")
  152. assert response.status_code == 200
  153. payload = response.json()
  154. assert payload["allowed_local_file_dirs"] == [str(allowed_dir.resolve())]
  155. def test_metadata_includes_quick_replies() -> None:
  156. """Metadata should expose both starters and quick replies for UI clients."""
  157. def create_agency(load_threads_callback=None, save_threads_callback=None):
  158. agent = Agent(
  159. name="QuickRepliesAgent",
  160. instructions="Use quick replies",
  161. conversation_starters=["Support: I need help with billing"],
  162. quick_replies=["hi", "hello"],
  163. )
  164. return Agency(
  165. agent,
  166. load_threads_callback=load_threads_callback,
  167. save_threads_callback=save_threads_callback,
  168. )
  169. app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="")
  170. client = TestClient(app)
  171. response = client.get("/test_agency/get_metadata")
  172. assert response.status_code == 200
  173. payload = response.json()
  174. nodes = payload.get("nodes", [])
  175. quick_agent = next((n for n in nodes if n["id"] == "QuickRepliesAgent"), None)
  176. assert quick_agent is not None
  177. data = quick_agent["data"]
  178. assert data["conversationStarters"] == ["Support: I need help with billing"]
  179. assert data["quickReplies"] == ["hi", "hello"]
  180. def test_metadata_endpoint_reads_live_metadata():
  181. """Metadata should reflect the current agency factory state on each request."""
  182. state = {"quick": ["hi"]}
  183. def create_agency(load_threads_callback=None, save_threads_callback=None):
  184. agent = Agent(
  185. name="LiveMetadataAgent",
  186. instructions="Use quick replies",
  187. quick_replies=list(state["quick"]),
  188. )
  189. return Agency(
  190. agent,
  191. load_threads_callback=load_threads_callback,
  192. save_threads_callback=save_threads_callback,
  193. )
  194. app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="")
  195. client = TestClient(app)
  196. first = client.get("/test_agency/get_metadata")
  197. assert first.status_code == 200
  198. first_agent = next((n for n in first.json().get("nodes", []) if n["id"] == "LiveMetadataAgent"), None)
  199. assert first_agent is not None
  200. assert first_agent["data"]["quickReplies"] == ["hi"]
  201. state["quick"] = ["bye"]
  202. second = client.get("/test_agency/get_metadata")
  203. assert second.status_code == 200
  204. second_agent = next((n for n in second.json().get("nodes", []) if n["id"] == "LiveMetadataAgent"), None)
  205. assert second_agent is not None
  206. assert second_agent["data"]["quickReplies"] == ["bye"]
  207. def test_metadata_includes_tool_input_schema():
  208. """Metadata should include input schema for function tools when available."""
  209. @function_tool
  210. def sample_tool(text: str) -> str:
  211. return text
  212. def create_agency(load_threads_callback=None, save_threads_callback=None):
  213. agent = Agent(name="SchemaAgent", instructions="Test", tools=[sample_tool])
  214. return Agency(agent, load_threads_callback=load_threads_callback, save_threads_callback=save_threads_callback)
  215. app = run_fastapi(agencies={"test_agency": create_agency}, return_app=True, app_token_env="")
  216. client = TestClient(app)
  217. response = client.get("/test_agency/get_metadata")
  218. assert response.status_code == 200
  219. payload = response.json()
  220. schema_agent = next((n for n in payload.get("nodes", []) if n["id"] == "SchemaAgent"), None)
  221. assert schema_agent is not None
  222. tools = schema_agent["data"].get("tools", [])
  223. assert tools
  224. input_schema = tools[0].get("inputSchema")
  225. assert isinstance(input_schema, dict)
  226. assert "text" in input_schema.get("properties", {})
  227. def test_metadata_includes_missing_allowed_dirs(tmp_path, agency_factory):
  228. """Missing allowed local file directories should still appear in metadata."""
  229. missing_dir = tmp_path / "missing-uploads"
  230. app = run_fastapi(
  231. agencies={"test_agency": agency_factory},
  232. return_app=True,
  233. app_token_env="",
  234. allowed_local_file_dirs=[str(missing_dir)],
  235. )
  236. client = TestClient(app)
  237. response = client.get("/test_agency/get_metadata")
  238. assert response.status_code == 200
  239. payload = response.json()
  240. assert payload["allowed_local_file_dirs"] == [str(missing_dir)]
  241. def test_metadata_includes_non_directory_allowed_dirs(tmp_path, agency_factory):
  242. """Non-directory allowlist entries should appear in metadata as configured."""
  243. file_entry = tmp_path / "not-a-directory.txt"
  244. file_entry.write_text("x", encoding="utf-8")
  245. app = run_fastapi(
  246. agencies={"test_agency": agency_factory},
  247. return_app=True,
  248. app_token_env="",
  249. allowed_local_file_dirs=[str(file_entry)],
  250. )
  251. client = TestClient(app)
  252. response = client.get("/test_agency/get_metadata")
  253. assert response.status_code == 200
  254. payload = response.json()
  255. assert payload["allowed_local_file_dirs"] == [str(file_entry)]
  256. def test_metadata_returns_absolute_allowlist_paths(tmp_path, monkeypatch, agency_factory):
  257. """Metadata should return allowlist entries as absolute paths."""
  258. from pathlib import Path
  259. allowed_dir = tmp_path / "uploads"
  260. allowed_dir.mkdir(parents=True, exist_ok=True)
  261. app = run_fastapi(
  262. agencies={"test_agency": agency_factory},
  263. return_app=True,
  264. app_token_env="",
  265. allowed_local_file_dirs=[str(allowed_dir)],
  266. )
  267. client = TestClient(app)
  268. response = client.get("/test_agency/get_metadata")
  269. assert response.status_code == 200
  270. payload = response.json()
  271. expected = str(Path(allowed_dir).expanduser().resolve())
  272. assert payload["allowed_local_file_dirs"] == [expected]
  273. assert Path(payload["allowed_local_file_dirs"][0]).is_absolute()
  274. def test_tool_endpoint_handles_nested_schema():
  275. """Test that tool endpoints work with nested Pydantic models."""
  276. class Address(BaseModel):
  277. street: str
  278. zip_code: int
  279. class NestedTool(BaseTool):
  280. address: Address
  281. def run(self) -> str:
  282. return self.address.street
  283. app = run_fastapi(tools=[NestedTool], return_app=True, app_token_env="")
  284. client = TestClient(app)
  285. response = client.post("/tool/NestedTool", json={"address": {"street": "Elm", "zip_code": 90210}})
  286. assert response.status_code == 200
  287. assert response.json() == {"response": "Elm"}
  288. def test_tool_endpoint_serializes_datetime_for_on_invoke(monkeypatch):
  289. """Ensure typed tool endpoints pass JSON strings to on_invoke_tool."""
  290. class TimestampRequest(BaseModel):
  291. timestamp: datetime
  292. class TimestampFunctionTool:
  293. name = "TimestampFunctionTool"
  294. openai_schema = {
  295. "parameters": {
  296. "type": "object",
  297. "properties": {
  298. "timestamp": {
  299. "type": "string",
  300. "description": "ISO timestamp",
  301. }
  302. },
  303. "required": ["timestamp"],
  304. }
  305. }
  306. calls: list[str] = []
  307. @staticmethod
  308. async def on_invoke_tool(context, input_json: str):
  309. TimestampFunctionTool.calls.append(input_json)
  310. return "ok"
  311. def fake_build_request_model(*_, **__):
  312. return TimestampRequest
  313. monkeypatch.setattr(tool_endpoints, "build_request_model", fake_build_request_model)
  314. TimestampFunctionTool.calls = []
  315. app = run_fastapi(tools=[TimestampFunctionTool], return_app=True, app_token_env="")
  316. client = TestClient(app)
  317. response = client.post("/tool/TimestampFunctionTool", json={"timestamp": "2024-05-01T09:30:00Z"})
  318. assert response.status_code == 200
  319. assert response.json() == {"response": "ok"}
  320. assert TimestampFunctionTool.calls, "on_invoke_tool should be triggered"
  321. payload = json.loads(TimestampFunctionTool.calls[0])
  322. assert payload == {"timestamp": "2024-05-01T09:30:00Z"}
  323. def test_openapi_json_includes_nested_schemas():
  324. """Verify /openapi.json contains proper schemas for tools with nested models."""
  325. class Address(BaseModel):
  326. street: str
  327. zip_code: int
  328. class NestedTool(BaseTool):
  329. address: Address
  330. def run(self) -> str:
  331. return self.address.street
  332. class SimpleTool(BaseTool):
  333. name: str
  334. age: int
  335. def run(self) -> str:
  336. return self.name
  337. app = run_fastapi(tools=[NestedTool, SimpleTool], return_app=True, app_token_env="")
  338. client = TestClient(app)
  339. response = client.get("/openapi.json")
  340. assert response.status_code == 200
  341. schema = response.json()
  342. assert "/tool/NestedTool" in schema["paths"]
  343. assert "/tool/SimpleTool" in schema["paths"]
  344. nested_endpoint = schema["paths"]["/tool/NestedTool"]["post"]
  345. assert "requestBody" in nested_endpoint
  346. nested_schema_ref = nested_endpoint["requestBody"]["content"]["application/json"]["schema"]
  347. assert nested_schema_ref["$ref"] == "#/components/schemas/NestedTool"
  348. assert "NestedTool" in schema["components"]["schemas"]
  349. assert "Address" in schema["components"]["schemas"]
  350. nested_tool_schema = schema["components"]["schemas"]["NestedTool"]
  351. assert nested_tool_schema["properties"]["address"]["$ref"] == "#/components/schemas/Address"
  352. address_schema = schema["components"]["schemas"]["Address"]
  353. assert address_schema["type"] == "object"
  354. assert "street" in address_schema["properties"]
  355. assert "zip_code" in address_schema["properties"]
  356. assert address_schema["required"] == ["street", "zip_code"]
  357. def test_function_tool_with_nested_schema():
  358. """Verify that FunctionTools with nested models work correctly via adapted BaseTool."""
  359. class Address(BaseModel):
  360. street: str
  361. zip_code: int
  362. class UserTool(BaseTool):
  363. """Create a user with address."""
  364. name: str
  365. address: Address
  366. def run(self) -> str:
  367. return f"{self.name} at {self.address.street}"
  368. # Adapt the BaseTool to a FunctionTool (simulates what happens in agents)
  369. function_tool = ToolFactory.adapt_base_tool(UserTool)
  370. app = run_fastapi(tools=[function_tool], return_app=True, app_token_env="")
  371. client = TestClient(app)
  372. # Test that the endpoint works
  373. response = client.post(
  374. "/tool/UserTool", json={"name": "Alice", "address": {"street": "123 Main St", "zip_code": 12345}}
  375. )
  376. assert response.status_code == 200
  377. assert "Alice at 123 Main St" in response.json()["response"]
  378. # Test that OpenAPI schema includes nested model
  379. schema_response = client.get("/openapi.json")
  380. assert schema_response.status_code == 200
  381. openapi_schema = schema_response.json()
  382. assert "/tool/UserTool" in openapi_schema["paths"]
  383. endpoint_schema = openapi_schema["paths"]["/tool/UserTool"]["post"]
  384. assert "requestBody" in endpoint_schema
  385. # Verify the schema is properly typed (not generic Request)
  386. request_schema = endpoint_schema["requestBody"]["content"]["application/json"]["schema"]
  387. assert "$ref" in request_schema
  388. assert "UserToolRequest" in request_schema["$ref"]
  389. def test_raw_sdk_function_tool_endpoint_receives_manual_tool_context():
  390. """Raw SDK FunctionTools exposed directly through run_fastapi should get ToolContext."""
  391. seen_contexts: list[ToolContext[None]] = []
  392. @sdk_function_tool
  393. async def raw_sdk_tool(ctx: RunContextWrapper[None], value: str) -> str:
  394. tool_context = cast(ToolContext[None], ctx)
  395. seen_contexts.append(tool_context)
  396. return f"{tool_context.tool_name}:{tool_context.tool_arguments}:{value}"
  397. app = run_fastapi(tools=[raw_sdk_tool], return_app=True, app_token_env="")
  398. client = TestClient(app)
  399. response = client.post("/tool/raw_sdk_tool", json={"value": "ok"})
  400. assert response.status_code == 200
  401. assert response.json() == {"response": 'raw_sdk_tool:{"value":"ok"}:ok'}
  402. assert len(seen_contexts) == 1
  403. assert seen_contexts[0].tool_name == "raw_sdk_tool"
  404. assert seen_contexts[0].tool_arguments == '{"value":"ok"}'
  405. assert seen_contexts[0].tool_call_id == "agency_swarm_manual_raw_sdk_tool"
  406. def test_strict_function_tool_rejects_extra_fields():
  407. """Ensure strict tools exposed via FastAPI still validate unexpected inputs."""
  408. class StrictTool(BaseTool):
  409. """Return the given value."""
  410. class ToolConfig:
  411. strict = True
  412. value: int
  413. def run(self) -> int:
  414. return self.value
  415. strict_function_tool = ToolFactory.adapt_base_tool(StrictTool)
  416. app = run_fastapi(tools=[strict_function_tool], return_app=True, app_token_env="")
  417. client = TestClient(app)
  418. response = client.post("/tool/StrictTool", json={"value": 7, "unexpected": "boom"})
  419. assert response.status_code == 422
  420. detail = response.json()["detail"]
  421. assert any(item.get("type") == "extra_forbidden" for item in detail)
  422. def test_tool_endpoint_preserves_explicit_nulls():
  423. """Tools must receive explicit null payloads without them being dropped."""
  424. class NullableTool(BaseTool):
  425. note: str | None = None
  426. def run(self) -> str | None:
  427. return self.note
  428. app = run_fastapi(tools=[NullableTool], return_app=True, app_token_env="")
  429. client = TestClient(app)
  430. response = client.post("/tool/NullableTool", json={"note": None})
  431. assert response.status_code == 200
  432. assert response.json() == {"response": None}
  433. def test_function_tool_nested_list_validation_survives_schema_export():
  434. """FunctionTools should retain nested list schemas after ToolFactory exports."""
  435. class Address(BaseModel):
  436. street: str
  437. zip_code: int
  438. class AddressListTool(BaseTool):
  439. addresses: list[Address]
  440. def run(self) -> str:
  441. return ",".join(addr.street for addr in self.addresses)
  442. function_tool = ToolFactory.adapt_base_tool(AddressListTool)
  443. ToolFactory.get_openapi_schema([function_tool], "https://api.test.com")
  444. app = run_fastapi(tools=[function_tool], return_app=True, app_token_env="")
  445. client = TestClient(app)
  446. # Missing zip_code inside nested list should raise a FastAPI validation error (422)
  447. invalid_response = client.post("/tool/AddressListTool", json={"addresses": [{"street": "Elm"}]})
  448. assert invalid_response.status_code == 422
  449. valid_response = client.post(
  450. "/tool/AddressListTool",
  451. json={"addresses": [{"street": "Elm", "zip_code": 90210}]},
  452. )
  453. assert valid_response.status_code == 200
  454. assert valid_response.json() == {"response": "Elm"}
  455. def test_function_tool_nested_union_falls_back_to_generic_handler():
  456. """Nested unions should bypass the typed request model to avoid false 422 errors."""
  457. class Contact(BaseModel):
  458. identifier: str | int
  459. class ContainerTool(BaseTool):
  460. contact: Contact
  461. def run(self) -> str:
  462. return str(self.contact.identifier)
  463. function_tool = ToolFactory.adapt_base_tool(ContainerTool)
  464. app = run_fastapi(tools=[function_tool], return_app=True, app_token_env="")
  465. client = TestClient(app)
  466. response = client.post("/tool/ContainerTool", json={"contact": {"identifier": 99}})
  467. assert response.status_code == 200
  468. assert response.json() == {"response": "99"}
  469. schema = client.get("/openapi.json").json()
  470. endpoint = schema["paths"]["/tool/ContainerTool"]["post"]
  471. assert "requestBody" not in endpoint
  472. def test_openapi_schema_includes_custom_server_url():
  473. """/openapi.json should expose the configured server base URL."""
  474. class EchoTool(BaseTool):
  475. message: str
  476. def run(self) -> str:
  477. return self.message
  478. app = run_fastapi(
  479. tools=[EchoTool],
  480. return_app=True,
  481. app_token_env="",
  482. server_url="https://api.example.com/base",
  483. )
  484. client = TestClient(app)
  485. schema = client.get("/openapi.json").json()
  486. assert schema["servers"] == [{"url": "https://api.example.com/base"}]
  487. assert "/tool/EchoTool" in schema["paths"]