interloper-api 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ from interloper_api.app import create_app
2
+
3
+ __all__ = ["create_app"]
interloper_api/app.py ADDED
@@ -0,0 +1,100 @@
1
+ """FastAPI application factory for the interloper API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from interloper.catalog.base import Catalog
10
+ from interloper_db import Store
11
+
12
+ from interloper_api.dependencies import set_auth_config, set_catalog, set_smtp_config, set_store
13
+ from interloper_api.routes import (
14
+ assets,
15
+ auth,
16
+ backfills,
17
+ destinations,
18
+ external,
19
+ jobs,
20
+ oauth,
21
+ organisations,
22
+ resources,
23
+ runs,
24
+ sources,
25
+ ws,
26
+ )
27
+ from interloper_api.routes import catalog as catalog_routes
28
+
29
+
30
+ def create_app(
31
+ store: Store | None = None,
32
+ catalog: Catalog | None = None,
33
+ auth_config: Any | None = None,
34
+ smtp_config: Any | None = None,
35
+ cors_origins: list[str] | None = None,
36
+ **kwargs: Any,
37
+ ) -> FastAPI:
38
+ """Create the FastAPI application with all routes.
39
+
40
+ Args:
41
+ store: The ``Store`` instance for persistence.
42
+ catalog: Catalog instance.
43
+ auth_config: ``AuthConfig`` instance for authentication settings.
44
+ smtp_config: ``SmtpConfig`` instance for sending invitation emails.
45
+ cors_origins: Allowed CORS origins. Only needed in dev mode for direct
46
+ WebSocket connections that bypass the Vite proxy.
47
+ **kwargs: Additional kwargs forwarded to ``FastAPI()``.
48
+
49
+ Returns:
50
+ The configured FastAPI application.
51
+ """
52
+ app = FastAPI(title="Interloper API", lifespan=ws.realtime_lifespan, **kwargs)
53
+
54
+ if cors_origins:
55
+ app.add_middleware(
56
+ CORSMiddleware, # type: ignore[arg-type]
57
+ allow_origins=cors_origins,
58
+ allow_credentials=True,
59
+ allow_methods=["*"],
60
+ allow_headers=["*"],
61
+ )
62
+
63
+ if store:
64
+ set_store(store)
65
+ if catalog:
66
+ set_catalog(catalog)
67
+ if auth_config:
68
+ set_auth_config(auth_config)
69
+ if smtp_config:
70
+ set_smtp_config(smtp_config)
71
+
72
+ api = APIRouter(prefix="/api")
73
+ api.include_router(auth.router, tags=["auth"])
74
+ api.include_router(organisations.router, tags=["organisations"])
75
+ api.include_router(catalog_routes.router, prefix="/catalog", tags=["catalog"])
76
+ api.include_router(resources.router, prefix="/resources", tags=["resources"])
77
+ api.include_router(sources.router, prefix="/sources", tags=["sources"])
78
+ api.include_router(destinations.router, prefix="/destinations", tags=["destinations"])
79
+ api.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
80
+ api.include_router(runs.router, prefix="/runs", tags=["runs"])
81
+ api.include_router(backfills.router, prefix="/backfills", tags=["backfills"])
82
+ api.include_router(assets.router, prefix="/assets", tags=["assets"])
83
+ api.include_router(oauth.router, tags=["oauth"])
84
+ api.include_router(external.router, prefix="/external", tags=["external"])
85
+ api.include_router(ws.router, tags=["ws"])
86
+
87
+ try:
88
+ from interloper_api.routes import agent as agent_routes
89
+
90
+ api.include_router(agent_routes.router, prefix="/agent", tags=["agent"])
91
+ except ImportError:
92
+ pass
93
+
94
+ @api.get("/health")
95
+ def health() -> dict[str, str]:
96
+ return {"status": "ok"}
97
+
98
+ app.include_router(api)
99
+
100
+ return app
@@ -0,0 +1,293 @@
1
+ """Shared FastAPI dependencies for store, catalog, auth, and RBAC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from fastapi import Cookie, Depends, HTTPException
9
+ from interloper.catalog.base import Catalog
10
+ from interloper_db import Organisation, Profile, Store
11
+ from interloper_db.models import Session as SessionModel
12
+
13
+ _store: Store | None = None
14
+ _catalog: Catalog | None = None
15
+ _auth_config: Any | None = None
16
+ _smtp_config: Any | None = None
17
+
18
+ # Role hierarchy: admin > editor > viewer
19
+ _ROLE_RANK = {"viewer": 0, "editor": 1, "admin": 2}
20
+
21
+
22
+ def set_store(store: Store) -> None:
23
+ """Set the global store instance.
24
+
25
+ Args:
26
+ store: The Store to use for all API operations.
27
+ """
28
+ global _store # noqa: PLW0603
29
+ _store = store
30
+
31
+
32
+ def set_catalog(catalog: Catalog) -> None:
33
+ """Set the global catalog instance.
34
+
35
+ Args:
36
+ catalog: The Catalog instance.
37
+ """
38
+ global _catalog # noqa: PLW0603
39
+ _catalog = catalog
40
+
41
+
42
+ def set_auth_config(auth_config: Any) -> None:
43
+ """Set the global auth config.
44
+
45
+ Args:
46
+ auth_config: The AuthConfig instance.
47
+ """
48
+ global _auth_config # noqa: PLW0603
49
+ _auth_config = auth_config
50
+
51
+
52
+ def get_store() -> Store:
53
+ """Return the global store instance.
54
+
55
+ Returns:
56
+ The Store.
57
+
58
+ Raises:
59
+ RuntimeError: If the store has not been set.
60
+ """
61
+ if _store is None:
62
+ raise RuntimeError("Store not initialized. Call set_store() first.")
63
+ return _store
64
+
65
+
66
+ def get_catalog() -> dict[str, Any]:
67
+ """Return the global catalog as a serialized dict.
68
+
69
+ Returns:
70
+ Mapping from component key to serialized definition.
71
+
72
+ Raises:
73
+ RuntimeError: If the catalog has not been set.
74
+ """
75
+ if _catalog is None:
76
+ raise RuntimeError("Catalog not initialized. Call set_catalog() first.")
77
+ return _catalog.dump()
78
+
79
+
80
+ def get_auth_config() -> Any:
81
+ """Return the global auth config.
82
+
83
+ Returns:
84
+ The AuthConfig instance.
85
+
86
+ Raises:
87
+ RuntimeError: If the auth config has not been set.
88
+ """
89
+ if _auth_config is None:
90
+ raise RuntimeError("Auth config not initialized. Call set_auth_config() first.")
91
+ return _auth_config
92
+
93
+
94
+ def set_smtp_config(smtp_config: Any) -> None:
95
+ """Set the global SMTP config.
96
+
97
+ Args:
98
+ smtp_config: The SmtpConfig instance.
99
+ """
100
+ global _smtp_config # noqa: PLW0603
101
+ _smtp_config = smtp_config
102
+
103
+
104
+ def get_smtp_config() -> Any:
105
+ """Return the global SMTP config.
106
+
107
+ Returns:
108
+ The SmtpConfig instance, or None if not configured.
109
+ """
110
+ return _smtp_config
111
+
112
+
113
+ # -- Auth dependencies -------------------------------------------------------
114
+
115
+
116
+ def get_current_user(
117
+ store: Store = Depends(get_store),
118
+ session_token: str | None = Cookie(default=None),
119
+ ) -> Profile:
120
+ """Resolve the current user from the session cookie.
121
+
122
+ Args:
123
+ store: The Store instance.
124
+ session_token: Session cookie value.
125
+
126
+ Returns:
127
+ The authenticated Profile.
128
+
129
+ Raises:
130
+ HTTPException: 401 if not authenticated or session invalid/expired.
131
+ """
132
+ if not session_token:
133
+ raise HTTPException(status_code=401, detail="Not authenticated")
134
+
135
+ result = store.resolve_session(session_token)
136
+ if not result:
137
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
138
+
139
+ profile, _ = result
140
+ return profile
141
+
142
+
143
+ def get_session_context(
144
+ store: Store = Depends(get_store),
145
+ session_token: str | None = Cookie(default=None),
146
+ ) -> tuple[Profile, SessionModel]:
147
+ """Resolve user and session from the cookie.
148
+
149
+ Args:
150
+ store: The Store instance.
151
+ session_token: Session cookie value.
152
+
153
+ Returns:
154
+ ``(Profile, Session)`` tuple.
155
+
156
+ Raises:
157
+ HTTPException: 401 if not authenticated.
158
+ """
159
+ if not session_token:
160
+ raise HTTPException(status_code=401, detail="Not authenticated")
161
+
162
+ result = store.resolve_session(session_token)
163
+ if not result:
164
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
165
+
166
+ return result
167
+
168
+
169
+ def get_current_org(
170
+ store: Store = Depends(get_store),
171
+ session_token: str | None = Cookie(default=None),
172
+ ) -> Organisation:
173
+ """Resolve the current organisation from the session.
174
+
175
+ Args:
176
+ store: The Store instance.
177
+ session_token: Session cookie value.
178
+
179
+ Returns:
180
+ The active Organisation.
181
+
182
+ Raises:
183
+ HTTPException: 400 if no organisation selected, 401 if not authenticated.
184
+ """
185
+ if not session_token:
186
+ raise HTTPException(status_code=401, detail="Not authenticated")
187
+
188
+ result = store.resolve_session(session_token)
189
+ if not result:
190
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
191
+
192
+ _, session_row = result
193
+ if not session_row.organisation_id:
194
+ raise HTTPException(status_code=400, detail="No organisation selected")
195
+
196
+ org = store.get_organisation(session_row.organisation_id)
197
+ if not org:
198
+ raise HTTPException(status_code=404, detail="Organisation not found")
199
+
200
+ return org
201
+
202
+
203
+ def get_org_id(
204
+ org: Organisation = Depends(get_current_org),
205
+ ) -> UUID:
206
+ """Shorthand: return just the org UUID for route handlers.
207
+
208
+ Args:
209
+ org: The resolved Organisation.
210
+
211
+ Returns:
212
+ The organisation UUID.
213
+ """
214
+ return org.id # type: ignore[return-value]
215
+
216
+
217
+ # -- RBAC dependencies -------------------------------------------------------
218
+
219
+
220
+ def _check_role(
221
+ minimum: str,
222
+ user: Profile,
223
+ org_id: UUID,
224
+ store: Store,
225
+ ) -> Profile:
226
+ """Verify the user has at least the required role in the org.
227
+
228
+ Args:
229
+ minimum: Minimum role required (``viewer``, ``editor``, ``admin``).
230
+ user: The authenticated user.
231
+ org_id: The active organisation UUID.
232
+ store: The Store instance.
233
+
234
+ Returns:
235
+ The authenticated Profile (pass-through for dependency chaining).
236
+
237
+ Raises:
238
+ HTTPException: 403 if insufficient permissions.
239
+ """
240
+ role = store.get_user_role(user.id, org_id) # type: ignore[arg-type]
241
+ if role is None:
242
+ raise HTTPException(status_code=403, detail="Not a member of this organisation")
243
+ if _ROLE_RANK.get(role, -1) < _ROLE_RANK[minimum]:
244
+ raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
245
+ return user
246
+
247
+
248
+ def require_viewer(
249
+ user: Profile = Depends(get_current_user),
250
+ org_id: UUID = Depends(get_org_id),
251
+ store: Store = Depends(get_store),
252
+ ) -> Profile:
253
+ """Require at least ``viewer`` role. Any org member passes.
254
+
255
+ Returns:
256
+ The authenticated Profile.
257
+
258
+ Raises:
259
+ HTTPException: 401/403 on auth or role failure.
260
+ """
261
+ return _check_role("viewer", user, org_id, store)
262
+
263
+
264
+ def require_editor(
265
+ user: Profile = Depends(get_current_user),
266
+ org_id: UUID = Depends(get_org_id),
267
+ store: Store = Depends(get_store),
268
+ ) -> Profile:
269
+ """Require at least ``editor`` role.
270
+
271
+ Returns:
272
+ The authenticated Profile.
273
+
274
+ Raises:
275
+ HTTPException: 401/403 on auth or role failure.
276
+ """
277
+ return _check_role("editor", user, org_id, store)
278
+
279
+
280
+ def require_admin(
281
+ user: Profile = Depends(get_current_user),
282
+ org_id: UUID = Depends(get_org_id),
283
+ store: Store = Depends(get_store),
284
+ ) -> Profile:
285
+ """Require ``admin`` role.
286
+
287
+ Returns:
288
+ The authenticated Profile.
289
+
290
+ Raises:
291
+ HTTPException: 401/403 on auth or role failure.
292
+ """
293
+ return _check_role("admin", user, org_id, store)
@@ -0,0 +1,79 @@
1
+ """SMTP email utilities for sending invitation emails."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import smtplib
7
+ from email.mime.multipart import MIMEMultipart
8
+ from email.mime.text import MIMEText
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def send_invite_email(
15
+ smtp_config: Any,
16
+ to: str,
17
+ org_name: str,
18
+ inviter_name: str,
19
+ invite_url: str,
20
+ ) -> None:
21
+ """Send an organisation invitation email via SMTP.
22
+
23
+ Args:
24
+ smtp_config: SmtpConfig instance with host, port, user, password, from_addr.
25
+ to: Recipient email address.
26
+ org_name: Name of the organisation.
27
+ inviter_name: Display name of the person who sent the invite.
28
+ invite_url: Full URL to accept the invitation.
29
+
30
+ Raises:
31
+ RuntimeError: If SMTP is not configured.
32
+ smtplib.SMTPException: If sending fails.
33
+ """
34
+ if not smtp_config.enabled:
35
+ raise RuntimeError("SMTP is not configured. Set smtp.host, smtp.user, and smtp.password.")
36
+
37
+ msg = MIMEMultipart("alternative")
38
+ msg["Subject"] = f"You've been invited to join {org_name} on Interloper"
39
+ msg["From"] = smtp_config.from_addr
40
+ msg["To"] = to
41
+
42
+ text = (
43
+ f"{inviter_name} has invited you to join the {org_name} organisation on Interloper.\n\n"
44
+ f"Click the link below to accept the invitation:\n{invite_url}\n\n"
45
+ "This invitation expires in 7 days."
46
+ )
47
+
48
+ html = f"""\
49
+ <html>
50
+ <body style="font-family: sans-serif; color: #333;">
51
+ <h2>You've been invited to join {org_name}</h2>
52
+ <p>{inviter_name} has invited you to join the <strong>{org_name}</strong> organisation on Interloper.</p>
53
+ <p>
54
+ <a href="{invite_url}"
55
+ style="display: inline-block; padding: 10px 20px; background: #2563eb;
56
+ color: #fff; text-decoration: none; border-radius: 6px;">
57
+ Accept Invitation
58
+ </a>
59
+ </p>
60
+ <p style="color: #666; font-size: 0.875rem;">This invitation expires in 7 days.</p>
61
+ </body>
62
+ </html>"""
63
+
64
+ msg.attach(MIMEText(text, "plain"))
65
+ msg.attach(MIMEText(html, "html"))
66
+
67
+ logger.info("Sending invite email to %s", to)
68
+
69
+ if smtp_config.port == 465:
70
+ with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
71
+ server.login(smtp_config.user, smtp_config.password)
72
+ server.sendmail(smtp_config.from_addr, to, msg.as_string())
73
+ else:
74
+ with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
75
+ server.starttls()
76
+ server.login(smtp_config.user, smtp_config.password)
77
+ server.sendmail(smtp_config.from_addr, to, msg.as_string())
78
+
79
+ logger.info("Invite email sent to %s", to)
File without changes
@@ -0,0 +1,229 @@
1
+ """Agent API: ADK-powered chat sessions with SSE streaming.
2
+
3
+ Provides endpoints for creating agent chat sessions, sending messages,
4
+ and streaming responses. The ADK Runner executes the interloper agent
5
+ in-process, reusing the API's authenticated Store and catalog.
6
+
7
+ Available when ``interloper-agent`` is installed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from collections.abc import AsyncIterator
14
+ from typing import Any
15
+ from uuid import UUID
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, Response
18
+ from fastapi.responses import StreamingResponse
19
+ from google.adk.apps import App
20
+ from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
21
+ from google.adk.runners import Runner
22
+ from google.adk.sessions.in_memory_session_service import InMemorySessionService
23
+ from google.genai import types
24
+ from interloper.catalog.base import Catalog
25
+ from interloper_db import Profile, Store
26
+ from pydantic import BaseModel
27
+
28
+ from interloper_api.dependencies import get_catalog, get_org_id, get_store, require_editor, require_viewer
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ router = APIRouter()
33
+
34
+ APP_NAME = "interloper_agent"
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Lazy runner singleton
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _runner: Runner | None = None
41
+ _session_service: InMemorySessionService | None = None
42
+
43
+
44
+ def _get_session_service() -> InMemorySessionService:
45
+ """Return the shared session service, creating it on first call."""
46
+ global _session_service # noqa: PLW0603
47
+ if _session_service is None:
48
+ _session_service = InMemorySessionService()
49
+ return _session_service
50
+
51
+
52
+ def _get_runner(store: Store, catalog: Catalog) -> Runner:
53
+ """Return the shared Runner, creating it on first call.
54
+
55
+ On first call, injects the API's Store and catalog into the agent
56
+ context so that agent tools can access them.
57
+
58
+ Args:
59
+ store: The Store instance (from API dependencies).
60
+ catalog: The Catalog instance (from API dependencies).
61
+ """
62
+ global _runner # noqa: PLW0603
63
+ if _runner is None:
64
+ from interloper_agent.agent import root_agent
65
+ from interloper_agent.context import set_catalog, set_store
66
+
67
+ if store is not None:
68
+ set_store(store)
69
+ if catalog is not None:
70
+ set_catalog(catalog)
71
+
72
+ app = App(name=APP_NAME, root_agent=root_agent)
73
+ _runner = Runner(
74
+ app=app,
75
+ session_service=_get_session_service(),
76
+ artifact_service=InMemoryArtifactService(),
77
+ )
78
+ logger.info("Agent runner initialized")
79
+ return _runner
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Request / response models
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ class ChatRequest(BaseModel):
88
+ """Request body for sending a chat message."""
89
+
90
+ message: str
91
+
92
+
93
+ class SessionResponse(BaseModel):
94
+ """Response body for an agent session."""
95
+
96
+ id: str
97
+ user_id: str
98
+ app_name: str
99
+ state: dict[str, Any]
100
+ last_update_time: float
101
+ event_count: int
102
+
103
+
104
+ def _session_to_response(session: Any) -> SessionResponse:
105
+ """Convert an ADK Session to a response model."""
106
+ return SessionResponse(
107
+ id=session.id,
108
+ user_id=session.user_id,
109
+ app_name=session.app_name,
110
+ state=session.state,
111
+ last_update_time=session.last_update_time,
112
+ event_count=len(session.events),
113
+ )
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Endpoints
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ @router.post("/sessions")
122
+ async def create_session(
123
+ user: Profile = Depends(require_editor),
124
+ org_id: UUID = Depends(get_org_id),
125
+ store: Store = Depends(get_store),
126
+ catalog: Catalog = Depends(get_catalog),
127
+ ) -> SessionResponse:
128
+ """Create a new agent chat session.
129
+
130
+ The authenticated user's ``org_id`` is injected into the ADK session
131
+ state so agent tools are scoped to the correct organisation.
132
+ """
133
+ _get_runner(store=store, catalog=catalog)
134
+ session_service = _get_session_service()
135
+ session = await session_service.create_session(
136
+ app_name=APP_NAME,
137
+ user_id=str(user.id),
138
+ state={"org_id": str(org_id)},
139
+ )
140
+ return _session_to_response(session)
141
+
142
+
143
+ @router.get("/sessions")
144
+ async def list_sessions(
145
+ user: Profile = Depends(require_viewer),
146
+ ) -> list[SessionResponse]:
147
+ """List all agent sessions for the current user."""
148
+ session_service = _get_session_service()
149
+ result = await session_service.list_sessions(
150
+ app_name=APP_NAME,
151
+ user_id=str(user.id),
152
+ )
153
+ sessions = result.sessions if hasattr(result, "sessions") else result
154
+ return [_session_to_response(s) for s in sessions]
155
+
156
+
157
+ @router.get("/sessions/{session_id}")
158
+ async def get_session(
159
+ session_id: str,
160
+ user: Profile = Depends(require_viewer),
161
+ ) -> Response:
162
+ """Get a session with its full event history.
163
+
164
+ Returns the session object including all events (messages and tool calls).
165
+ """
166
+ session_service = _get_session_service()
167
+ session = await session_service.get_session(
168
+ app_name=APP_NAME,
169
+ user_id=str(user.id),
170
+ session_id=session_id,
171
+ )
172
+ if not session:
173
+ raise HTTPException(status_code=404, detail="Session not found")
174
+ return Response(
175
+ content=session.model_dump_json(exclude_none=True, by_alias=True),
176
+ media_type="application/json",
177
+ )
178
+
179
+
180
+ @router.delete("/sessions/{session_id}")
181
+ async def delete_session(
182
+ session_id: str,
183
+ user: Profile = Depends(require_editor),
184
+ ) -> dict[str, str]:
185
+ """Delete an agent session."""
186
+ session_service = _get_session_service()
187
+ session = await session_service.get_session(
188
+ app_name=APP_NAME,
189
+ user_id=str(user.id),
190
+ session_id=session_id,
191
+ )
192
+ if not session:
193
+ raise HTTPException(status_code=404, detail="Session not found")
194
+ await session_service.delete_session(
195
+ app_name=APP_NAME,
196
+ user_id=str(user.id),
197
+ session_id=session_id,
198
+ )
199
+ return {"status": "deleted"}
200
+
201
+
202
+ @router.post("/sessions/{session_id}/chat")
203
+ async def chat(
204
+ session_id: str,
205
+ body: ChatRequest,
206
+ user: Profile = Depends(require_editor),
207
+ store: Store = Depends(get_store),
208
+ catalog: Catalog = Depends(get_catalog),
209
+ ) -> StreamingResponse:
210
+ """Send a message and stream the agent's response as SSE.
211
+
212
+ Each SSE ``data`` line contains a JSON-serialized ADK Event.
213
+ Events with ``content.parts`` containing ``text`` are the agent's
214
+ text responses. Events with ``function_call`` or ``function_response``
215
+ parts represent tool invocations.
216
+ """
217
+ runner = _get_runner(store=store, catalog=catalog)
218
+ user_id = str(user.id)
219
+ message = types.Content(parts=[types.Part(text=body.message)], role="user")
220
+
221
+ async def event_stream() -> AsyncIterator[str]:
222
+ async for event in runner.run_async(
223
+ user_id=user_id,
224
+ session_id=session_id,
225
+ new_message=message,
226
+ ):
227
+ yield f"data: {event.model_dump_json(exclude_none=True, by_alias=True)}\n\n"
228
+
229
+ return StreamingResponse(event_stream(), media_type="text/event-stream")