routes.py 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508
  1. import asyncio
  2. import json
  3. from datetime import datetime
  4. from time import perf_counter
  5. from typing import Annotated, AsyncIterator
  6. from uuid import uuid4
  7. from core_domain import ServiceDescriptor, ServiceHealth
  8. import httpx
  9. from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, StreamingResponse
  10. from sqlalchemy import text
  11. from sqlalchemy.orm import Session
  12. from pydantic import BaseModel
  13. from app.bootstrap.settings import ApiGatewaySettings
  14. from app.db.session import get_db
  15. from app.domain.repositories import (
  16. ApiKeyRepository,
  17. AppApiKeyRepository,
  18. AppDefinitionRepository,
  19. AppInvocationAuditRepository,
  20. GatewayRequestAuditRepository,
  21. )
  22. from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
  23. from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
  24. from core_shared import error_detail
  25. from core_shared.security import build_internal_service_headers
  26. from app.schemas.gateway import (
  27. ApiKeyCreateRequest,
  28. ApiKeyCreateResponse,
  29. ApiKeyListRequest,
  30. ApiKeyResponse,
  31. ApiKeyStatusPostRequest,
  32. ApiKeyStatusUpdateRequest,
  33. AppApiKeyCreateRequest,
  34. AppApiKeyCreateResponse,
  35. AppApiKeyListRequest,
  36. AppApiKeyResponse,
  37. AppApiKeyStatusUpdateRequest,
  38. AppAuditListRequest,
  39. AppCreateRequest,
  40. AppDetailRequest,
  41. AppInvocationAuditResponse,
  42. AppListRequest,
  43. AppResponse,
  44. AppStatusUpdateRequest,
  45. AppUpdateRequest,
  46. GatewayAuditServiceStats,
  47. GatewayAuditStatsResponse,
  48. GatewayRequestAuditResponse,
  49. GatewayServicesHealthResponse,
  50. OpenApiChatRequest,
  51. OpenApiChatResponse,
  52. )
  53. router = APIRouter()
  54. DbSession = Annotated[Session, Depends(get_db)]
  55. class SessionExecuteRequest(BaseModel):
  56. session_id: str
  57. message_text: str
  58. stream: bool = False
  59. class SessionExecuteResponse(BaseModel):
  60. session_id: str
  61. run_request_id: str
  62. target_type: str
  63. target_id: str
  64. target_config_id: str | None = None
  65. request_status: str
  66. user_message_id: str
  67. assistant_message_id: str | None = None
  68. agent_run_id: str | None = None
  69. team_run_id: str | None = None
  70. output_text: str | None = None
  71. error_message: str | None = None
  72. @router.get("/health", response_model=ServiceDescriptor)
  73. def health_check(db: DbSession) -> ServiceDescriptor:
  74. db.execute(text("SELECT 1"))
  75. return ServiceDescriptor(name="api-gateway")
  76. @router.get("/ready", response_model=ServiceHealth)
  77. def readiness_check(db: DbSession) -> ServiceHealth:
  78. db.execute(text("SELECT 1"))
  79. return ServiceHealth(service="api-gateway", status="ok", database="ok")
  80. @router.post("/gateway/api-keys", response_model=ApiKeyCreateResponse)
  81. def create_api_key(
  82. payload: ApiKeyCreateRequest,
  83. db: DbSession) -> ApiKeyCreateResponse:
  84. api_key = generate_api_key()
  85. entity = ApiKeyRepository(db).create(
  86. name=payload.name,
  87. key_prefix=get_api_key_prefix(api_key),
  88. key_hash=hash_api_key(api_key),
  89. scopes=payload.scopes,
  90. expires_time=payload.expires_time)
  91. return ApiKeyCreateResponse(
  92. id=entity.id,
  93. name=entity.name,
  94. key_prefix=entity.key_prefix,
  95. api_key=api_key,
  96. status=entity.status,
  97. scopes=entity.scopes,
  98. expires_time=entity.expires_time,
  99. created_time=entity.created_time)
  100. @router.get("/gateway/api-keys", response_model=list[ApiKeyResponse])
  101. def list_api_keys(
  102. db: DbSession) -> list[ApiKeyResponse]:
  103. return [
  104. ApiKeyResponse.from_entity(item)
  105. for item in ApiKeyRepository(db).list_all()
  106. ]
  107. @router.post("/gateway/api-keys/list", response_model=list[ApiKeyResponse])
  108. def list_api_keys_post(
  109. payload: ApiKeyListRequest,
  110. db: DbSession) -> list[ApiKeyResponse]:
  111. return [
  112. ApiKeyResponse.from_entity(item)
  113. for item in ApiKeyRepository(db).list_all()
  114. ]
  115. @router.patch("/gateway/api-keys/{api_key_id}/status", response_model=ApiKeyResponse)
  116. def update_api_key_status(
  117. api_key_id: str,
  118. payload: ApiKeyStatusUpdateRequest,
  119. db: DbSession) -> ApiKeyResponse:
  120. entity = ApiKeyRepository(db).update_status(
  121. api_key_id=api_key_id,
  122. status=payload.status)
  123. if entity is None:
  124. raise HTTPException(status_code=404, detail=error_detail("error.api_key.not_found", id=api_key_id))
  125. return ApiKeyResponse.from_entity(entity)
  126. @router.post("/gateway/api-keys/status", response_model=ApiKeyResponse)
  127. def update_api_key_status_post(
  128. payload: ApiKeyStatusPostRequest,
  129. db: DbSession) -> ApiKeyResponse:
  130. entity = ApiKeyRepository(db).update_status(
  131. api_key_id=payload.api_key_id,
  132. status=payload.status)
  133. if entity is None:
  134. raise HTTPException(status_code=404, detail=error_detail("error.api_key.not_found", id=payload.api_key_id))
  135. return ApiKeyResponse.from_entity(entity)
  136. @router.get("/gateway/audits", response_model=list[GatewayRequestAuditResponse])
  137. def list_gateway_audits(
  138. db: DbSession,
  139. request_id: Annotated[str | None, Query()] = None,
  140. target_service: Annotated[str | None, Query()] = None,
  141. limit: Annotated[int, Query(ge=1, le=500)] = 100) -> list[GatewayRequestAuditResponse]:
  142. items = GatewayRequestAuditRepository(db).list_by_scope(
  143. request_id=request_id,
  144. target_service=target_service,
  145. limit=limit)
  146. return [GatewayRequestAuditResponse.from_entity(item) for item in items]
  147. @router.get("/gateway/audits/stats", response_model=GatewayAuditStatsResponse)
  148. def gateway_audit_stats(
  149. db: DbSession) -> GatewayAuditStatsResponse:
  150. rows = GatewayRequestAuditRepository(db).stats_by_service()
  151. services = [
  152. GatewayAuditServiceStats(
  153. target_service=target_service,
  154. request_count=request_count,
  155. error_count=error_count,
  156. average_duration_ms=round(average_duration_ms, 2))
  157. for target_service, request_count, error_count, average_duration_ms in rows
  158. ]
  159. return GatewayAuditStatsResponse(
  160. total_request_count=sum(item.request_count for item in services),
  161. total_error_count=sum(item.error_count for item in services),
  162. services=services)
  163. def get_gateway_settings() -> ApiGatewaySettings:
  164. return ApiGatewaySettings()
  165. def get_service_proxy(
  166. settings: Annotated[ApiGatewaySettings, Depends(get_gateway_settings)]) -> ServiceProxy:
  167. return ServiceProxy(settings=settings, timeout_seconds=settings.proxy_timeout_seconds)
  168. GatewaySettingsDep = Annotated[ApiGatewaySettings, Depends(get_gateway_settings)]
  169. ServiceProxyDep = Annotated[ServiceProxy, Depends(get_service_proxy)]
  170. def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName, ProxyTarget]:
  171. return {
  172. "session-service": ProxyTarget(
  173. service_name="session-service",
  174. base_url=settings.session_service_url,
  175. path_prefix="/sessions",
  176. health_path="/sessions/health"),
  177. "tool-service": ProxyTarget(
  178. service_name="tool-service",
  179. base_url=settings.tool_service_url,
  180. path_prefix="/tools",
  181. health_path="/tools/health"),
  182. "model-gateway-service": ProxyTarget(
  183. service_name="model-gateway-service",
  184. base_url=settings.model_gateway_service_url,
  185. path_prefix="/models",
  186. health_path="/models/health"),
  187. "model-provider-service": ProxyTarget(
  188. service_name="model-provider-service",
  189. base_url=settings.model_gateway_service_url,
  190. path_prefix="/models/providers",
  191. health_path="/models/health"),
  192. "code-runner-service": ProxyTarget(
  193. service_name="code-runner-service",
  194. base_url=settings.code_runner_service_url,
  195. path_prefix="/code",
  196. health_path="/code/health"),
  197. "agent-service": ProxyTarget(
  198. service_name="agent-service",
  199. base_url=settings.agent_service_url,
  200. path_prefix="/agents",
  201. health_path="/agents/health"),
  202. "memory-service": ProxyTarget(
  203. service_name="memory-service",
  204. base_url=settings.memory_service_url,
  205. path_prefix="/memories",
  206. health_path="/memories/health"),
  207. "team-service": ProxyTarget(
  208. service_name="team-service",
  209. base_url=settings.team_service_url,
  210. path_prefix="/teams",
  211. health_path="/teams/health"),
  212. "skill-service": ProxyTarget(
  213. service_name="skill-service",
  214. base_url=settings.skill_service_url,
  215. path_prefix="/skills",
  216. health_path="/skills/health"),
  217. "human-service": ProxyTarget(
  218. service_name="human-service",
  219. base_url=settings.human_service_url,
  220. path_prefix="/human",
  221. health_path="/human/health"),
  222. "knowledge-service": ProxyTarget(
  223. service_name="knowledge-service",
  224. base_url=settings.knowledge_service_url,
  225. path_prefix="/knowledge",
  226. health_path="/knowledge/health"),
  227. "event-service": ProxyTarget(
  228. service_name="event-service",
  229. base_url=settings.event_service_url,
  230. path_prefix="/events",
  231. health_path="/events/health"),
  232. "identity-service": ProxyTarget(
  233. service_name="identity-service",
  234. base_url=settings.auth_service_url,
  235. path_prefix="/identity",
  236. health_path="/identity/health"),
  237. "scheduler-service": ProxyTarget(
  238. service_name="scheduler-service",
  239. base_url=settings.scheduler_service_url,
  240. path_prefix="/scheduler",
  241. health_path="/scheduler/health"),
  242. }
  243. @router.get("/gateway/services/health", response_model=GatewayServicesHealthResponse)
  244. async def downstream_health_check(
  245. settings: GatewaySettingsDep) -> GatewayServicesHealthResponse:
  246. targets = build_proxy_targets(settings)
  247. health_proxy = ServiceProxy(
  248. settings=settings,
  249. timeout_seconds=settings.downstream_health_timeout_seconds)
  250. downstream_services = await asyncio.gather(
  251. *[health_proxy.check_health(target) for target in targets.values()]
  252. )
  253. status = "ok" if all(item.status == "ok" for item in downstream_services) else "degraded"
  254. return GatewayServicesHealthResponse(
  255. status=status,
  256. downstream_services=downstream_services)
  257. @router.post("/gateway/sessions/execute")
  258. async def execute_session(
  259. payload: SessionExecuteRequest,
  260. request: Request,
  261. settings: GatewaySettingsDep):
  262. if payload.stream:
  263. return StreamingResponse(
  264. _stream_session_execute(payload, request, settings),
  265. media_type="text/event-stream",
  266. headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
  267. targets = build_proxy_targets(settings)
  268. session_target = targets["session-service"]
  269. agent_target = targets["agent-service"]
  270. team_target = targets["team-service"]
  271. headers = _build_internal_headers(request, settings)
  272. async with httpx_client(settings.proxy_timeout_seconds) as client:
  273. session = await _post_json(
  274. client=client,
  275. target=session_target,
  276. path="detail",
  277. payload={"session_id": payload.session_id},
  278. headers=headers)
  279. target_type = _get_string(session, "runtime_target_type")
  280. target_id = _get_string(session, "runtime_target_id")
  281. target_config_id = _get_optional_string(session, "runtime_target_config_id")
  282. if target_type not in {"agent", "team"} or not target_id:
  283. raise HTTPException(status_code=422, detail=error_detail("error.session.runtime_not_configured"))
  284. run_request_payload = {
  285. "target_type": target_type,
  286. "target_id": target_id,
  287. "target_config_id": target_config_id,
  288. "mode": "production",
  289. "input_text": payload.message_text,
  290. }
  291. run_request = await _post_json(
  292. client=client,
  293. target=session_target,
  294. path="run-requests",
  295. payload={
  296. "session_id": payload.session_id,
  297. "app_config_id": target_config_id or target_id,
  298. "workflow_config_id": target_id,
  299. "trigger_type": "chat",
  300. "request_payload_json": run_request_payload,
  301. "request_status": "accepted",
  302. },
  303. headers=headers)
  304. run_request_id = _get_string(run_request, "id")
  305. user_message = await _post_json(
  306. client=client,
  307. target=session_target,
  308. path="messages",
  309. payload={
  310. "session_id": payload.session_id,
  311. "turn_id": run_request_id,
  312. "role": "user",
  313. "content_type": "text",
  314. "content_text": payload.message_text,
  315. "content_json": {},
  316. },
  317. headers=headers)
  318. user_message_id = _get_string(user_message, "id")
  319. await _post_json(
  320. client=client,
  321. target=session_target,
  322. path="run-requests/update",
  323. payload={
  324. "run_request_id": run_request_id,
  325. "request_status": "running",
  326. "request_payload_json": {
  327. **run_request_payload,
  328. "user_message_id": user_message_id,
  329. },
  330. },
  331. headers=headers)
  332. assistant_message_id: str | None = None
  333. agent_run_id: str | None = None
  334. team_run_id: str | None = None
  335. output_text: str | None = None
  336. error_message: str | None = None
  337. request_status = "completed"
  338. try:
  339. if target_type == "agent":
  340. agent_run = await _post_json(
  341. client=client,
  342. target=agent_target,
  343. path="runs",
  344. payload={
  345. "agent_id": target_id,
  346. "agent_config_id": target_config_id,
  347. "session_id": payload.session_id,
  348. "input_text": payload.message_text,
  349. "input_json": {
  350. "source": "session",
  351. "run_request_id": run_request_id,
  352. },
  353. },
  354. headers=headers)
  355. agent_run_id = _get_string(agent_run, "id")
  356. execute_result = await _post_json(
  357. client=client,
  358. target=agent_target,
  359. path="runs/execute",
  360. payload={
  361. "agent_run_id": agent_run_id,
  362. "dry_run": False,
  363. },
  364. headers=headers)
  365. run_data = _get_dict(execute_result, "run")
  366. output_text = _resolve_output_text(run_data)
  367. error_message = _get_optional_string(run_data, "error_message")
  368. else:
  369. team_run = await _post_json(
  370. client=client,
  371. target=team_target,
  372. path="runs",
  373. payload={
  374. "team_id": target_id,
  375. "team_config_id": target_config_id,
  376. "session_id": payload.session_id,
  377. "input_text": payload.message_text,
  378. "input_json": {
  379. "source": "session",
  380. "run_request_id": run_request_id,
  381. },
  382. "enqueue": True,
  383. },
  384. headers=headers)
  385. team_run_id = _get_string(team_run, "id")
  386. execute_result = await _post_json(
  387. client=client,
  388. target=team_target,
  389. path=f"runs/{team_run_id}/execute",
  390. payload={
  391. "dry_run": False,
  392. },
  393. headers=headers)
  394. run_data = _get_dict(execute_result, "run")
  395. output_text = _resolve_output_text(run_data)
  396. error_message = _get_optional_string(run_data, "error_message")
  397. if error_message:
  398. request_status = "failed"
  399. if output_text:
  400. assistant_message = await _post_json(
  401. client=client,
  402. target=session_target,
  403. path="messages",
  404. payload={
  405. "session_id": payload.session_id,
  406. "turn_id": run_request_id,
  407. "role": "assistant",
  408. "content_type": "text",
  409. "content_text": output_text,
  410. "content_json": {},
  411. },
  412. headers=headers)
  413. assistant_message_id = _get_string(assistant_message, "id")
  414. except HTTPException as exc:
  415. request_status = "failed"
  416. error_message = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
  417. await _post_json(
  418. client=client,
  419. target=session_target,
  420. path="run-requests/update",
  421. payload={
  422. "run_request_id": run_request_id,
  423. "request_status": request_status,
  424. "request_payload_json": {
  425. **run_request_payload,
  426. "user_message_id": user_message_id,
  427. "assistant_message_id": assistant_message_id,
  428. "agent_run_id": agent_run_id,
  429. "team_run_id": team_run_id,
  430. "output_text": output_text,
  431. "error_message": error_message,
  432. },
  433. },
  434. headers=headers)
  435. return SessionExecuteResponse(
  436. session_id=payload.session_id,
  437. run_request_id=run_request_id,
  438. target_type=target_type,
  439. target_id=target_id,
  440. target_config_id=target_config_id,
  441. request_status=request_status,
  442. user_message_id=user_message_id,
  443. assistant_message_id=assistant_message_id,
  444. agent_run_id=agent_run_id,
  445. team_run_id=team_run_id,
  446. output_text=output_text,
  447. error_message=error_message)
  448. async def _stream_session_execute(
  449. payload: SessionExecuteRequest,
  450. request: Request,
  451. settings: ApiGatewaySettings):
  452. targets = build_proxy_targets(settings)
  453. session_target = targets["session-service"]
  454. agent_target = targets["agent-service"]
  455. team_target = targets["team-service"]
  456. headers = _build_internal_headers(request, settings)
  457. client = httpx.AsyncClient(timeout=settings.proxy_timeout_seconds)
  458. try:
  459. session = await _post_json(
  460. client=client, target=session_target, path="detail",
  461. payload={"session_id": payload.session_id}, headers=headers)
  462. target_type = _get_string(session, "runtime_target_type")
  463. target_id = _get_string(session, "runtime_target_id")
  464. target_config_id = _get_optional_string(session, "runtime_target_config_id")
  465. if target_type not in {"agent", "team"} or not target_id:
  466. raise HTTPException(status_code=422, detail=error_detail("error.session.runtime_not_configured"))
  467. run_request_payload = {
  468. "target_type": target_type, "target_id": target_id,
  469. "target_config_id": target_config_id, "mode": "production",
  470. "input_text": payload.message_text,
  471. }
  472. run_request = await _post_json(
  473. client=client, target=session_target, path="run-requests",
  474. payload={
  475. "session_id": payload.session_id,
  476. "app_config_id": target_config_id or target_id,
  477. "workflow_config_id": target_id,
  478. "trigger_type": "chat",
  479. "request_payload_json": run_request_payload,
  480. "request_status": "accepted",
  481. }, headers=headers)
  482. run_request_id = _get_string(run_request, "id")
  483. user_message = await _post_json(
  484. client=client, target=session_target, path="messages",
  485. payload={
  486. "session_id": payload.session_id, "turn_id": run_request_id,
  487. "role": "user", "content_type": "text",
  488. "content_text": payload.message_text, "content_json": {},
  489. }, headers=headers)
  490. user_message_id = _get_string(user_message, "id")
  491. await _post_json(
  492. client=client, target=session_target, path="run-requests/update",
  493. payload={
  494. "run_request_id": run_request_id, "request_status": "running",
  495. "request_payload_json": {**run_request_payload, "user_message_id": user_message_id},
  496. }, headers=headers)
  497. yield _sse("session.execute.started", {
  498. "run_request_id": run_request_id, "user_message_id": user_message_id,
  499. "target_type": target_type, "target_id": target_id,
  500. })
  501. output_text = ""
  502. error_message: str | None = None
  503. agent_run_id: str | None = None
  504. team_run_id: str | None = None
  505. if target_type == "agent":
  506. agent_run = await _post_json(
  507. client=client, target=agent_target, path="runs",
  508. payload={
  509. "agent_id": target_id, "agent_config_id": target_config_id,
  510. "session_id": payload.session_id, "input_text": payload.message_text,
  511. "input_json": {"source": "session", "run_request_id": run_request_id},
  512. }, headers=headers)
  513. agent_run_id = _get_string(agent_run, "id")
  514. stream_url = _target_url(agent_target, f"runs/{agent_run_id}/execute-stream")
  515. async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
  516. if not resp.is_success:
  517. error_message = await _read_stream_error(resp)
  518. else:
  519. async for ev_name, ev_data in _parse_sse(resp):
  520. data = json.loads(ev_data)
  521. yield _sse(ev_name, data)
  522. if ev_name == "agent.run.delta" and isinstance(data.get("text"), str):
  523. output_text += data["text"]
  524. elif ev_name == "agent.run.completed":
  525. run_data = data.get("run", data)
  526. if not output_text and isinstance(run_data.get("output_text"), str):
  527. output_text = run_data["output_text"]
  528. elif ev_name == "agent.run.failed":
  529. error_message = data.get("error_message", "Agent execution failed")
  530. if not isinstance(error_message, str):
  531. error_message = "Agent execution failed"
  532. else:
  533. team_run = await _post_json(
  534. client=client, target=team_target, path="runs",
  535. payload={
  536. "team_id": target_id, "team_config_id": target_config_id,
  537. "session_id": payload.session_id, "input_text": payload.message_text,
  538. "input_json": {"source": "session", "run_request_id": run_request_id},
  539. "enqueue": True,
  540. }, headers=headers)
  541. team_run_id = _get_string(team_run, "id")
  542. stream_url = _target_url(team_target, f"runs/{team_run_id}/execute-stream")
  543. async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
  544. if not resp.is_success:
  545. error_message = await _read_stream_error(resp)
  546. else:
  547. async for ev_name, ev_data in _parse_sse(resp):
  548. data = json.loads(ev_data)
  549. yield _sse(ev_name, data)
  550. if ev_name == "team.run.delta" and isinstance(data.get("text"), str):
  551. output_text += data["text"]
  552. elif ev_name == "team.run.completed":
  553. run_data = data.get("run", data)
  554. if not output_text and isinstance(run_data.get("output_text"), str):
  555. output_text = run_data["output_text"]
  556. elif ev_name == "team.run.failed":
  557. error_message = data.get("error_message", "Team execution failed")
  558. if not isinstance(error_message, str):
  559. error_message = "Team execution failed"
  560. request_status = "failed" if error_message else "completed"
  561. assistant_message_id: str | None = None
  562. if output_text:
  563. assistant_message = await _post_json(
  564. client=client, target=session_target, path="messages",
  565. payload={
  566. "session_id": payload.session_id, "turn_id": run_request_id,
  567. "role": "assistant", "content_type": "text",
  568. "content_text": output_text, "content_json": {},
  569. }, headers=headers)
  570. assistant_message_id = _get_string(assistant_message, "id")
  571. await _post_json(
  572. client=client, target=session_target, path="run-requests/update",
  573. payload={
  574. "run_request_id": run_request_id, "request_status": request_status,
  575. "request_payload_json": {
  576. **run_request_payload, "user_message_id": user_message_id,
  577. "assistant_message_id": assistant_message_id,
  578. "agent_run_id": agent_run_id, "team_run_id": team_run_id,
  579. "output_text": output_text, "error_message": error_message,
  580. },
  581. }, headers=headers)
  582. yield _sse("session.execute.completed", {
  583. "run_request_id": run_request_id, "request_status": request_status,
  584. "assistant_message_id": assistant_message_id, "output_text": output_text,
  585. "error_message": error_message,
  586. })
  587. except HTTPException as exc:
  588. detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
  589. yield _sse("session.execute.failed", {"error_message": detail})
  590. except Exception as exc:
  591. yield _sse("session.execute.failed", {"error_message": str(exc)})
  592. finally:
  593. await client.aclose()
  594. # ── Application Admin Routes ─────────────────────────────────────────────────
  595. @router.post("/gateway/apps", response_model=AppResponse)
  596. def create_app(payload: AppCreateRequest, db: DbSession) -> AppResponse:
  597. existing = AppDefinitionRepository(db).get_by_code(code=payload.code)
  598. if existing is not None:
  599. raise HTTPException(status_code=409, detail=error_detail("error.app.code_exists", code=payload.code))
  600. entity = AppDefinitionRepository(db).create(
  601. code=payload.code,
  602. name=payload.name,
  603. description=payload.description,
  604. target_type=payload.target_type,
  605. target_id=payload.target_id,
  606. owner_user_id=payload.owner_user_id,
  607. settings_json=payload.settings_json)
  608. return AppResponse.from_entity(entity)
  609. @router.post("/gateway/apps/list", response_model=list[AppResponse])
  610. def list_apps(payload: AppListRequest, db: DbSession) -> list[AppResponse]:
  611. return [AppResponse.from_entity(e) for e in AppDefinitionRepository(db).list_all()]
  612. @router.post("/gateway/apps/detail", response_model=AppResponse)
  613. def get_app_detail(payload: AppDetailRequest, db: DbSession) -> AppResponse:
  614. entity = AppDefinitionRepository(db).get_by_id(app_id=payload.app_id)
  615. if entity is None:
  616. raise HTTPException(status_code=404, detail=error_detail("error.app.not_found", id=payload.app_id))
  617. return AppResponse.from_entity(entity)
  618. @router.post("/gateway/apps/update", response_model=AppResponse)
  619. def update_app(payload: AppUpdateRequest, db: DbSession) -> AppResponse:
  620. entity = AppDefinitionRepository(db).update(
  621. app_id=payload.app_id,
  622. name=payload.name,
  623. description=payload.description,
  624. target_type=payload.target_type,
  625. target_id=payload.target_id,
  626. settings_json=payload.settings_json)
  627. if entity is None:
  628. raise HTTPException(status_code=404, detail=error_detail("error.app.not_found", id=payload.app_id))
  629. return AppResponse.from_entity(entity)
  630. @router.post("/gateway/apps/status", response_model=AppResponse)
  631. def update_app_status(payload: AppStatusUpdateRequest, db: DbSession) -> AppResponse:
  632. entity = AppDefinitionRepository(db).update_status(app_id=payload.app_id, status=payload.status)
  633. if entity is None:
  634. raise HTTPException(status_code=404, detail=error_detail("error.app.not_found", id=payload.app_id))
  635. return AppResponse.from_entity(entity)
  636. @router.post("/gateway/apps/{app_id}/api-keys", response_model=AppApiKeyCreateResponse)
  637. def create_app_api_key(app_id: str, payload: AppApiKeyCreateRequest, db: DbSession) -> AppApiKeyCreateResponse:
  638. app_entity = AppDefinitionRepository(db).get_by_id(app_id=app_id)
  639. if app_entity is None:
  640. raise HTTPException(status_code=404, detail=error_detail("error.app.not_found", id=app_id))
  641. api_key = generate_api_key()
  642. entity = AppApiKeyRepository(db).create(
  643. app_id=app_id,
  644. name=payload.name,
  645. key_prefix=get_api_key_prefix(api_key),
  646. key_hash=hash_api_key(api_key),
  647. scopes=payload.scopes,
  648. expires_time=payload.expires_time)
  649. return AppApiKeyCreateResponse(
  650. id=entity.id,
  651. app_id=entity.app_id,
  652. name=entity.name,
  653. key_prefix=entity.key_prefix,
  654. api_key=api_key,
  655. status=entity.status,
  656. scopes=entity.scopes,
  657. expires_time=entity.expires_time,
  658. created_time=entity.created_time)
  659. @router.post("/gateway/apps/{app_id}/api-keys/list", response_model=list[AppApiKeyResponse])
  660. def list_app_api_keys(app_id: str, payload: AppApiKeyListRequest, db: DbSession) -> list[AppApiKeyResponse]:
  661. return [AppApiKeyResponse.from_entity(e) for e in AppApiKeyRepository(db).list_by_app(app_id=app_id)]
  662. @router.post("/gateway/apps/{app_id}/api-keys/status", response_model=AppApiKeyResponse)
  663. def update_app_api_key_status(app_id: str, payload: AppApiKeyStatusUpdateRequest, db: DbSession) -> AppApiKeyResponse:
  664. entity = AppApiKeyRepository(db).update_status(api_key_id=payload.api_key_id, status=payload.status)
  665. if entity is None:
  666. raise HTTPException(status_code=404, detail=error_detail("error.api_key.not_found", id=payload.api_key_id))
  667. return AppApiKeyResponse.from_entity(entity)
  668. @router.post("/gateway/apps/{app_id}/audits", response_model=list[AppInvocationAuditResponse])
  669. def list_app_audits(app_id: str, payload: AppAuditListRequest, db: DbSession) -> list[AppInvocationAuditResponse]:
  670. return [
  671. AppInvocationAuditResponse.from_entity(e)
  672. for e in AppInvocationAuditRepository(db).list_by_app(app_id=app_id, limit=payload.limit)
  673. ]
  674. # ── OpenAPI External Invocation ──────────────────────────────────────────────
  675. def _authenticate_app_api_key(request: Request, db: Session):
  676. settings = ApiGatewaySettings()
  677. token: str | None = None
  678. authorization = request.headers.get("authorization")
  679. if authorization:
  680. scheme, _, t = authorization.partition(" ")
  681. if scheme.lower() == "bearer" and t.strip():
  682. token = t.strip()
  683. if token is None:
  684. token = request.headers.get(settings.api_key_header_name)
  685. if not token:
  686. raise HTTPException(status_code=401, detail=error_detail("error.auth.missing_token"))
  687. key_hash = hash_api_key(token)
  688. key_entity = AppApiKeyRepository(db).get_active_by_hash(key_hash=key_hash)
  689. if key_entity is None:
  690. raise HTTPException(status_code=401, detail=error_detail("error.api_key.invalid"))
  691. if key_entity.expires_time is not None and key_entity.expires_time <= datetime.utcnow():
  692. raise HTTPException(status_code=401, detail=error_detail("error.api_key.expired"))
  693. app_entity = AppDefinitionRepository(db).get_by_id(app_id=key_entity.app_id)
  694. if app_entity is None:
  695. raise HTTPException(status_code=401, detail=error_detail("error.app.not_found"))
  696. if app_entity.status != "published":
  697. raise HTTPException(status_code=403, detail=error_detail("error.app.not_published", status=app_entity.status))
  698. AppApiKeyRepository(db).touch_last_used_time(api_key_id=key_entity.id)
  699. return key_entity, app_entity
  700. @router.post("/gateway/openapi/apps/{app_code}/chat", response_model=OpenApiChatResponse)
  701. async def openapi_chat(app_code: str, payload: OpenApiChatRequest, request: Request, db: DbSession):
  702. start = perf_counter()
  703. request_id = str(uuid4())
  704. key_entity, app_entity = _authenticate_app_api_key(request, db)
  705. if app_entity.code != app_code:
  706. raise HTTPException(status_code=403, detail=error_detail("error.api_key.unauthorized"))
  707. targets = build_proxy_targets(ApiGatewaySettings())
  708. session_target = targets["session-service"]
  709. agent_target = targets["agent-service"]
  710. team_target = targets["team-service"]
  711. headers = _build_internal_headers(request, ApiGatewaySettings())
  712. async with httpx_client(ApiGatewaySettings().proxy_timeout_seconds) as client:
  713. session_id = payload.session_id
  714. if not session_id:
  715. session_data = await _post_json(
  716. client=client, target=session_target, path="",
  717. payload={
  718. "app_id": app_entity.id,
  719. "user_id": payload.user_id or "openapi",
  720. "channel_type": "openapi",
  721. "runtime_target_type": app_entity.target_type,
  722. "runtime_target_id": app_entity.target_id,
  723. }, headers=headers)
  724. session_id = _get_string(session_data, "id")
  725. run_request_payload = {
  726. "target_type": app_entity.target_type,
  727. "target_id": app_entity.target_id,
  728. "mode": "production",
  729. "input_text": payload.message,
  730. }
  731. run_request = await _post_json(
  732. client=client, target=session_target, path="run-requests",
  733. payload={
  734. "session_id": session_id,
  735. "app_config_id": app_entity.target_id,
  736. "workflow_config_id": app_entity.target_id,
  737. "trigger_type": "chat",
  738. "request_payload_json": run_request_payload,
  739. "request_status": "accepted",
  740. }, headers=headers)
  741. run_request_id = _get_string(run_request, "id")
  742. user_message = await _post_json(
  743. client=client, target=session_target, path="messages",
  744. payload={
  745. "session_id": session_id, "turn_id": run_request_id,
  746. "role": "user", "content_type": "text",
  747. "content_text": payload.message, "content_json": {},
  748. }, headers=headers)
  749. await _post_json(
  750. client=client, target=session_target, path="run-requests/update",
  751. payload={
  752. "run_request_id": run_request_id, "request_status": "running",
  753. "request_payload_json": {**run_request_payload, "user_message_id": _get_string(user_message, "id")},
  754. }, headers=headers)
  755. output_text: str | None = None
  756. error_message: str | None = None
  757. request_status = "completed"
  758. try:
  759. if app_entity.target_type == "agent":
  760. agent_run = await _post_json(
  761. client=client, target=agent_target, path="runs",
  762. payload={
  763. "agent_id": app_entity.target_id,
  764. "session_id": session_id,
  765. "input_text": payload.message,
  766. "input_json": {"source": "openapi", "run_request_id": run_request_id},
  767. }, headers=headers)
  768. agent_run_id = _get_string(agent_run, "id")
  769. execute_result = await _post_json(
  770. client=client, target=agent_target, path="runs/execute",
  771. payload={"agent_run_id": agent_run_id, "dry_run": False}, headers=headers)
  772. run_data = _get_dict(execute_result, "run")
  773. output_text = _resolve_output_text(run_data)
  774. error_message = _get_optional_string(run_data, "error_message")
  775. else:
  776. team_run = await _post_json(
  777. client=client, target=team_target, path="runs",
  778. payload={
  779. "team_id": app_entity.target_id,
  780. "session_id": session_id,
  781. "input_text": payload.message,
  782. "input_json": {"source": "openapi", "run_request_id": run_request_id},
  783. "enqueue": True,
  784. }, headers=headers)
  785. team_run_id = _get_string(team_run, "id")
  786. execute_result = await _post_json(
  787. client=client, target=team_target, path=f"runs/{team_run_id}/execute",
  788. payload={"dry_run": False}, headers=headers)
  789. run_data = _get_dict(execute_result, "run")
  790. output_text = _resolve_output_text(run_data)
  791. error_message = _get_optional_string(run_data, "error_message")
  792. if error_message:
  793. request_status = "failed"
  794. if output_text:
  795. await _post_json(
  796. client=client, target=session_target, path="messages",
  797. payload={
  798. "session_id": session_id, "turn_id": run_request_id,
  799. "role": "assistant", "content_type": "text",
  800. "content_text": output_text, "content_json": {},
  801. }, headers=headers)
  802. except HTTPException as exc:
  803. request_status = "failed"
  804. error_message = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
  805. await _post_json(
  806. client=client, target=session_target, path="run-requests/update",
  807. payload={
  808. "run_request_id": run_request_id, "request_status": request_status,
  809. "request_payload_json": {
  810. **run_request_payload,
  811. "user_message_id": _get_string(user_message, "id"),
  812. "output_text": output_text, "error_message": error_message,
  813. },
  814. }, headers=headers)
  815. duration_ms = int((perf_counter() - start) * 1000)
  816. AppInvocationAuditRepository(db).create(
  817. app_id=app_entity.id,
  818. api_key_prefix=key_entity.key_prefix,
  819. request_id=request_id,
  820. session_id=session_id,
  821. run_request_id=run_request_id,
  822. target_type=app_entity.target_type,
  823. target_id=app_entity.target_id,
  824. invoke_type="sync",
  825. status=request_status,
  826. duration_ms=duration_ms,
  827. error_message=error_message,
  828. client_metadata_json=json.dumps(payload.metadata) if payload.metadata else None)
  829. return OpenApiChatResponse(
  830. request_id=request_id,
  831. app_code=app_entity.code,
  832. session_id=session_id,
  833. run_request_id=run_request_id,
  834. target_type=app_entity.target_type,
  835. target_id=app_entity.target_id,
  836. status=request_status,
  837. output_text=output_text,
  838. error=error_message)
  839. @router.post("/gateway/openapi/apps/{app_code}/chat/stream")
  840. async def openapi_chat_stream(app_code: str, payload: OpenApiChatRequest, request: Request):
  841. settings = ApiGatewaySettings()
  842. session_factory = request.app.state.session_factory
  843. auth_db = session_factory()
  844. try:
  845. key_entity, app_entity = _authenticate_app_api_key(request, auth_db)
  846. except HTTPException as exc:
  847. auth_db.close()
  848. detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
  849. return StreamingResponse(
  850. _single_sse("failed", {"status": "failed", "error_code": "auth_error", "error_message": detail}),
  851. media_type="text/event-stream",
  852. headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
  853. finally:
  854. auth_db.close()
  855. if app_entity.code != app_code:
  856. return StreamingResponse(
  857. _single_sse("failed", {"status": "failed", "error_code": "forbidden", "error_message": "api key does not belong to this app"}),
  858. media_type="text/event-stream",
  859. headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
  860. if app_entity.target_type != "agent":
  861. return StreamingResponse(
  862. _single_sse("failed", {"status": "failed", "error_code": "unsupported", "error_message": "streaming is only supported for agent targets in V0.1"}),
  863. media_type="text/event-stream",
  864. headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
  865. return StreamingResponse(
  866. _stream_openapi_chat(app_code, payload, request, key_entity, app_entity, session_factory, settings),
  867. media_type="text/event-stream",
  868. headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
  869. def _single_sse(event: str, data: dict) -> AsyncIterator[str]:
  870. async def _gen():
  871. yield _sse(event, data)
  872. return _gen()
  873. async def _stream_openapi_chat(
  874. app_code: str,
  875. payload: OpenApiChatRequest,
  876. request: Request,
  877. key_entity,
  878. app_entity,
  879. session_factory,
  880. settings: ApiGatewaySettings):
  881. start = perf_counter()
  882. request_id = str(uuid4())
  883. targets = build_proxy_targets(settings)
  884. session_target = targets["session-service"]
  885. agent_target = targets["agent-service"]
  886. headers = _build_internal_headers(request, settings)
  887. client = httpx.AsyncClient(timeout=settings.proxy_timeout_seconds)
  888. output_text = ""
  889. error_message: str | None = None
  890. session_id: str | None = None
  891. run_request_id: str | None = None
  892. request_status = "failed"
  893. try:
  894. session_id = payload.session_id
  895. if not session_id:
  896. session_data = await _post_json(
  897. client=client, target=session_target, path="",
  898. payload={
  899. "app_id": app_entity.id,
  900. "user_id": payload.user_id or "openapi",
  901. "channel_type": "openapi",
  902. "runtime_target_type": app_entity.target_type,
  903. "runtime_target_id": app_entity.target_id,
  904. }, headers=headers)
  905. session_id = _get_string(session_data, "id")
  906. run_request_payload = {
  907. "target_type": app_entity.target_type,
  908. "target_id": app_entity.target_id,
  909. "mode": "production",
  910. "input_text": payload.message,
  911. }
  912. run_request = await _post_json(
  913. client=client, target=session_target, path="run-requests",
  914. payload={
  915. "session_id": session_id,
  916. "app_config_id": app_entity.target_id,
  917. "workflow_config_id": app_entity.target_id,
  918. "trigger_type": "chat",
  919. "request_payload_json": run_request_payload,
  920. "request_status": "accepted",
  921. }, headers=headers)
  922. run_request_id = _get_string(run_request, "id")
  923. user_message = await _post_json(
  924. client=client, target=session_target, path="messages",
  925. payload={
  926. "session_id": session_id, "turn_id": run_request_id,
  927. "role": "user", "content_type": "text",
  928. "content_text": payload.message, "content_json": {},
  929. }, headers=headers)
  930. user_message_id = _get_string(user_message, "id")
  931. await _post_json(
  932. client=client, target=session_target, path="run-requests/update",
  933. payload={
  934. "run_request_id": run_request_id, "request_status": "running",
  935. "request_payload_json": {**run_request_payload, "user_message_id": user_message_id},
  936. }, headers=headers)
  937. yield _sse("started", {
  938. "request_id": request_id,
  939. "session_id": session_id,
  940. "run_request_id": run_request_id})
  941. agent_run = await _post_json(
  942. client=client, target=agent_target, path="runs",
  943. payload={
  944. "agent_id": app_entity.target_id,
  945. "session_id": session_id,
  946. "input_text": payload.message,
  947. "input_json": {"source": "openapi", "run_request_id": run_request_id},
  948. }, headers=headers)
  949. agent_run_id = _get_string(agent_run, "id")
  950. stream_url = _target_url(agent_target, f"runs/{agent_run_id}/execute-stream")
  951. async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
  952. if not resp.is_success:
  953. error_message = await _read_stream_error(resp)
  954. else:
  955. async for ev_name, ev_data in _parse_sse(resp):
  956. data = json.loads(ev_data)
  957. if ev_name == "agent.run.delta":
  958. text_chunk = data.get("text", "")
  959. yield _sse("delta", {"text": text_chunk})
  960. if isinstance(text_chunk, str):
  961. output_text += text_chunk
  962. elif ev_name == "agent.run.completed":
  963. run_data = data.get("run", data)
  964. final_text = _get_optional_string(run_data, "output_text")
  965. if not output_text and final_text:
  966. output_text = final_text
  967. yield _sse("completed", {"status": "completed", "output_text": output_text})
  968. elif ev_name == "agent.run.failed":
  969. msg = data.get("error_message", "Agent execution failed")
  970. if not isinstance(msg, str):
  971. msg = "Agent execution failed"
  972. error_message = msg
  973. yield _sse("failed", {"status": "failed", "error_code": "agent_error", "error_message": msg})
  974. else:
  975. yield _sse(ev_name, data)
  976. request_status = "failed" if error_message else "completed"
  977. if output_text:
  978. await _post_json(
  979. client=client, target=session_target, path="messages",
  980. payload={
  981. "session_id": session_id, "turn_id": run_request_id,
  982. "role": "assistant", "content_type": "text",
  983. "content_text": output_text, "content_json": {},
  984. }, headers=headers)
  985. await _post_json(
  986. client=client, target=session_target, path="run-requests/update",
  987. payload={
  988. "run_request_id": run_request_id, "request_status": request_status,
  989. "request_payload_json": {
  990. **run_request_payload, "user_message_id": user_message_id,
  991. "output_text": output_text, "error_message": error_message,
  992. },
  993. }, headers=headers)
  994. except HTTPException as exc:
  995. detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
  996. yield _sse("failed", {"status": "failed", "error_code": "gateway_error", "error_message": detail})
  997. except Exception as exc:
  998. yield _sse("failed", {"status": "failed", "error_code": "internal_error", "error_message": str(exc)})
  999. finally:
  1000. await client.aclose()
  1001. duration_ms = int((perf_counter() - start) * 1000)
  1002. audit_db = session_factory()
  1003. try:
  1004. AppInvocationAuditRepository(audit_db).create(
  1005. app_id=app_entity.id,
  1006. api_key_prefix=key_entity.key_prefix,
  1007. request_id=request_id,
  1008. session_id=session_id,
  1009. run_request_id=run_request_id,
  1010. target_type=app_entity.target_type,
  1011. target_id=app_entity.target_id,
  1012. invoke_type="stream",
  1013. status=request_status,
  1014. duration_ms=duration_ms,
  1015. error_message=error_message,
  1016. client_metadata_json=json.dumps(payload.metadata) if payload.metadata else None)
  1017. except Exception:
  1018. pass
  1019. finally:
  1020. audit_db.close()
  1021. @router.api_route(
  1022. "/gateway/sessions",
  1023. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1024. @router.api_route(
  1025. "/gateway/sessions/{path:path}",
  1026. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1027. async def proxy_session_service(
  1028. request: Request,
  1029. settings: GatewaySettingsDep,
  1030. proxy: ServiceProxyDep,
  1031. path: str = "") -> Response:
  1032. return await proxy.forward(
  1033. request=request,
  1034. target=build_proxy_targets(settings)["session-service"],
  1035. path=path)
  1036. @router.api_route(
  1037. "/gateway/agents",
  1038. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1039. @router.api_route(
  1040. "/gateway/agents/{path:path}",
  1041. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1042. async def proxy_agent_service(
  1043. request: Request,
  1044. settings: GatewaySettingsDep,
  1045. proxy: ServiceProxyDep,
  1046. path: str = "") -> Response:
  1047. return await proxy.forward(
  1048. request=request,
  1049. target=build_proxy_targets(settings)["agent-service"],
  1050. path=path)
  1051. @router.api_route(
  1052. "/gateway/memories",
  1053. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1054. @router.api_route(
  1055. "/gateway/memories/{path:path}",
  1056. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1057. async def proxy_memory_service(
  1058. request: Request,
  1059. settings: GatewaySettingsDep,
  1060. proxy: ServiceProxyDep,
  1061. path: str = "") -> Response:
  1062. return await proxy.forward(
  1063. request=request,
  1064. target=build_proxy_targets(settings)["memory-service"],
  1065. path=path)
  1066. @router.api_route(
  1067. "/gateway/teams",
  1068. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1069. @router.api_route(
  1070. "/gateway/teams/{path:path}",
  1071. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1072. async def proxy_team_service(
  1073. request: Request,
  1074. settings: GatewaySettingsDep,
  1075. proxy: ServiceProxyDep,
  1076. path: str = "") -> Response:
  1077. return await proxy.forward(
  1078. request=request,
  1079. target=build_proxy_targets(settings)["team-service"],
  1080. path=path)
  1081. @router.api_route(
  1082. "/gateway/skills",
  1083. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1084. @router.api_route(
  1085. "/gateway/skills/{path:path}",
  1086. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1087. async def proxy_skill_service(
  1088. request: Request,
  1089. settings: GatewaySettingsDep,
  1090. proxy: ServiceProxyDep,
  1091. path: str = "") -> Response:
  1092. return await proxy.forward(
  1093. request=request,
  1094. target=build_proxy_targets(settings)["skill-service"],
  1095. path=path)
  1096. @router.api_route(
  1097. "/gateway/human",
  1098. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1099. @router.api_route(
  1100. "/gateway/human/{path:path}",
  1101. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1102. async def proxy_human_service(
  1103. request: Request,
  1104. settings: GatewaySettingsDep,
  1105. proxy: ServiceProxyDep,
  1106. path: str = "") -> Response:
  1107. return await proxy.forward(
  1108. request=request,
  1109. target=build_proxy_targets(settings)["human-service"],
  1110. path=path)
  1111. @router.api_route(
  1112. "/gateway/knowledge",
  1113. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1114. @router.api_route(
  1115. "/gateway/knowledge/{path:path}",
  1116. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1117. async def proxy_knowledge_service(
  1118. request: Request,
  1119. settings: GatewaySettingsDep,
  1120. proxy: ServiceProxyDep,
  1121. path: str = "") -> Response:
  1122. return await proxy.forward(
  1123. request=request,
  1124. target=build_proxy_targets(settings)["knowledge-service"],
  1125. path=path)
  1126. @router.api_route(
  1127. "/gateway/events",
  1128. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1129. @router.api_route(
  1130. "/gateway/events/{path:path}",
  1131. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1132. async def proxy_event_service(
  1133. request: Request,
  1134. settings: GatewaySettingsDep,
  1135. proxy: ServiceProxyDep,
  1136. path: str = "") -> Response:
  1137. return await proxy.forward(
  1138. request=request,
  1139. target=build_proxy_targets(settings)["event-service"],
  1140. path=path)
  1141. @router.api_route(
  1142. "/gateway/identity",
  1143. methods=["POST"])
  1144. @router.api_route(
  1145. "/gateway/identity/{path:path}",
  1146. methods=["POST"])
  1147. async def proxy_identity_service(
  1148. request: Request,
  1149. settings: GatewaySettingsDep,
  1150. proxy: ServiceProxyDep,
  1151. path: str = "") -> Response:
  1152. return await proxy.forward(
  1153. request=request,
  1154. target=build_proxy_targets(settings)["identity-service"],
  1155. path=path)
  1156. @router.api_route(
  1157. "/gateway/scheduler",
  1158. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1159. @router.api_route(
  1160. "/gateway/scheduler/{path:path}",
  1161. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1162. async def proxy_scheduler_service(
  1163. request: Request,
  1164. settings: GatewaySettingsDep,
  1165. proxy: ServiceProxyDep,
  1166. path: str = "") -> Response:
  1167. return await proxy.forward(
  1168. request=request,
  1169. target=build_proxy_targets(settings)["scheduler-service"],
  1170. path=path)
  1171. @router.api_route(
  1172. "/gateway/tools",
  1173. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1174. @router.api_route(
  1175. "/gateway/tools/{path:path}",
  1176. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1177. async def proxy_tool_service(
  1178. request: Request,
  1179. settings: GatewaySettingsDep,
  1180. proxy: ServiceProxyDep,
  1181. path: str = "") -> Response:
  1182. return await proxy.forward(
  1183. request=request,
  1184. target=build_proxy_targets(settings)["tool-service"],
  1185. path=path)
  1186. @router.api_route(
  1187. "/gateway/models",
  1188. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1189. @router.api_route(
  1190. "/gateway/models/{path:path}",
  1191. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1192. async def proxy_model_gateway_service(
  1193. request: Request,
  1194. settings: GatewaySettingsDep,
  1195. proxy: ServiceProxyDep,
  1196. path: str = "") -> Response:
  1197. return await proxy.forward(
  1198. request=request,
  1199. target=build_proxy_targets(settings)["model-gateway-service"],
  1200. path=path)
  1201. @router.api_route(
  1202. "/gateway/model-providers",
  1203. methods=["POST"])
  1204. @router.api_route(
  1205. "/gateway/model-providers/{path:path}",
  1206. methods=["POST"])
  1207. async def proxy_model_provider_service(
  1208. request: Request,
  1209. settings: GatewaySettingsDep,
  1210. proxy: ServiceProxyDep,
  1211. path: str = "") -> Response:
  1212. return await proxy.forward(
  1213. request=request,
  1214. target=build_proxy_targets(settings)["model-provider-service"],
  1215. path=path)
  1216. @router.api_route(
  1217. "/gateway/code",
  1218. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1219. @router.api_route(
  1220. "/gateway/code/{path:path}",
  1221. methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  1222. async def proxy_code_runner_service(
  1223. request: Request,
  1224. settings: GatewaySettingsDep,
  1225. proxy: ServiceProxyDep,
  1226. path: str = "") -> Response:
  1227. return await proxy.forward(
  1228. request=request,
  1229. target=build_proxy_targets(settings)["code-runner-service"],
  1230. path=path)
  1231. def _build_internal_headers(request: Request, settings: ApiGatewaySettings) -> dict[str, str]:
  1232. headers = build_internal_service_headers(settings)
  1233. authorization = request.headers.get("authorization")
  1234. user_id = request.headers.get("x-user-id")
  1235. if authorization:
  1236. headers["authorization"] = authorization
  1237. if user_id:
  1238. headers["x-user-id"] = user_id
  1239. return headers
  1240. def httpx_client(timeout_seconds: float) -> httpx.AsyncClient:
  1241. return httpx.AsyncClient(timeout=timeout_seconds)
  1242. async def _post_json(
  1243. *,
  1244. client: httpx.AsyncClient,
  1245. target: ProxyTarget,
  1246. path: str,
  1247. payload: dict[str, object],
  1248. headers: dict[str, str]) -> dict[str, object]:
  1249. url = _target_url(target, path)
  1250. try:
  1251. response = await client.post(url, headers=headers, json=payload)
  1252. except httpx.HTTPError as exc:
  1253. raise HTTPException(status_code=502, detail=error_detail("error.downstream.request_failed", service=target.service_name, error=str(exc))) from exc
  1254. if not response.is_success:
  1255. raise HTTPException(status_code=response.status_code, detail=_error_detail(response))
  1256. data = response.json()
  1257. if not isinstance(data, dict):
  1258. raise HTTPException(status_code=502, detail=error_detail("error.downstream.unexpected_response", service=target.service_name))
  1259. return data
  1260. def _target_url(target: ProxyTarget, path: str) -> str:
  1261. normalized_path = path.strip("/")
  1262. if normalized_path:
  1263. return f"{target.base_url.rstrip('/')}{target.path_prefix}/{normalized_path}"
  1264. return f"{target.base_url.rstrip('/')}{target.path_prefix}"
  1265. def _error_detail(response: httpx.Response):
  1266. try:
  1267. payload = response.json()
  1268. except ValueError:
  1269. return error_detail("error.downstream.generic_failure", status=str(response.status_code))
  1270. if isinstance(payload, dict):
  1271. detail = payload.get("detail")
  1272. if isinstance(detail, str):
  1273. return detail
  1274. error = payload.get("error")
  1275. if isinstance(error, dict):
  1276. message = error.get("message")
  1277. if isinstance(message, str):
  1278. return message
  1279. return error_detail("error.downstream.generic_failure", status=str(response.status_code))
  1280. def _get_string(payload: dict[str, object], key: str) -> str:
  1281. value = payload.get(key)
  1282. if not isinstance(value, str) or not value:
  1283. raise HTTPException(status_code=502, detail=error_detail("error.downstream.missing_field", field=key))
  1284. return value
  1285. def _get_optional_string(payload: dict[str, object], key: str) -> str | None:
  1286. value = payload.get(key)
  1287. return value if isinstance(value, str) and value else None
  1288. def _get_dict(payload: dict[str, object], key: str) -> dict[str, object]:
  1289. value = payload.get(key)
  1290. if not isinstance(value, dict):
  1291. raise HTTPException(status_code=502, detail=error_detail("error.downstream.missing_field", field=key))
  1292. return value
  1293. def _resolve_output_text(run_payload: dict[str, object]) -> str | None:
  1294. output_text = _get_optional_string(run_payload, "output_text")
  1295. if output_text:
  1296. return output_text
  1297. output_json = run_payload.get("output_json")
  1298. if isinstance(output_json, dict) and output_json:
  1299. return json.dumps(output_json, ensure_ascii=False)
  1300. return None
  1301. def _sse(event: str, data: dict) -> str:
  1302. return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
  1303. async def _parse_sse(response: httpx.Response):
  1304. current_event = "message"
  1305. current_data = ""
  1306. async for line in response.aiter_lines():
  1307. if line.startswith("event:"):
  1308. current_event = line[6:].strip()
  1309. elif line.startswith("data:"):
  1310. current_data = line[5:].strip()
  1311. elif line == "":
  1312. if current_data:
  1313. yield current_event, current_data
  1314. current_event = "message"
  1315. current_data = ""
  1316. if current_data:
  1317. yield current_event, current_data
  1318. async def _read_stream_error(response: httpx.Response) -> str:
  1319. body = await response.aread()
  1320. text = body.decode(errors="replace")
  1321. try:
  1322. data = json.loads(text)
  1323. if isinstance(data, dict):
  1324. detail = data.get("detail")
  1325. if isinstance(detail, str):
  1326. return detail
  1327. except (ValueError, UnicodeDecodeError):
  1328. pass
  1329. return text or f"downstream error {response.status_code}"