interloper-api 0.90.0__tar.gz → 0.92.0__tar.gz

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.
Files changed (26) hide show
  1. {interloper_api-0.90.0 → interloper_api-0.92.0}/PKG-INFO +2 -2
  2. {interloper_api-0.90.0 → interloper_api-0.92.0}/pyproject.toml +2 -2
  3. {interloper_api-0.90.0 → interloper_api-0.92.0}/pyproject.toml.orig +2 -2
  4. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/app.py +5 -0
  5. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/dependencies/__init__.py +6 -0
  6. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/dependencies/state.py +27 -0
  7. interloper_api-0.92.0/src/interloper_api/routes/agent.py +296 -0
  8. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/auth.py +3 -3
  9. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/components.py +5 -5
  10. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/oauth.py +12 -9
  11. interloper_api-0.90.0/src/interloper_api/routes/agent.py +0 -289
  12. {interloper_api-0.90.0 → interloper_api-0.92.0}/README.md +0 -0
  13. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/__init__.py +0 -0
  14. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/dependencies/auth.py +0 -0
  15. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/dependencies/rbac.py +0 -0
  16. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/notifications/__init__.py +0 -0
  17. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/notifications/invitations.py +0 -0
  18. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/__init__.py +0 -0
  19. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/admin.py +0 -0
  20. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/backfills.py +0 -0
  21. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/catalog.py +0 -0
  22. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/health.py +0 -0
  23. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/organisations.py +0 -0
  24. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/runs.py +0 -0
  25. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/tokens.py +0 -0
  26. {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.90.0
3
+ Version: 0.92.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -8,7 +8,7 @@ Requires-Dist: interloper-core
8
8
  Requires-Dist: interloper-db
9
9
  Requires-Dist: fastapi>=0.115.0
10
10
  Requires-Dist: google-auth>=2.0.0
11
- Requires-Dist: httpx>=0.28.0
11
+ Requires-Dist: httpx2>=2.13.1
12
12
  Requires-Dist: psycopg2-binary>=2.9.0
13
13
  Requires-Dist: uvicorn[standard]>=0.41.0
14
14
  Requires-Dist: wsproto>=1.2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interloper-api"
3
- version = "0.90.0"
3
+ version = "0.92.0"
4
4
  description = "Interloper FastAPI routes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -9,7 +9,7 @@ dependencies = [
9
9
  "interloper-db",
10
10
  "fastapi>=0.115.0",
11
11
  "google-auth>=2.0.0",
12
- "httpx>=0.28.0",
12
+ "httpx2>=2.13.1",
13
13
  "psycopg2-binary>=2.9.0",
14
14
  "uvicorn[standard]>=0.41.0",
15
15
  "wsproto>=1.2.0",
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.90.0"
6
+ version = "0.92.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -13,7 +13,7 @@ dependencies = [
13
13
  "interloper-db",
14
14
  "fastapi>=0.115.0",
15
15
  "google-auth>=2.0.0",
16
- "httpx>=0.28.0",
16
+ "httpx2>=2.13.1",
17
17
  "psycopg2-binary>=2.9.0",
18
18
  "uvicorn[standard]>=0.41.0",
19
19
  "wsproto>=1.2.0",
@@ -15,6 +15,7 @@ from interloper_db import Store
15
15
 
16
16
  from interloper_api.dependencies import (
17
17
  set_admin_config,
18
+ set_agent,
18
19
  set_auth_config,
19
20
  set_catalog,
20
21
  set_features,
@@ -249,7 +250,11 @@ def _install_state(
249
250
  set_store(store)
250
251
  if catalog:
251
252
  set_catalog(catalog)
253
+ if agent_available:
254
+ from interloper.settings import AgentSettings
255
+ from interloper_agent import build_agent
252
256
 
257
+ set_agent(build_agent(settings.agent.model if settings else AgentSettings().model))
253
258
  set_features({"agent": agent_available})
254
259
 
255
260
  if settings:
@@ -27,11 +27,13 @@ from interloper_api.dependencies.rbac import (
27
27
  )
28
28
  from interloper_api.dependencies.state import (
29
29
  AdminConfigDep,
30
+ AgentDep,
30
31
  AuthConfigDep,
31
32
  CatalogDep,
32
33
  QuotaDefaultsDep,
33
34
  StoreDep,
34
35
  get_admin_config,
36
+ get_agent,
35
37
  get_auth_config,
36
38
  get_catalog,
37
39
  get_features,
@@ -39,6 +41,7 @@ from interloper_api.dependencies.state import (
39
41
  get_smtp_config,
40
42
  get_store,
41
43
  set_admin_config,
44
+ set_agent,
42
45
  set_auth_config,
43
46
  set_catalog,
44
47
  set_features,
@@ -50,6 +53,7 @@ from interloper_api.dependencies.state import (
50
53
  __all__ = [
51
54
  "AdminConfigDep",
52
55
  "AdminDep",
56
+ "AgentDep",
53
57
  "AuthConfigDep",
54
58
  "CatalogDep",
55
59
  "CurrentUserDep",
@@ -61,6 +65,7 @@ __all__ = [
61
65
  "ViewerDep",
62
66
  "authorize_org_member",
63
67
  "get_admin_config",
68
+ "get_agent",
64
69
  "get_auth_config",
65
70
  "get_catalog",
66
71
  "get_current_org",
@@ -77,6 +82,7 @@ __all__ = [
77
82
  "require_super_admin",
78
83
  "require_viewer",
79
84
  "set_admin_config",
85
+ "set_agent",
80
86
  "set_auth_config",
81
87
  "set_catalog",
82
88
  "set_features",
@@ -15,6 +15,7 @@ from interloper_db import Store
15
15
 
16
16
  _store: Store | None = None
17
17
  _catalog: Catalog | None = None
18
+ _agent: Any | None = None
18
19
  _auth_config: Any | None = None
19
20
  _smtp_config: Any | None = None
20
21
  _features: dict[str, bool] = {}
@@ -42,6 +43,31 @@ def set_catalog(catalog: Catalog) -> None:
42
43
  _catalog = catalog
43
44
 
44
45
 
46
+ def set_agent(agent: Any) -> None:
47
+ """Set the process-wide assistant.
48
+
49
+ Args:
50
+ agent: The built ``interloper_agent`` agent; typed loosely because the
51
+ agent extra is optional and this module must import without it.
52
+ """
53
+ global _agent
54
+ _agent = agent
55
+
56
+
57
+ def get_agent() -> Any:
58
+ """Return the process-wide assistant.
59
+
60
+ Returns:
61
+ The agent the chat route runs.
62
+
63
+ Raises:
64
+ RuntimeError: If the agent has not been set.
65
+ """
66
+ if _agent is None:
67
+ raise RuntimeError("Agent not initialized. Call set_agent() first.")
68
+ return _agent
69
+
70
+
45
71
  def set_auth_config(auth_config: Any) -> None:
46
72
  """Set the global auth config.
47
73
 
@@ -176,6 +202,7 @@ def get_quota_defaults() -> Any:
176
202
 
177
203
  StoreDep = Annotated[Store, Depends(get_store)]
178
204
  CatalogDep = Annotated[Catalog, Depends(get_catalog)]
205
+ AgentDep = Annotated[Any, Depends(get_agent)]
179
206
  AuthConfigDep = Annotated[Any, Depends(get_auth_config)]
180
207
  AdminConfigDep = Annotated[Any, Depends(get_admin_config)]
181
208
  QuotaDefaultsDep = Annotated[Any, Depends(get_quota_defaults)]
@@ -0,0 +1,296 @@
1
+ """Agent API: conversations with the assistant, streamed the Vercel AI SDK way.
2
+
3
+ A conversation is a member's own: its history lives in the store and the
4
+ server, not the client, is its source of truth. Each turn is one
5
+ :meth:`~pydantic_ai.ui.vercel_ai.VercelAIAdapter.dispatch_request`: the
6
+ client's request carries the new message (and any tool approvals or answers
7
+ the app collected), the stored history is passed as ``message_history``, and
8
+ the whole history is saved back when the run completes.
9
+
10
+ Available when ``interloper-agent`` is installed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from collections.abc import AsyncIterator
17
+ from datetime import datetime
18
+ from typing import Any
19
+ from uuid import UUID
20
+
21
+ from fastapi import APIRouter, Request, Response
22
+ from interloper_agent import TURN_LIMITS
23
+ from interloper_db.models import Conversation
24
+ from interloper_toolkit import ToolkitContext
25
+ from pydantic import BaseModel
26
+ from pydantic_ai.agent import AgentRunResult
27
+ from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter, ModelRequest, UserPromptPart
28
+ from pydantic_ai.ui import UIEventStream
29
+ from pydantic_ai.ui.vercel_ai import VercelAIAdapter, VercelAIEventStream
30
+ from pydantic_ai.ui.vercel_ai.request_types import RequestData, UIMessage
31
+ from pydantic_ai.ui.vercel_ai.response_types import BaseChunk
32
+
33
+ from interloper_api.dependencies import AgentDep, CatalogDep, EditorDep, OrgIdDep, StoreDep, ViewerDep
34
+
35
+ logger = logging.getLogger(__name__)
36
+ router = APIRouter(prefix="/agent", tags=["agent"])
37
+
38
+ SDK_VERSION = 7
39
+ """The AI SDK major version the app speaks; tool approvals need at least 6."""
40
+
41
+
42
+ class LoggedEventStream(VercelAIEventStream[ToolkitContext, Any]):
43
+ """The Vercel event stream, with a run's failure in the server log too.
44
+
45
+ The adapter answers a failed run with a 200 and an error chunk, which the
46
+ app renders as a generic message; without this, the cause would exist
47
+ nowhere on the server side.
48
+ """
49
+
50
+ async def on_error(self, error: Exception) -> AsyncIterator[BaseChunk]:
51
+ """Log the failure, then encode it for the client as the base class does.
52
+
53
+ Args:
54
+ error: The exception that ended the run.
55
+
56
+ Yields:
57
+ The protocol's error chunks.
58
+ """
59
+ logger.error("Agent turn failed: %s", error, exc_info=error)
60
+ async for chunk in super().on_error(error):
61
+ yield chunk
62
+
63
+
64
+ class LoggedAdapter(VercelAIAdapter[ToolkitContext, Any]):
65
+ """The Vercel adapter, building :class:`LoggedEventStream`."""
66
+
67
+ def build_event_stream(self) -> UIEventStream[RequestData, BaseChunk, ToolkitContext, Any]:
68
+ """Build the event stream that also logs failures.
69
+
70
+ Returns:
71
+ The stream transformer for this request.
72
+ """
73
+ return LoggedEventStream(
74
+ self.run_input, accept=self.accept, sdk_version=self.sdk_version, server_message_id=self.server_message_id
75
+ )
76
+
77
+
78
+ class ConversationResponse(BaseModel):
79
+ """A conversation as the list shows it."""
80
+
81
+ id: UUID
82
+ title: str | None
83
+ created_at: datetime | None
84
+ updated_at: datetime | None
85
+
86
+ @classmethod
87
+ def from_conversation(cls, conversation: Conversation) -> ConversationResponse:
88
+ """Project a conversation row.
89
+
90
+ Args:
91
+ conversation: The row.
92
+
93
+ Returns:
94
+ The response model.
95
+ """
96
+ return cls(
97
+ id=conversation.id,
98
+ title=conversation.title,
99
+ created_at=conversation.created_at,
100
+ updated_at=conversation.updated_at,
101
+ )
102
+
103
+
104
+ class ConversationDetailResponse(ConversationResponse):
105
+ """A conversation with its history in the AI SDK's ``UIMessage`` shape."""
106
+
107
+ messages: list[dict[str, Any]]
108
+
109
+ @classmethod
110
+ def from_conversation(cls, conversation: Conversation) -> ConversationDetailResponse:
111
+ """Project a conversation row and render its history for the app.
112
+
113
+ Args:
114
+ conversation: The row.
115
+
116
+ Returns:
117
+ The response model, its messages as the app's ``useChat`` loads them.
118
+ """
119
+ history = ModelMessagesTypeAdapter.validate_python(conversation.messages)
120
+ messages = _fold_turns(VercelAIAdapter.dump_messages(history, sdk_version=SDK_VERSION))
121
+ return cls(
122
+ **ConversationResponse.from_conversation(conversation).model_dump(),
123
+ messages=[message.model_dump(by_alias=True, exclude_none=True) for message in messages],
124
+ )
125
+
126
+
127
+ @router.post("/conversations", status_code=201)
128
+ def create_conversation(user: EditorDep, org_id: OrgIdDep, store: StoreDep) -> ConversationResponse:
129
+ """Start a conversation in the active organisation.
130
+
131
+ Args:
132
+ user: The authenticated user, required to hold at least the ``editor`` role.
133
+ org_id: The active organisation's UUID.
134
+ store: The Store instance.
135
+
136
+ Returns:
137
+ The new, empty conversation.
138
+ """
139
+ return ConversationResponse.from_conversation(store.conversations.create(org_id, user.id))
140
+
141
+
142
+ @router.get("/conversations")
143
+ def list_conversations(user: ViewerDep, org_id: OrgIdDep, store: StoreDep) -> list[ConversationResponse]:
144
+ """List the caller's conversations in the active organisation, newest first.
145
+
146
+ Args:
147
+ user: The authenticated user, required to hold at least the ``viewer`` role.
148
+ org_id: The active organisation's UUID.
149
+ store: The Store instance.
150
+
151
+ Returns:
152
+ The conversations, as response models.
153
+ """
154
+ rows = store.conversations.list_all(org_id, user.id)
155
+ return [ConversationResponse.from_conversation(row) for row in rows]
156
+
157
+
158
+ @router.get("/conversations/{conversation_id}")
159
+ def get_conversation(
160
+ conversation_id: UUID, user: ViewerDep, org_id: OrgIdDep, store: StoreDep
161
+ ) -> ConversationDetailResponse:
162
+ """Read one of the caller's conversations with its history.
163
+
164
+ Args:
165
+ conversation_id: The conversation UUID.
166
+ user: The authenticated user, required to hold at least the ``viewer`` role.
167
+ org_id: The active organisation's UUID.
168
+ store: The Store instance.
169
+
170
+ Returns:
171
+ The conversation and its messages, as response models.
172
+ """
173
+ row = store.conversations.get(conversation_id, org_id=org_id, user_id=user.id)
174
+ return ConversationDetailResponse.from_conversation(row)
175
+
176
+
177
+ @router.delete("/conversations/{conversation_id}", status_code=204)
178
+ def delete_conversation(conversation_id: UUID, user: EditorDep, org_id: OrgIdDep, store: StoreDep) -> Response:
179
+ """Delete one of the caller's conversations.
180
+
181
+ Args:
182
+ conversation_id: The conversation UUID.
183
+ user: The authenticated user, required to hold at least the ``editor`` role.
184
+ org_id: The active organisation's UUID.
185
+ store: The Store instance.
186
+
187
+ Returns:
188
+ An empty response.
189
+ """
190
+ row = store.conversations.get(conversation_id, org_id=org_id, user_id=user.id)
191
+ store.conversations.delete(row.id)
192
+ return Response(status_code=204)
193
+
194
+
195
+ @router.post("/conversations/{conversation_id}/chat")
196
+ async def chat(
197
+ conversation_id: UUID,
198
+ request: Request,
199
+ user: EditorDep,
200
+ org_id: OrgIdDep,
201
+ store: StoreDep,
202
+ catalog: CatalogDep,
203
+ agent: AgentDep,
204
+ ) -> Response:
205
+ """Run one turn of a conversation and stream it as the AI SDK expects.
206
+
207
+ The request body is the AI SDK's: the client's message list, of which the
208
+ adapter takes the new user message and any approvals or tool answers.
209
+ Everything before comes from the stored history, saved back whole once
210
+ the run completes.
211
+
212
+ Args:
213
+ conversation_id: The conversation UUID.
214
+ request: The raw request, handed to the adapter.
215
+ user: The authenticated user, required to hold at least the ``editor`` role.
216
+ org_id: The active organisation's UUID.
217
+ store: The Store instance.
218
+ catalog: The Catalog instance.
219
+ agent: The assistant.
220
+
221
+ Returns:
222
+ The turn as a ``text/event-stream`` response.
223
+ """
224
+ conversation = store.conversations.get(conversation_id, org_id=org_id, user_id=user.id)
225
+ context = ToolkitContext(
226
+ store=store,
227
+ catalog=catalog.dump(),
228
+ org_id=org_id,
229
+ role=store.organisations.member_role(user.id, org_id) or "viewer",
230
+ )
231
+
232
+ def save(result: AgentRunResult[Any]) -> None:
233
+ messages = result.all_messages()
234
+ history = ModelMessagesTypeAdapter.dump_python(messages, mode="json")
235
+ store.conversations.save(conversation.id, history, title=_first_prompt(messages))
236
+ usage = result.usage
237
+ logger.info(
238
+ "Agent turn on conversation %s: %d requests, %d input tokens, %d output tokens, %d messages stored",
239
+ conversation.id,
240
+ usage.requests,
241
+ usage.input_tokens,
242
+ usage.output_tokens,
243
+ len(messages),
244
+ )
245
+
246
+ return await LoggedAdapter.dispatch_request(
247
+ request,
248
+ agent=agent,
249
+ sdk_version=SDK_VERSION,
250
+ deps=context,
251
+ message_history=ModelMessagesTypeAdapter.validate_python(conversation.messages),
252
+ conversation_id=str(conversation.id),
253
+ usage_limits=TURN_LIMITS,
254
+ on_complete=save,
255
+ )
256
+
257
+
258
+ def _fold_turns(messages: list[UIMessage]) -> list[UIMessage]:
259
+ """Merge each turn's assistant messages into one, as the stream builds them.
260
+
261
+ ``dump_messages`` yields one assistant message per model response, so a
262
+ turn with several tool rounds restores as several messages; the app
263
+ renders each with its own avatar. Live, the SDK accumulates a turn into
264
+ one message, and a reload should read the same.
265
+
266
+ Args:
267
+ messages: The history as the adapter dumps it.
268
+
269
+ Returns:
270
+ The history with consecutive assistant messages folded into the first.
271
+ """
272
+ folded: list[UIMessage] = []
273
+ for message in messages:
274
+ last = folded[-1] if folded else None
275
+ if last is not None and last.role == "assistant" and message.role == "assistant":
276
+ last.parts.extend(message.parts)
277
+ else:
278
+ folded.append(message.model_copy(update={"parts": list(message.parts)}))
279
+ return folded
280
+
281
+
282
+ def _first_prompt(messages: list[ModelMessage]) -> str | None:
283
+ """The first thing the user said, which titles the conversation.
284
+
285
+ Args:
286
+ messages: The conversation's history.
287
+
288
+ Returns:
289
+ The first user prompt's text, or ``None`` when there is none yet.
290
+ """
291
+ for message in messages:
292
+ if isinstance(message, ModelRequest):
293
+ for part in message.parts:
294
+ if isinstance(part, UserPromptPart) and isinstance(part.content, str):
295
+ return part.content
296
+ return None
@@ -9,7 +9,7 @@ from urllib.parse import urlencode
9
9
  from uuid import UUID
10
10
  from zoneinfo import ZoneInfo
11
11
 
12
- import httpx
12
+ import httpx2
13
13
  from fastapi import APIRouter, Cookie, HTTPException, Response
14
14
  from fastapi.responses import RedirectResponse
15
15
  from interloper.errors import NotFoundError
@@ -158,7 +158,7 @@ def google_callback(
158
158
  if not client_id or not client_secret:
159
159
  raise HTTPException(status_code=500, detail="Google OAuth not configured")
160
160
 
161
- token_resp = httpx.post(
161
+ token_resp = httpx2.post(
162
162
  GOOGLE_TOKEN_URL,
163
163
  data={
164
164
  "code": code,
@@ -176,7 +176,7 @@ def google_callback(
176
176
  if not access_token:
177
177
  raise HTTPException(status_code=401, detail="No access token in response")
178
178
 
179
- userinfo_resp = httpx.get(
179
+ userinfo_resp = httpx2.get(
180
180
  GOOGLE_USERINFO_URL,
181
181
  headers={"Authorization": f"Bearer {access_token}"},
182
182
  )
@@ -22,7 +22,7 @@ import logging
22
22
  from typing import Annotated, Any, Literal
23
23
  from uuid import UUID
24
24
 
25
- import httpx
25
+ import httpx2
26
26
  from fastapi import APIRouter, HTTPException, Query
27
27
  from interloper.component import KINDS
28
28
  from interloper.connection.base import Connection
@@ -676,7 +676,7 @@ def handle_error(error: Exception, context: str) -> None:
676
676
  """
677
677
  logger.error("Error %s: %s", context, error)
678
678
 
679
- if isinstance(error, httpx.HTTPStatusError):
679
+ if isinstance(error, httpx2.HTTPStatusError):
680
680
  status = error.response.status_code
681
681
  if status in (401, 403):
682
682
  raise HTTPException(status_code=status, detail=f"Authorization failed while {context}.")
@@ -830,16 +830,16 @@ class CheckResponse(BaseModel):
830
830
 
831
831
  if isinstance(exception, ConnectionCheckError):
832
832
  return cls(ok=False, live=True, category="error", message=str(exception))
833
- if isinstance(exception, httpx.HTTPStatusError):
833
+ if isinstance(exception, httpx2.HTTPStatusError):
834
834
  status = exception.response.status_code
835
835
  if status in (401, 403):
836
836
  return cls(ok=False, live=True, category="auth", message="The provider rejected the credentials.")
837
837
  return cls(
838
838
  ok=False, live=True, category="error", message=f"The provider responded with HTTP {status}."
839
839
  )
840
- if isinstance(exception, (TimeoutError, httpx.TimeoutException)):
840
+ if isinstance(exception, (TimeoutError, httpx2.TimeoutException)):
841
841
  return cls(ok=False, live=True, category="network", message="The provider did not respond in time.")
842
- if isinstance(exception, httpx.TransportError):
842
+ if isinstance(exception, httpx2.TransportError):
843
843
  return cls(ok=False, live=True, category="network", message="The provider could not be reached.")
844
844
  return cls(ok=False, live=True, category="error", message="The connection check failed unexpectedly.")
845
845
 
@@ -22,7 +22,7 @@ import logging
22
22
  import os
23
23
  from typing import Any
24
24
 
25
- import httpx
25
+ import httpx2
26
26
  from fastapi import APIRouter, HTTPException
27
27
  from interloper.oauth import PROVIDERS, OAuthAppCredentials
28
28
  from pydantic import BaseModel
@@ -106,9 +106,10 @@ async def exchange_authorization_code(
106
106
  ) -> dict[str, Any]:
107
107
  """Exchange an authorization code for tokens. Requires authentication.
108
108
 
109
- Returns only the provider's token response (e.g. ``refresh_token``); the
110
- in-house OAuth credentials are never included — connections resolve them
111
- from env at runtime.
109
+ Returns only the provider's token response (e.g. ``refresh_token``), read
110
+ through the provider's own parser so dialect envelopes never reach the
111
+ browser; the in-house OAuth credentials are never included — connections
112
+ resolve them from env at runtime.
112
113
 
113
114
  Args:
114
115
  provider: The registry key of the provider to exchange against.
@@ -117,11 +118,12 @@ async def exchange_authorization_code(
117
118
  identity itself is not used.
118
119
 
119
120
  Returns:
120
- The provider's raw token response.
121
+ The provider's token response, credentials at the top level.
121
122
 
122
123
  Raises:
123
124
  HTTPException: 400 when the provider is unknown or has no credentials
124
- configured, 500 when the exchange itself fails.
125
+ configured, 500 when the exchange itself fails — including a
126
+ rejection the provider reports inside a 200 body.
125
127
  """
126
128
  spec = PROVIDERS.get(provider)
127
129
  if spec is None:
@@ -139,12 +141,13 @@ async def exchange_authorization_code(
139
141
  client_id=config.client_id,
140
142
  client_secret=config.client_secret,
141
143
  )
142
- async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
144
+ async with httpx2.AsyncClient(timeout=30, follow_redirects=True) as client:
143
145
  response = await client.send(request)
144
146
  response.raise_for_status()
147
+ tokens = spec.parse_authorization_code_response(response.json())
145
148
  logger.info("Successfully exchanged auth code for provider %s", provider)
146
- return response.json()
147
- except httpx.HTTPStatusError as exception:
149
+ return tokens
150
+ except httpx2.HTTPStatusError as exception:
148
151
  detail = exception.response.text
149
152
  logger.error("Token exchange failed for %s: %s %s", provider, exception.response.status_code, detail)
150
153
  raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {detail}")
@@ -1,289 +0,0 @@
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
-
16
- from fastapi import APIRouter, HTTPException, Response
17
- from fastapi.responses import StreamingResponse
18
- from google.adk.apps import App
19
- from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
20
- from google.adk.runners import Runner
21
- from google.adk.sessions.in_memory_session_service import InMemorySessionService
22
- from google.genai import types
23
- from interloper.catalog.base import Catalog
24
- from interloper_db import Store
25
- from pydantic import BaseModel
26
-
27
- from interloper_api.dependencies import (
28
- CatalogDep,
29
- EditorDep,
30
- OrgIdDep,
31
- StoreDep,
32
- ViewerDep,
33
- )
34
-
35
- logger = logging.getLogger(__name__)
36
-
37
- router = APIRouter(prefix="/agent", tags=["agent"])
38
-
39
- APP_NAME = "interloper_agent"
40
-
41
- # -- Lazy runner singleton -----------------------------------------------------
42
-
43
- _runner: Runner | None = None
44
- _session_service: InMemorySessionService | None = None
45
-
46
-
47
- def _get_session_service() -> InMemorySessionService:
48
- """Return the shared session service, creating it on first call.
49
-
50
- Returns:
51
- The process-wide in-memory session service.
52
- """
53
- global _session_service
54
- if _session_service is None:
55
- _session_service = InMemorySessionService()
56
- return _session_service
57
-
58
-
59
- def _get_runner(store: Store, catalog: Catalog) -> Runner:
60
- """Return the shared Runner, creating it on first call.
61
-
62
- On first call, injects the API's Store and catalog into the agent
63
- context so that agent tools can access them.
64
-
65
- Args:
66
- store: The Store instance (from API dependencies).
67
- catalog: The Catalog instance (from API dependencies).
68
-
69
- Returns:
70
- The process-wide ADK Runner.
71
- """
72
- global _runner
73
- if _runner is None:
74
- from interloper_agent.agent import root_agent
75
- from interloper_agent.context import set_catalog, set_store
76
-
77
- if store is not None:
78
- set_store(store)
79
- if catalog is not None:
80
- set_catalog(catalog)
81
-
82
- app = App(name=APP_NAME, root_agent=root_agent)
83
- _runner = Runner(
84
- app=app,
85
- session_service=_get_session_service(),
86
- artifact_service=InMemoryArtifactService(),
87
- )
88
- logger.info("Agent runner initialized")
89
- return _runner
90
-
91
-
92
- # -- Request / response models -------------------------------------------------
93
-
94
-
95
- class ChatRequest(BaseModel):
96
- """Request body for sending a chat message."""
97
-
98
- message: str
99
-
100
-
101
- class SessionResponse(BaseModel):
102
- """Response body for an agent session."""
103
-
104
- id: str
105
- user_id: str
106
- app_name: str
107
- state: dict[str, Any]
108
- last_update_time: float
109
- event_count: int
110
-
111
- @classmethod
112
- def from_session(cls, session: Any) -> SessionResponse:
113
- """Convert an ADK Session to a response model.
114
-
115
- Args:
116
- session: The ADK Session object.
117
-
118
- Returns:
119
- The response model.
120
- """
121
- return cls(
122
- id=session.id,
123
- user_id=session.user_id,
124
- app_name=session.app_name,
125
- state=session.state,
126
- last_update_time=session.last_update_time,
127
- event_count=len(session.events),
128
- )
129
-
130
-
131
- # -- Endpoints -----------------------------------------------------------------
132
-
133
-
134
- @router.post("/sessions")
135
- async def create_session(
136
- user: EditorDep,
137
- org_id: OrgIdDep,
138
- store: StoreDep,
139
- catalog: CatalogDep,
140
- ) -> SessionResponse:
141
- """Create a new agent chat session.
142
-
143
- The active ``org_id`` and the user's role in it are stamped into the ADK
144
- session state: the org scopes every tool, the role gates the writes.
145
-
146
- Args:
147
- user: The authenticated user, required to hold at least the ``editor`` role.
148
- org_id: The active organisation's UUID.
149
- store: The Store instance.
150
- catalog: The Catalog instance.
151
-
152
- Returns:
153
- The created session, as a response model.
154
- """
155
- _get_runner(store=store, catalog=catalog)
156
- session_service = _get_session_service()
157
- session = await session_service.create_session(
158
- app_name=APP_NAME,
159
- user_id=str(user.id),
160
- state={"org_id": str(org_id), "role": store.organisations.member_role(user.id, org_id)},
161
- )
162
- return SessionResponse.from_session(session)
163
-
164
-
165
- @router.get("/sessions")
166
- async def list_sessions(
167
- user: ViewerDep,
168
- ) -> list[SessionResponse]:
169
- """List all agent sessions for the current user.
170
-
171
- Args:
172
- user: The authenticated user, required to hold at least the ``viewer`` role.
173
-
174
- Returns:
175
- The user's agent sessions, as response models.
176
- """
177
- session_service = _get_session_service()
178
- result = await session_service.list_sessions(
179
- app_name=APP_NAME,
180
- user_id=str(user.id),
181
- )
182
- sessions = result.sessions if hasattr(result, "sessions") else result
183
- return [SessionResponse.from_session(s) for s in sessions]
184
-
185
-
186
- @router.get("/sessions/{session_id}")
187
- async def get_session(
188
- session_id: str,
189
- user: ViewerDep,
190
- ) -> Response:
191
- """Get a session with its full event history.
192
-
193
- Returns the session object including all events (messages and tool calls).
194
-
195
- Args:
196
- session_id: The ADK session ID.
197
- user: The authenticated user, required to hold at least the ``viewer`` role.
198
-
199
- Returns:
200
- The session serialized by the ADK's own model, as raw JSON.
201
-
202
- Raises:
203
- HTTPException: 404 if the user has no session with that ID.
204
- """
205
- session_service = _get_session_service()
206
- session = await session_service.get_session(
207
- app_name=APP_NAME,
208
- user_id=str(user.id),
209
- session_id=session_id,
210
- )
211
- if not session:
212
- raise HTTPException(status_code=404, detail="Session not found")
213
- return Response(
214
- content=session.model_dump_json(exclude_none=True, by_alias=True),
215
- media_type="application/json",
216
- )
217
-
218
-
219
- @router.delete("/sessions/{session_id}")
220
- async def delete_session(
221
- session_id: str,
222
- user: EditorDep,
223
- ) -> dict[str, str]:
224
- """Delete an agent session.
225
-
226
- Args:
227
- session_id: The ADK session ID.
228
- user: The authenticated user, required to hold at least the ``editor`` role.
229
-
230
- Returns:
231
- A confirmation that the session was deleted.
232
-
233
- Raises:
234
- HTTPException: 404 if the user has no session with that ID.
235
- """
236
- session_service = _get_session_service()
237
- session = await session_service.get_session(
238
- app_name=APP_NAME,
239
- user_id=str(user.id),
240
- session_id=session_id,
241
- )
242
- if not session:
243
- raise HTTPException(status_code=404, detail="Session not found")
244
- await session_service.delete_session(
245
- app_name=APP_NAME,
246
- user_id=str(user.id),
247
- session_id=session_id,
248
- )
249
- return {"status": "deleted"}
250
-
251
-
252
- @router.post("/sessions/{session_id}/chat")
253
- async def chat(
254
- session_id: str,
255
- body: ChatRequest,
256
- user: EditorDep,
257
- store: StoreDep,
258
- catalog: CatalogDep,
259
- ) -> StreamingResponse:
260
- """Send a message and stream the agent's response as SSE.
261
-
262
- Each SSE ``data`` line contains a JSON-serialized ADK Event.
263
- Events with ``content.parts`` containing ``text`` are the agent's
264
- text responses. Events with ``function_call`` or ``function_response``
265
- parts represent tool invocations.
266
-
267
- Args:
268
- session_id: The ADK session ID to continue.
269
- body: The chat message to send.
270
- user: The authenticated user, required to hold at least the ``editor`` role.
271
- store: The Store instance.
272
- catalog: The Catalog instance.
273
-
274
- Returns:
275
- A ``text/event-stream`` response carrying the agent's events.
276
- """
277
- runner = _get_runner(store=store, catalog=catalog)
278
- user_id = str(user.id)
279
- message = types.Content(parts=[types.Part(text=body.message)], role="user")
280
-
281
- async def event_stream() -> AsyncIterator[str]:
282
- async for event in runner.run_async(
283
- user_id=user_id,
284
- session_id=session_id,
285
- new_message=message,
286
- ):
287
- yield f"data: {event.model_dump_json(exclude_none=True, by_alias=True)}\n\n"
288
-
289
- return StreamingResponse(event_stream(), media_type="text/event-stream")