routes.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. import asyncio
  2. import json
  3. from typing import Annotated
  4. from core_domain import ServiceDescriptor, ServiceHealth
  5. import httpx
  6. from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, StreamingResponse
  7. from sqlalchemy import text
  8. from sqlalchemy.orm import Session
  9. from pydantic import BaseModel
  10. from app.bootstrap.settings import ApiGatewaySettings
  11. from app.db.session import get_db
  12. from app.domain.repositories import ApiKeyRepository, GatewayRequestAuditRepository
  13. from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
  14. from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
  15. from core_shared.security import build_internal_service_headers
  16. from app.schemas.gateway import (
  17. ApiKeyCreateRequest,
  18. ApiKeyCreateResponse,
  19. ApiKeyListRequest,
  20. ApiKeyResponse,
  21. ApiKeyStatusPostRequest,
  22. ApiKeyStatusUpdateRequest,
  23. GatewayAuditServiceStats,
  24. GatewayAuditStatsResponse,
  25. GatewayRequestAuditResponse,
  26. GatewayServicesHealthResponse,
  27. )
  28. router = APIRouter()
  29. DbSession = Annotated[Session, Depends(get_db)]
  30. class SessionExecuteRequest(BaseModel):
  31. session_id: str
  32. message_text: str
  33. stream: bool = False
  34. class SessionExecuteResponse(BaseModel):
  35. session_id: str
  36. run_request_id: str
  37. target_type: str
  38. target_id: str
  39. target_config_id: str | None = None
  40. request_status: str
  41. user_message_id: str
  42. assistant_message_id: str | None = None
  43. agent_run_id: str | None = None
  44. team_run_id: str | None = None
  45. output_text: str | None = None
  46. error_message: str | None = None
  47. @router.get("/health", response_model=ServiceDescriptor)
  48. def health_check(db: DbSession) -> ServiceDescriptor:
  49. db.execute(text("SELECT 1"))
  50. return ServiceDescriptor(name="api-gateway")
  51. @router.get("/ready", response_model=ServiceHealth)
  52. def readiness_check(db: DbSession) -> ServiceHealth:
  53. db.execute(text("SELECT 1"))
  54. return ServiceHealth(service="api-gateway", status="ok", database="ok")
  55. @router.post("/gateway/api-keys", response_model=ApiKeyCreateResponse)
  56. def create_api_key(
  57. payload: ApiKeyCreateRequest,
  58. db: DbSession) -> ApiKeyCreateResponse:
  59. api_key = generate_api_key()
  60. entity = ApiKeyRepository(db).create(
  61. name=payload.name,
  62. key_prefix=get_api_key_prefix(api_key),
  63. key_hash=hash_api_key(api_key),
  64. scopes=payload.scopes,
  65. expires_time=payload.expires_time)
  66. return ApiKeyCreateResponse(
  67. id=entity.id,
  68. name=entity.name,
  69. key_prefix=entity.key_prefix,
  70. api_key=api_key,
  71. status=entity.status,
  72. scopes=entity.scopes,
  73. expires_time=entity.expires_time,
  74. created_time=entity.created_time)
  75. @router.get("/gateway/api-keys", response_model=list[ApiKeyResponse])
  76. def list_api_keys(
  77. db: DbSession) -> list[ApiKeyResponse]:
  78. return [
  79. ApiKeyResponse.from_entity(item)
  80. for item in ApiKeyRepository(db).list_all()
  81. ]
  82. @router.post("/gateway/api-keys/list", response_model=list[ApiKeyResponse])
  83. def list_api_keys_post(
  84. payload: ApiKeyListRequest,
  85. db: DbSession) -> list[ApiKeyResponse]:
  86. return [
  87. ApiKeyResponse.from_entity(item)
  88. for item in ApiKeyRepository(db).list_all()
  89. ]
  90. @router.patch("/gateway/api-keys/{api_key_id}/status", response_model=ApiKeyResponse)
  91. def update_api_key_status(
  92. api_key_id: str,
  93. payload: ApiKeyStatusUpdateRequest,
  94. db: DbSession) -> ApiKeyResponse:
  95. entity = ApiKeyRepository(db).update_status(
  96. api_key_id=api_key_id,
  97. status=payload.status)
  98. if entity is None:
  99. raise HTTPException(status_code=404, detail=f"api key not found: {api_key_id}")
  100. return ApiKeyResponse.from_entity(entity)
  101. @router.post("/gateway/api-keys/status", response_model=ApiKeyResponse)
  102. def update_api_key_status_post(
  103. payload: ApiKeyStatusPostRequest,
  104. db: DbSession) -> ApiKeyResponse:
  105. entity = ApiKeyRepository(db).update_status(
  106. api_key_id=payload.api_key_id,
  107. status=payload.status)
  108. if entity is None:
  109. raise HTTPException(status_code=404, detail=f"api key not found: {payload.api_key_id}")
  110. return ApiKeyResponse.from_entity(entity)
  111. @router.get("/gateway/audits", response_model=list[GatewayRequestAuditResponse])
  112. def list_gateway_audits(
  113. db: DbSession,
  114. request_id: Annotated[str | None, Query()] = None,
  115. target_service: Annotated[str | None, Query()] = None,
  116. limit: Annotated[int, Query(ge=1, le=500)] = 100) -> list[GatewayRequestAuditResponse]:
  117. items = GatewayRequestAuditRepository(db).list_by_scope(
  118. request_id=request_id,
  119. target_service=target_service,
  120. limit=limit)
  121. return [GatewayRequestAuditResponse.from_entity(item) for item in items]
  122. @router.get("/gateway/audits/stats", response_model=GatewayAuditStatsResponse)
  123. def gateway_audit_stats(
  124. db: DbSession) -> GatewayAuditStatsResponse:
  125. rows = GatewayRequestAuditRepository(db).stats_by_service()
  126. services = [
  127. GatewayAuditServiceStats(
  128. target_service=target_service,
  129. request_count=request_count,
  130. error_count=error_count,
  131. average_duration_ms=round(average_duration_ms, 2))
  132. for target_service, request_count, error_count, average_duration_ms in rows
  133. ]
  134. return GatewayAuditStatsResponse(
  135. total_request_count=sum(item.request_count for item in services),
  136. total_error_count=sum(item.error_count for item in services),
  137. services=services)
  138. def get_gateway_settings() -> ApiGatewaySettings:
  139. return ApiGatewaySettings()
  140. def get_service_proxy(
  141. settings: Annotated[ApiGatewaySettings, Depends(get_gateway_settings)]) -> ServiceProxy:
  142. return ServiceProxy(settings=settings, timeout_seconds=settings.proxy_timeout_seconds)
  143. GatewaySettingsDep = Annotated[ApiGatewaySettings, Depends(get_gateway_settings)]
  144. ServiceProxyDep = Annotated[ServiceProxy, Depends(get_service_proxy)]
  145. def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName, ProxyTarget]:
  146. return {
  147. "session-service": ProxyTarget(
  148. service_name="session-service",
  149. base_url=settings.session_service_url,
  150. path_prefix="/sessions",
  151. health_path="/sessions/health"),
  152. "tool-service": ProxyTarget(
  153. service_name="tool-service",
  154. base_url=settings.tool_service_url,
  155. path_prefix="/tools",
  156. health_path="/tools/health"),
  157. "model-gateway-service": ProxyTarget(
  158. service_name="model-gateway-service",
  159. base_url=settings.model_gateway_service_url,
  160. path_prefix="/models",
  161. health_path="/models/health"),
  162. "model-provider-service": ProxyTarget(
  163. service_name="model-provider-service",
  164. base_url=settings.model_gateway_service_url,
  165. path_prefix="/models/providers",
  166. health_path="/models/health"),
  167. "code-runner-service": ProxyTarget(
  168. service_name="code-runner-service",
  169. base_url=settings.code_runner_service_url,
  170. path_prefix="/code",
  171. health_path="/code/health"),
  172. "agent-service": ProxyTarget(
  173. service_name="agent-service",
  174. base_url=settings.agent_service_url,
  175. path_prefix="/agents",
  176. health_path="/agents/health"),
  177. "memory-service": ProxyTarget(
  178. service_name="memory-service",
  179. base_url=settings.memory_service_url,
  180. path_prefix="/memories",
  181. health_path="/memories/health"),
  182. "team-service": ProxyTarget(
  183. service_name="team-service",
  184. base_url=settings.team_service_url,
  185. path_prefix="/teams",
  186. health_path="/teams/health"),
  187. "skill-service": ProxyTarget(
  188. service_name="skill-service",
  189. base_url=settings.skill_service_url,
  190. path_prefix="/skills",
  191. health_path="/skills/health"),
  192. "human-service": ProxyTarget(
  193. service_name="human-service",
  194. base_url=settings.human_service_url,
  195. path_prefix="/human",
  196. health_path="/human/health"),
  197. "knowledge-service": ProxyTarget(
  198. service_name="knowledge-service",
  199. base_url=settings.knowledge_service_url,
  200. path_prefix="/knowledge",
  201. health_path="/knowledge/health"),
  202. "event-service": ProxyTarget(
  203. service_name="event-service",
  204. base_url=settings.event_service_url,
  205. path_prefix="/events",
  206. health_path="/events/health"),
  207. "identity-service": ProxyTarget(
  208. service_name="identity-service",
  209. base_url=settings.auth_service_url,
  210. path_prefix="/identity",
  211. health_path="/identity/health"),
  212. "scheduler-service": ProxyTarget(
  213. service_name="scheduler-service",
  214. base_url=settings.scheduler_service_url,
  215. path_prefix="/scheduler",
  216. health_path="/scheduler/health"),
  217. }
  218. @router.get("/gateway/services/health", response_model=GatewayServicesHealthResponse)
  219. async def downstream_health_check(
  220. settings: GatewaySettingsDep) -> GatewayServicesHealthResponse:
  221. targets = build_proxy_targets(settings)
  222. health_proxy = ServiceProxy(
  223. settings=settings,
  224. timeout_seconds=settings.downstream_health_timeout_seconds)
  225. downstream_services = await asyncio.gather(
  226. *[health_proxy.check_health(target) for target in targets.values()]
  227. )
  228. status = "ok" if all(item.status == "ok" for item in downstream_services) else "degraded"
  229. return GatewayServicesHealthResponse(
  230. status=status,
  231. downstream_services=downstream_services)
  232. @router.post("/gateway/sessions/execute")
  233. async def execute_session(
  234. payload: SessionExecuteRequest,
  235. request: Request,
  236. settings: GatewaySettingsDep):
  237. if payload.stream:
  238. return StreamingResponse(
  239. _stream_session_execute(payload, request, settings),
  240. media_type="text/event-stream",
  241. headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
  242. targets = build_proxy_targets(settings)
  243. session_target = targets["session-service"]
  244. agent_target = targets["agent-service"]
  245. team_target = targets["team-service"]
  246. headers = _build_internal_headers(request, settings)
  247. async with httpx_client(settings.proxy_timeout_seconds) as client:
  248. session = await _post_json(
  249. client=client,
  250. target=session_target,
  251. path="detail",
  252. payload={"session_id": payload.session_id},
  253. headers=headers)
  254. target_type = _get_string(session, "runtime_target_type")
  255. target_id = _get_string(session, "runtime_target_id")
  256. target_config_id = _get_optional_string(session, "runtime_target_config_id")
  257. if target_type not in {"agent", "team"} or not target_id:
  258. raise HTTPException(status_code=422, detail="session runtime target is not configured")
  259. run_request_payload = {
  260. "target_type": target_type,
  261. "target_id": target_id,
  262. "target_config_id": target_config_id,
  263. "mode": "production",
  264. "input_text": payload.message_text,
  265. }
  266. run_request = await _post_json(
  267. client=client,
  268. target=session_target,
  269. path="run-requests",
  270. payload={
  271. "session_id": payload.session_id,
  272. "app_config_id": target_config_id or target_id,
  273. "workflow_config_id": target_id,
  274. "trigger_type": "chat",
  275. "request_payload_json": run_request_payload,
  276. "request_status": "accepted",
  277. },
  278. headers=headers)
  279. run_request_id = _get_string(run_request, "id")
  280. user_message = await _post_json(
  281. client=client,
  282. target=session_target,
  283. path="messages",
  284. payload={
  285. "session_id": payload.session_id,
  286. "turn_id": run_request_id,
  287. "role": "user",
  288. "content_type": "text",
  289. "content_text": payload.message_text,
  290. "content_json": {},
  291. },
  292. headers=headers)
  293. user_message_id = _get_string(user_message, "id")
  294. await _post_json(
  295. client=client,
  296. target=session_target,
  297. path="run-requests/update",
  298. payload={
  299. "run_request_id": run_request_id,
  300. "request_status": "running",
  301. "request_payload_json": {
  302. **run_request_payload,
  303. "user_message_id": user_message_id,
  304. },
  305. },
  306. headers=headers)
  307. assistant_message_id: str | None = None
  308. agent_run_id: str | None = None
  309. team_run_id: str | None = None
  310. output_text: str | None = None
  311. error_message: str | None = None
  312. request_status = "completed"
  313. try:
  314. if target_type == "agent":
  315. agent_run = await _post_json(
  316. client=client,
  317. target=agent_target,
  318. path="runs",
  319. payload={
  320. "agent_id": target_id,
  321. "agent_config_id": target_config_id,
  322. "session_id": payload.session_id,
  323. "input_text": payload.message_text,
  324. "input_json": {
  325. "source": "session",
  326. "run_request_id": run_request_id,
  327. },
  328. },
  329. headers=headers)
  330. agent_run_id = _get_string(agent_run, "id")
  331. execute_result = await _post_json(
  332. client=client,
  333. target=agent_target,
  334. path="runs/execute",
  335. payload={
  336. "agent_run_id": agent_run_id,
  337. "dry_run": False,
  338. },
  339. headers=headers)
  340. run_data = _get_dict(execute_result, "run")
  341. output_text = _resolve_output_text(run_data)
  342. error_message = _get_optional_string(run_data, "error_message")
  343. else:
  344. team_run = await _post_json(
  345. client=client,
  346. target=team_target,
  347. path="runs",
  348. payload={
  349. "team_id": target_id,
  350. "team_config_id": target_config_id,
  351. "session_id": payload.session_id,
  352. "input_text": payload.message_text,
  353. "input_json": {
  354. "source": "session",
  355. "run_request_id": run_request_id,
  356. },
  357. "enqueue": True,
  358. },
  359. headers=headers)
  360. team_run_id = _get_string(team_run, "id")
  361. execute_result = await _post_json(
  362. client=client,
  363. target=team_target,
  364. path=f"runs/{team_run_id}/execute",
  365. payload={
  366. "dry_run": False,
  367. },
  368. headers=headers)
  369. run_data = _get_dict(execute_result, "run")
  370. output_text = _resolve_output_text(run_data)
  371. error_message = _get_optional_string(run_data, "error_message")
  372. if error_message:
  373. request_status = "failed"
  374. if output_text:
  375. assistant_message = await _post_json(
  376. client=client,
  377. target=session_target,
  378. path="messages",
  379. payload={
  380. "session_id": payload.session_id,
  381. "turn_id": run_request_id,
  382. "role": "assistant",
  383. "content_type": "text",
  384. "content_text": output_text,
  385. "content_json": {},
  386. },
  387. headers=headers)
  388. assistant_message_id = _get_string(assistant_message, "id")
  389. except HTTPException as exc:
  390. request_status = "failed"
  391. error_message = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
  392. await _post_json(
  393. client=client,
  394. target=session_target,
  395. path="run-requests/update",
  396. payload={
  397. "run_request_id": run_request_id,
  398. "request_status": request_status,
  399. "request_payload_json": {
  400. **run_request_payload,
  401. "user_message_id": user_message_id,
  402. "assistant_message_id": assistant_message_id,
  403. "agent_run_id": agent_run_id,
  404. "team_run_id": team_run_id,
  405. "output_text": output_text,
  406. "error_message": error_message,
  407. },
  408. },
  409. headers=headers)
  410. return SessionExecuteResponse(
  411. session_id=payload.session_id,
  412. run_request_id=run_request_id,
  413. target_type=target_type,
  414. target_id=target_id,
  415. target_config_id=target_config_id,
  416. request_status=request_status,
  417. user_message_id=user_message_id,
  418. assistant_message_id=assistant_message_id,
  419. agent_run_id=agent_run_id,
  420. team_run_id=team_run_id,
  421. output_text=output_text,
  422. error_message=error_message)
  423. async def _stream_session_execute(
  424. payload: SessionExecuteRequest,
  425. request: Request,
  426. settings: ApiGatewaySettings):
  427. targets = build_proxy_targets(settings)
  428. session_target = targets["session-service"]
  429. agent_target = targets["agent-service"]
  430. team_target = targets["team-service"]
  431. headers = _build_internal_headers(request, settings)
  432. client = httpx.AsyncClient(timeout=settings.proxy_timeout_seconds)
  433. try:
  434. session = await _post_json(
  435. client=client, target=session_target, path="detail",
  436. payload={"session_id": payload.session_id}, headers=headers)
  437. target_type = _get_string(session, "runtime_target_type")
  438. target_id = _get_string(session, "runtime_target_id")
  439. target_config_id = _get_optional_string(session, "runtime_target_config_id")
  440. if target_type not in {"agent", "team"} or not target_id:
  441. raise HTTPException(status_code=422, detail="session runtime target is not configured")
  442. run_request_payload = {
  443. "target_type": target_type, "target_id": target_id,
  444. "target_config_id": target_config_id, "mode": "production",
  445. "input_text": payload.message_text,
  446. }
  447. run_request = await _post_json(
  448. client=client, target=session_target, path="run-requests",
  449. payload={
  450. "session_id": payload.session_id,
  451. "app_config_id": target_config_id or target_id,
  452. "workflow_config_id": target_id,
  453. "trigger_type": "chat",
  454. "request_payload_json": run_request_payload,
  455. "request_status": "accepted",
  456. }, headers=headers)
  457. run_request_id = _get_string(run_request, "id")
  458. user_message = await _post_json(
  459. client=client, target=session_target, path="messages",
  460. payload={
  461. "session_id": payload.session_id, "turn_id": run_request_id,
  462. "role": "user", "content_type": "text",
  463. "content_text": payload.message_text, "content_json": {},
  464. }, headers=headers)
  465. user_message_id = _get_string(user_message, "id")
  466. await _post_json(
  467. client=client, target=session_target, path="run-requests/update",
  468. payload={
  469. "run_request_id": run_request_id, "request_status": "running",
  470. "request_payload_json": {**run_request_payload, "user_message_id": user_message_id},
  471. }, headers=headers)
  472. yield _sse("session.execute.started", {
  473. "run_request_id": run_request_id, "user_message_id": user_message_id,
  474. "target_type": target_type, "target_id": target_id,
  475. })
  476. output_text = ""
  477. error_message: str | None = None
  478. agent_run_id: str | None = None
  479. team_run_id: str | None = None
  480. if target_type == "agent":
  481. agent_run = await _post_json(
  482. client=client, target=agent_target, path="runs",
  483. payload={
  484. "agent_id": target_id, "agent_config_id": target_config_id,
  485. "session_id": payload.session_id, "input_text": payload.message_text,
  486. "input_json": {"source": "session", "run_request_id": run_request_id},
  487. }, headers=headers)
  488. agent_run_id = _get_string(agent_run, "id")
  489. stream_url = _target_url(agent_target, f"runs/{agent_run_id}/execute-stream")
  490. async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
  491. if not resp.is_success:
  492. error_message = await _read_stream_error(resp)
  493. else:
  494. async for ev_name, ev_data in _parse_sse(resp):
  495. data = json.loads(ev_data)
  496. yield _sse(ev_name, data)
  497. if ev_name == "agent.run.delta" and isinstance(data.get("text"), str):
  498. output_text += data["text"]
  499. elif ev_name == "agent.run.completed":
  500. run_data = data.get("run", data)
  501. if not output_text and isinstance(run_data.get("output_text"), str):
  502. output_text = run_data["output_text"]
  503. elif ev_name == "agent.run.failed":
  504. error_message = data.get("error_message", "Agent execution failed")
  505. if not isinstance(error_message, str):
  506. error_message = "Agent execution failed"
  507. else:
  508. team_run = await _post_json(
  509. client=client, target=team_target, path="runs",
  510. payload={
  511. "team_id": target_id, "team_config_id": target_config_id,
  512. "session_id": payload.session_id, "input_text": payload.message_text,
  513. "input_json": {"source": "session", "run_request_id": run_request_id},
  514. "enqueue": True,
  515. }, headers=headers)
  516. team_run_id = _get_string(team_run, "id")
  517. stream_url = _target_url(team_target, f"runs/{team_run_id}/execute-stream")
  518. async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
  519. if not resp.is_success:
  520. error_message = await _read_stream_error(resp)
  521. else:
  522. async for ev_name, ev_data in _parse_sse(resp):
  523. data = json.loads(ev_data)
  524. yield _sse(ev_name, data)
  525. if ev_name == "team.run.delta" and isinstance(data.get("text"), str):
  526. output_text += data["text"]
  527. elif ev_name == "team.run.completed":
  528. run_data = data.get("run", data)
  529. if not output_text and isinstance(run_data.get("output_text"), str):
  530. output_text = run_data["output_text"]
  531. elif ev_name == "team.run.failed":
  532. error_message = data.get("error_message", "Team execution failed")
  533. if not isinstance(error_message, str):
  534. error_message = "Team execution failed"
  535. request_status = "failed" if error_message else "completed"
  536. assistant_message_id: str | None = None
  537. if output_text:
  538. assistant_message = await _post_json(
  539. client=client, target=session_target, path="messages",
  540. payload={
  541. "session_id": payload.session_id, "turn_id": run_request_id,
  542. "role": "assistant", "content_type": "text",
  543. "content_text": output_text, "content_json": {},
  544. }, headers=headers)
  545. assistant_message_id = _get_string(assistant_message, "id")
  546. await _post_json(
  547. client=client, target=session_target, path="run-requests/update",
  548. payload={
  549. "run_request_id": run_request_id, "request_status": request_status,
  550. "request_payload_json": {
  551. **run_request_payload, "user_message_id": user_message_id,
  552. "assistant_message_id": assistant_message_id,
  553. "agent_run_id": agent_run_id, "team_run_id": team_run_id,
  554. "output_text": output_text, "error_message": error_message,
  555. },
  556. }, headers=headers)
  557. yield _sse("session.execute.completed", {
  558. "run_request_id": run_request_id, "request_status": request_status,
  559. "assistant_message_id": assistant_message_id, "output_text": output_text,
  560. "error_message": error_message,
  561. })
  562. except HTTPException as exc:
  563. detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
  564. yield _sse("session.execute.failed", {"error_message": detail})
  565. except Exception as exc:
  566. yield _sse("session.execute.failed", {"error_message": str(exc)})
  567. finally:
  568. await client.aclose()
  569. @router.api_route(
  570. "/gateway/sessions",
  571. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  572. @router.api_route(
  573. "/gateway/sessions/{path:path}",
  574. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  575. async def proxy_session_service(
  576. request: Request,
  577. settings: GatewaySettingsDep,
  578. proxy: ServiceProxyDep,
  579. path: str = "") -> Response:
  580. return await proxy.forward(
  581. request=request,
  582. target=build_proxy_targets(settings)["session-service"],
  583. path=path)
  584. @router.api_route(
  585. "/gateway/agents",
  586. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  587. @router.api_route(
  588. "/gateway/agents/{path:path}",
  589. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  590. async def proxy_agent_service(
  591. request: Request,
  592. settings: GatewaySettingsDep,
  593. proxy: ServiceProxyDep,
  594. path: str = "") -> Response:
  595. return await proxy.forward(
  596. request=request,
  597. target=build_proxy_targets(settings)["agent-service"],
  598. path=path)
  599. @router.api_route(
  600. "/gateway/memories",
  601. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  602. @router.api_route(
  603. "/gateway/memories/{path:path}",
  604. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  605. async def proxy_memory_service(
  606. request: Request,
  607. settings: GatewaySettingsDep,
  608. proxy: ServiceProxyDep,
  609. path: str = "") -> Response:
  610. return await proxy.forward(
  611. request=request,
  612. target=build_proxy_targets(settings)["memory-service"],
  613. path=path)
  614. @router.api_route(
  615. "/gateway/teams",
  616. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  617. @router.api_route(
  618. "/gateway/teams/{path:path}",
  619. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  620. async def proxy_team_service(
  621. request: Request,
  622. settings: GatewaySettingsDep,
  623. proxy: ServiceProxyDep,
  624. path: str = "") -> Response:
  625. return await proxy.forward(
  626. request=request,
  627. target=build_proxy_targets(settings)["team-service"],
  628. path=path)
  629. @router.api_route(
  630. "/gateway/skills",
  631. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  632. @router.api_route(
  633. "/gateway/skills/{path:path}",
  634. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  635. async def proxy_skill_service(
  636. request: Request,
  637. settings: GatewaySettingsDep,
  638. proxy: ServiceProxyDep,
  639. path: str = "") -> Response:
  640. return await proxy.forward(
  641. request=request,
  642. target=build_proxy_targets(settings)["skill-service"],
  643. path=path)
  644. @router.api_route(
  645. "/gateway/human",
  646. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  647. @router.api_route(
  648. "/gateway/human/{path:path}",
  649. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  650. async def proxy_human_service(
  651. request: Request,
  652. settings: GatewaySettingsDep,
  653. proxy: ServiceProxyDep,
  654. path: str = "") -> Response:
  655. return await proxy.forward(
  656. request=request,
  657. target=build_proxy_targets(settings)["human-service"],
  658. path=path)
  659. @router.api_route(
  660. "/gateway/knowledge",
  661. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  662. @router.api_route(
  663. "/gateway/knowledge/{path:path}",
  664. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  665. async def proxy_knowledge_service(
  666. request: Request,
  667. settings: GatewaySettingsDep,
  668. proxy: ServiceProxyDep,
  669. path: str = "") -> Response:
  670. return await proxy.forward(
  671. request=request,
  672. target=build_proxy_targets(settings)["knowledge-service"],
  673. path=path)
  674. @router.api_route(
  675. "/gateway/events",
  676. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  677. @router.api_route(
  678. "/gateway/events/{path:path}",
  679. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  680. async def proxy_event_service(
  681. request: Request,
  682. settings: GatewaySettingsDep,
  683. proxy: ServiceProxyDep,
  684. path: str = "") -> Response:
  685. return await proxy.forward(
  686. request=request,
  687. target=build_proxy_targets(settings)["event-service"],
  688. path=path)
  689. @router.api_route(
  690. "/gateway/identity",
  691. methods=["POST"])
  692. @router.api_route(
  693. "/gateway/identity/{path:path}",
  694. methods=["POST"])
  695. async def proxy_identity_service(
  696. request: Request,
  697. settings: GatewaySettingsDep,
  698. proxy: ServiceProxyDep,
  699. path: str = "") -> Response:
  700. return await proxy.forward(
  701. request=request,
  702. target=build_proxy_targets(settings)["identity-service"],
  703. path=path)
  704. @router.api_route(
  705. "/gateway/scheduler",
  706. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  707. @router.api_route(
  708. "/gateway/scheduler/{path:path}",
  709. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  710. async def proxy_scheduler_service(
  711. request: Request,
  712. settings: GatewaySettingsDep,
  713. proxy: ServiceProxyDep,
  714. path: str = "") -> Response:
  715. return await proxy.forward(
  716. request=request,
  717. target=build_proxy_targets(settings)["scheduler-service"],
  718. path=path)
  719. @router.api_route(
  720. "/gateway/tools",
  721. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  722. @router.api_route(
  723. "/gateway/tools/{path:path}",
  724. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  725. async def proxy_tool_service(
  726. request: Request,
  727. settings: GatewaySettingsDep,
  728. proxy: ServiceProxyDep,
  729. path: str = "") -> Response:
  730. return await proxy.forward(
  731. request=request,
  732. target=build_proxy_targets(settings)["tool-service"],
  733. path=path)
  734. @router.api_route(
  735. "/gateway/models",
  736. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  737. @router.api_route(
  738. "/gateway/models/{path:path}",
  739. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  740. async def proxy_model_gateway_service(
  741. request: Request,
  742. settings: GatewaySettingsDep,
  743. proxy: ServiceProxyDep,
  744. path: str = "") -> Response:
  745. return await proxy.forward(
  746. request=request,
  747. target=build_proxy_targets(settings)["model-gateway-service"],
  748. path=path)
  749. @router.api_route(
  750. "/gateway/model-providers",
  751. methods=["POST"])
  752. @router.api_route(
  753. "/gateway/model-providers/{path:path}",
  754. methods=["POST"])
  755. async def proxy_model_provider_service(
  756. request: Request,
  757. settings: GatewaySettingsDep,
  758. proxy: ServiceProxyDep,
  759. path: str = "") -> Response:
  760. return await proxy.forward(
  761. request=request,
  762. target=build_proxy_targets(settings)["model-provider-service"],
  763. path=path)
  764. @router.api_route(
  765. "/gateway/code",
  766. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  767. @router.api_route(
  768. "/gateway/code/{path:path}",
  769. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  770. async def proxy_code_runner_service(
  771. request: Request,
  772. settings: GatewaySettingsDep,
  773. proxy: ServiceProxyDep,
  774. path: str = "") -> Response:
  775. return await proxy.forward(
  776. request=request,
  777. target=build_proxy_targets(settings)["code-runner-service"],
  778. path=path)
  779. def _build_internal_headers(request: Request, settings: ApiGatewaySettings) -> dict[str, str]:
  780. headers = build_internal_service_headers(settings)
  781. authorization = request.headers.get("authorization")
  782. user_id = request.headers.get("x-user-id")
  783. if authorization:
  784. headers["authorization"] = authorization
  785. if user_id:
  786. headers["x-user-id"] = user_id
  787. return headers
  788. def httpx_client(timeout_seconds: float) -> httpx.AsyncClient:
  789. return httpx.AsyncClient(timeout=timeout_seconds)
  790. async def _post_json(
  791. *,
  792. client: httpx.AsyncClient,
  793. target: ProxyTarget,
  794. path: str,
  795. payload: dict[str, object],
  796. headers: dict[str, str]) -> dict[str, object]:
  797. url = _target_url(target, path)
  798. try:
  799. response = await client.post(url, headers=headers, json=payload)
  800. except httpx.HTTPError as exc:
  801. raise HTTPException(status_code=502, detail=f"{target.service_name} request failed: {exc}") from exc
  802. if not response.is_success:
  803. raise HTTPException(status_code=response.status_code, detail=_error_detail(response))
  804. data = response.json()
  805. if not isinstance(data, dict):
  806. raise HTTPException(status_code=502, detail=f"{target.service_name} returned unexpected response")
  807. return data
  808. def _target_url(target: ProxyTarget, path: str) -> str:
  809. normalized_path = path.strip("/")
  810. if normalized_path:
  811. return f"{target.base_url.rstrip('/')}{target.path_prefix}/{normalized_path}"
  812. return f"{target.base_url.rstrip('/')}{target.path_prefix}"
  813. def _error_detail(response: httpx.Response) -> str:
  814. try:
  815. payload = response.json()
  816. except ValueError:
  817. return response.text or f"downstream request failed with {response.status_code}"
  818. if isinstance(payload, dict):
  819. detail = payload.get("detail")
  820. if isinstance(detail, str):
  821. return detail
  822. error = payload.get("error")
  823. if isinstance(error, dict):
  824. message = error.get("message")
  825. if isinstance(message, str):
  826. return message
  827. return response.text or f"downstream request failed with {response.status_code}"
  828. def _get_string(payload: dict[str, object], key: str) -> str:
  829. value = payload.get(key)
  830. if not isinstance(value, str) or not value:
  831. raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
  832. return value
  833. def _get_optional_string(payload: dict[str, object], key: str) -> str | None:
  834. value = payload.get(key)
  835. return value if isinstance(value, str) and value else None
  836. def _get_dict(payload: dict[str, object], key: str) -> dict[str, object]:
  837. value = payload.get(key)
  838. if not isinstance(value, dict):
  839. raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
  840. return value
  841. def _resolve_output_text(run_payload: dict[str, object]) -> str | None:
  842. output_text = _get_optional_string(run_payload, "output_text")
  843. if output_text:
  844. return output_text
  845. output_json = run_payload.get("output_json")
  846. if isinstance(output_json, dict) and output_json:
  847. return json.dumps(output_json, ensure_ascii=False)
  848. return None
  849. def _sse(event: str, data: dict) -> str:
  850. return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
  851. async def _parse_sse(response: httpx.Response):
  852. current_event = "message"
  853. current_data = ""
  854. async for line in response.aiter_lines():
  855. if line.startswith("event:"):
  856. current_event = line[6:].strip()
  857. elif line.startswith("data:"):
  858. current_data = line[5:].strip()
  859. elif line == "":
  860. if current_data:
  861. yield current_event, current_data
  862. current_event = "message"
  863. current_data = ""
  864. if current_data:
  865. yield current_event, current_data
  866. async def _read_stream_error(response: httpx.Response) -> str:
  867. body = await response.aread()
  868. text = body.decode(errors="replace")
  869. try:
  870. data = json.loads(text)
  871. if isinstance(data, dict):
  872. detail = data.get("detail")
  873. if isinstance(detail, str):
  874. return detail
  875. except (ValueError, UnicodeDecodeError):
  876. pass
  877. return text or f"downstream error {response.status_code}"