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.
- {interloper_api-0.90.0 → interloper_api-0.92.0}/PKG-INFO +2 -2
- {interloper_api-0.90.0 → interloper_api-0.92.0}/pyproject.toml +2 -2
- {interloper_api-0.90.0 → interloper_api-0.92.0}/pyproject.toml.orig +2 -2
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/app.py +5 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/dependencies/__init__.py +6 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/dependencies/state.py +27 -0
- interloper_api-0.92.0/src/interloper_api/routes/agent.py +296 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/auth.py +3 -3
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/components.py +5 -5
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/oauth.py +12 -9
- interloper_api-0.90.0/src/interloper_api/routes/agent.py +0 -289
- {interloper_api-0.90.0 → interloper_api-0.92.0}/README.md +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/dependencies/auth.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/dependencies/rbac.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/notifications/__init__.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/notifications/invitations.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/health.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/routes/tokens.py +0 -0
- {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.
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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``)
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
|
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
|
|
147
|
-
except
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/notifications/__init__.py
RENAMED
|
File without changes
|
{interloper_api-0.90.0 → interloper_api-0.92.0}/src/interloper_api/notifications/invitations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|