routes.py 59 KB

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