remdb 0.3.14__py3-none-any.whl → 0.3.157__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.
- rem/agentic/README.md +76 -0
- rem/agentic/__init__.py +15 -0
- rem/agentic/agents/__init__.py +32 -2
- rem/agentic/agents/agent_manager.py +310 -0
- rem/agentic/agents/sse_simulator.py +502 -0
- rem/agentic/context.py +51 -27
- rem/agentic/context_builder.py +5 -3
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/mcp/tool_wrapper.py +155 -18
- rem/agentic/otel/setup.py +93 -4
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +280 -57
- rem/agentic/schema.py +361 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/README.md +215 -1
- rem/api/deps.py +255 -0
- rem/api/main.py +132 -40
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +28 -5
- rem/api/mcp_router/tools.py +555 -7
- rem/api/routers/admin.py +494 -0
- rem/api/routers/auth.py +278 -4
- rem/api/routers/chat/completions.py +402 -20
- rem/api/routers/chat/models.py +88 -10
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +542 -0
- rem/api/routers/chat/streaming.py +697 -45
- rem/api/routers/dev.py +81 -0
- rem/api/routers/feedback.py +268 -0
- rem/api/routers/messages.py +473 -0
- rem/api/routers/models.py +78 -0
- rem/api/routers/query.py +360 -0
- rem/api/routers/shared_sessions.py +406 -0
- rem/auth/__init__.py +13 -3
- rem/auth/middleware.py +186 -22
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +237 -64
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +386 -143
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/process.py +14 -8
- rem/cli/commands/schema.py +97 -50
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +29 -6
- rem/config.py +10 -3
- rem/models/core/core_model.py +7 -1
- rem/models/core/experiment.py +58 -14
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/__init__.py +25 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/message.py +30 -1
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/session.py +83 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/registry.py +10 -4
- rem/schemas/agents/core/agent-builder.yaml +134 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/schemas/agents/rem.yaml +7 -3
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +92 -19
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +459 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/api.py +4 -4
- rem/services/embeddings/worker.py +16 -16
- rem/services/phoenix/client.py +154 -14
- rem/services/postgres/README.md +197 -15
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +547 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +470 -140
- rem/services/postgres/repository.py +132 -0
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/postgres/service.py +6 -6
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/compression.py +137 -51
- rem/services/session/reload.py +15 -8
- rem/settings.py +515 -27
- rem/sql/background_indexes.sql +21 -16
- rem/sql/migrations/001_install.sql +387 -54
- rem/sql/migrations/002_install_models.sql +2304 -377
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/__init__.py +18 -0
- rem/utils/date_utils.py +2 -2
- rem/utils/files.py +157 -1
- rem/utils/model_helpers.py +156 -1
- rem/utils/schema_loader.py +220 -22
- rem/utils/sql_paths.py +146 -0
- rem/utils/sql_types.py +3 -1
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/METADATA +340 -229
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/RECORD +109 -80
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/WHEEL +1 -1
- rem/sql/002_install_models.sql +0 -1068
- rem/sql/install_models.sql +0 -1051
- rem/sql/migrations/003_seed_default_user.sql +0 -48
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Messages and Sessions endpoints.
|
|
3
|
+
|
|
4
|
+
Provides endpoints for:
|
|
5
|
+
- Listing and filtering messages by date, user_id, session_id
|
|
6
|
+
- Creating and managing sessions (normal or evaluation mode)
|
|
7
|
+
|
|
8
|
+
Endpoints:
|
|
9
|
+
GET /api/v1/messages - List messages with filters
|
|
10
|
+
GET /api/v1/messages/{id} - Get a specific message
|
|
11
|
+
|
|
12
|
+
GET /api/v1/sessions - List sessions
|
|
13
|
+
POST /api/v1/sessions - Create a session
|
|
14
|
+
GET /api/v1/sessions/{id} - Get a specific session
|
|
15
|
+
PUT /api/v1/sessions/{id} - Update a session
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Literal
|
|
20
|
+
from uuid import UUID
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
|
23
|
+
from loguru import logger
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
from ..deps import (
|
|
27
|
+
get_current_user,
|
|
28
|
+
get_user_filter,
|
|
29
|
+
is_admin,
|
|
30
|
+
require_admin,
|
|
31
|
+
require_auth,
|
|
32
|
+
)
|
|
33
|
+
from ...models.entities import Message, Session, SessionMode
|
|
34
|
+
from ...services.postgres import Repository, get_postgres_service
|
|
35
|
+
from ...settings import settings
|
|
36
|
+
from ...utils.date_utils import parse_iso, utc_now
|
|
37
|
+
|
|
38
|
+
router = APIRouter(prefix="/api/v1")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# =============================================================================
|
|
42
|
+
# Request/Response Models
|
|
43
|
+
# =============================================================================
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MessageListResponse(BaseModel):
|
|
47
|
+
"""Response for message list endpoint."""
|
|
48
|
+
|
|
49
|
+
object: Literal["list"] = "list"
|
|
50
|
+
data: list[Message]
|
|
51
|
+
total: int
|
|
52
|
+
has_more: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SessionCreateRequest(BaseModel):
|
|
56
|
+
"""Request to create a new session."""
|
|
57
|
+
|
|
58
|
+
name: str = Field(description="Session name/identifier")
|
|
59
|
+
mode: SessionMode = Field(
|
|
60
|
+
default=SessionMode.NORMAL, description="Session mode: 'normal' or 'evaluation'"
|
|
61
|
+
)
|
|
62
|
+
description: str | None = Field(default=None, description="Session description")
|
|
63
|
+
original_trace_id: str | None = Field(
|
|
64
|
+
default=None,
|
|
65
|
+
description="For evaluation: ID of the original session being evaluated",
|
|
66
|
+
)
|
|
67
|
+
settings_overrides: dict | None = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="Settings overrides (model, temperature, max_tokens, system_prompt)",
|
|
70
|
+
)
|
|
71
|
+
prompt: str | None = Field(default=None, description="Custom prompt for this session")
|
|
72
|
+
agent_schema_uri: str | None = Field(
|
|
73
|
+
default=None, description="Agent schema URI for this session"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SessionUpdateRequest(BaseModel):
|
|
78
|
+
"""Request to update a session."""
|
|
79
|
+
|
|
80
|
+
description: str | None = None
|
|
81
|
+
settings_overrides: dict | None = None
|
|
82
|
+
prompt: str | None = None
|
|
83
|
+
message_count: int | None = None
|
|
84
|
+
total_tokens: int | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SessionListResponse(BaseModel):
|
|
88
|
+
"""Response for session list endpoint (deprecated, use SessionsQueryResponse)."""
|
|
89
|
+
|
|
90
|
+
object: Literal["list"] = "list"
|
|
91
|
+
data: list[Session]
|
|
92
|
+
total: int
|
|
93
|
+
has_more: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PaginationMetadata(BaseModel):
|
|
97
|
+
"""Pagination metadata for paginated responses."""
|
|
98
|
+
|
|
99
|
+
total: int = Field(description="Total number of records matching filters")
|
|
100
|
+
page: int = Field(description="Current page number (1-indexed)")
|
|
101
|
+
page_size: int = Field(description="Number of records per page")
|
|
102
|
+
total_pages: int = Field(description="Total number of pages")
|
|
103
|
+
has_next: bool = Field(description="Whether there are more pages after this one")
|
|
104
|
+
has_previous: bool = Field(description="Whether there are pages before this one")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SessionsQueryResponse(BaseModel):
|
|
108
|
+
"""Response for paginated sessions query."""
|
|
109
|
+
|
|
110
|
+
object: Literal["list"] = "list"
|
|
111
|
+
data: list[Session] = Field(description="List of sessions for the current page")
|
|
112
|
+
metadata: PaginationMetadata = Field(description="Pagination metadata")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# =============================================================================
|
|
116
|
+
# Messages Endpoints
|
|
117
|
+
# =============================================================================
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.get("/messages", response_model=MessageListResponse, tags=["messages"])
|
|
121
|
+
async def list_messages(
|
|
122
|
+
request: Request,
|
|
123
|
+
mine: bool = Query(default=False, description="Only show my messages (uses JWT identity)"),
|
|
124
|
+
user_id: str | None = Query(default=None, description="Filter by user ID (admin only for cross-user)"),
|
|
125
|
+
session_id: str | None = Query(default=None, description="Filter by session ID"),
|
|
126
|
+
start_date: str | None = Query(
|
|
127
|
+
default=None, description="Filter messages after this ISO date"
|
|
128
|
+
),
|
|
129
|
+
end_date: str | None = Query(
|
|
130
|
+
default=None, description="Filter messages before this ISO date"
|
|
131
|
+
),
|
|
132
|
+
message_type: str | None = Query(
|
|
133
|
+
default=None, description="Filter by message type (user, assistant, system, tool)"
|
|
134
|
+
),
|
|
135
|
+
limit: int = Query(default=50, ge=1, le=100, description="Max results to return"),
|
|
136
|
+
offset: int = Query(default=0, ge=0, description="Offset for pagination"),
|
|
137
|
+
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
138
|
+
) -> MessageListResponse:
|
|
139
|
+
"""
|
|
140
|
+
List messages with optional filters.
|
|
141
|
+
|
|
142
|
+
Access Control:
|
|
143
|
+
- Regular users: Only see their own messages
|
|
144
|
+
- Admin users: Can filter by any user_id or see all messages
|
|
145
|
+
- mine=true: Forces filter to current user (useful for admins to see only their own)
|
|
146
|
+
|
|
147
|
+
Filters can be combined:
|
|
148
|
+
- mine: Only show messages owned by current JWT user (overrides user_id)
|
|
149
|
+
- user_id: Filter by the user who created/owns the message (admin only for cross-user)
|
|
150
|
+
- session_id: Filter by conversation session
|
|
151
|
+
- start_date/end_date: Filter by creation time range (ISO 8601 format)
|
|
152
|
+
- message_type: Filter by role (user, assistant, system, tool)
|
|
153
|
+
|
|
154
|
+
Returns paginated results ordered by created_at descending.
|
|
155
|
+
"""
|
|
156
|
+
if not settings.postgres.enabled:
|
|
157
|
+
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
158
|
+
|
|
159
|
+
repo = Repository(Message, table_name="messages")
|
|
160
|
+
|
|
161
|
+
# If mine=true, force filter to current user's ID from JWT
|
|
162
|
+
effective_user_id = user_id
|
|
163
|
+
if mine:
|
|
164
|
+
current_user = get_current_user(request)
|
|
165
|
+
if current_user:
|
|
166
|
+
effective_user_id = current_user.get("id")
|
|
167
|
+
|
|
168
|
+
# Build user-scoped filters (admin can see all, regular users see only their own)
|
|
169
|
+
filters = await get_user_filter(request, x_user_id=effective_user_id, x_tenant_id=x_tenant_id)
|
|
170
|
+
|
|
171
|
+
# Apply optional filters
|
|
172
|
+
if session_id:
|
|
173
|
+
filters["session_id"] = session_id
|
|
174
|
+
if message_type:
|
|
175
|
+
filters["message_type"] = message_type
|
|
176
|
+
|
|
177
|
+
# For date filtering, we need custom SQL (not supported by basic Repository)
|
|
178
|
+
# For now, fetch all matching base filters and filter in Python
|
|
179
|
+
# TODO: Extend Repository to support date range filters
|
|
180
|
+
messages = await repo.find(
|
|
181
|
+
filters,
|
|
182
|
+
order_by="created_at DESC",
|
|
183
|
+
limit=limit + 1, # Fetch one extra to determine has_more
|
|
184
|
+
offset=offset,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Apply date filters in Python if provided
|
|
188
|
+
if start_date or end_date:
|
|
189
|
+
start_dt = parse_iso(start_date) if start_date else None
|
|
190
|
+
end_dt = parse_iso(end_date) if end_date else None
|
|
191
|
+
|
|
192
|
+
filtered = []
|
|
193
|
+
for msg in messages:
|
|
194
|
+
if start_dt and msg.created_at < start_dt:
|
|
195
|
+
continue
|
|
196
|
+
if end_dt and msg.created_at > end_dt:
|
|
197
|
+
continue
|
|
198
|
+
filtered.append(msg)
|
|
199
|
+
messages = filtered
|
|
200
|
+
|
|
201
|
+
# Determine if there are more results
|
|
202
|
+
has_more = len(messages) > limit
|
|
203
|
+
if has_more:
|
|
204
|
+
messages = messages[:limit]
|
|
205
|
+
|
|
206
|
+
# Get total count for pagination info
|
|
207
|
+
total = await repo.count(filters)
|
|
208
|
+
|
|
209
|
+
return MessageListResponse(data=messages, total=total, has_more=has_more)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@router.get("/messages/{message_id}", response_model=Message, tags=["messages"])
|
|
213
|
+
async def get_message(
|
|
214
|
+
request: Request,
|
|
215
|
+
message_id: str,
|
|
216
|
+
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
217
|
+
) -> Message:
|
|
218
|
+
"""
|
|
219
|
+
Get a specific message by ID.
|
|
220
|
+
|
|
221
|
+
Access Control:
|
|
222
|
+
- Regular users: Only access their own messages
|
|
223
|
+
- Admin users: Can access any message
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
message_id: UUID of the message
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Message object if found
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
404: Message not found
|
|
233
|
+
403: Access denied (not owner and not admin)
|
|
234
|
+
"""
|
|
235
|
+
if not settings.postgres.enabled:
|
|
236
|
+
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
237
|
+
|
|
238
|
+
repo = Repository(Message, table_name="messages")
|
|
239
|
+
message = await repo.get_by_id(message_id, x_tenant_id)
|
|
240
|
+
|
|
241
|
+
if not message:
|
|
242
|
+
raise HTTPException(status_code=404, detail=f"Message '{message_id}' not found")
|
|
243
|
+
|
|
244
|
+
# Check access: admin or owner
|
|
245
|
+
current_user = get_current_user(request)
|
|
246
|
+
if not is_admin(current_user):
|
|
247
|
+
user_id = current_user.get("id") if current_user else None
|
|
248
|
+
if message.user_id and message.user_id != user_id:
|
|
249
|
+
raise HTTPException(status_code=403, detail="Access denied: not owner")
|
|
250
|
+
|
|
251
|
+
return message
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# =============================================================================
|
|
255
|
+
# Sessions Endpoints
|
|
256
|
+
# =============================================================================
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@router.get("/sessions", response_model=SessionsQueryResponse, tags=["sessions"])
|
|
260
|
+
async def list_sessions(
|
|
261
|
+
request: Request,
|
|
262
|
+
user_id: str | None = Query(default=None, description="Filter by user ID (admin only for cross-user)"),
|
|
263
|
+
mode: SessionMode | None = Query(default=None, description="Filter by session mode"),
|
|
264
|
+
page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
|
|
265
|
+
page_size: int = Query(default=50, ge=1, le=100, description="Number of results per page"),
|
|
266
|
+
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
267
|
+
) -> SessionsQueryResponse:
|
|
268
|
+
"""
|
|
269
|
+
List sessions with optional filters and page-based pagination.
|
|
270
|
+
|
|
271
|
+
Access Control:
|
|
272
|
+
- Regular users: Only see their own sessions
|
|
273
|
+
- Admin users: Can filter by any user_id or see all sessions
|
|
274
|
+
|
|
275
|
+
Filters:
|
|
276
|
+
- user_id: Filter by session owner (admin only for cross-user)
|
|
277
|
+
- mode: Filter by session mode (normal or evaluation)
|
|
278
|
+
|
|
279
|
+
Pagination:
|
|
280
|
+
- page: Page number (1-indexed, default: 1)
|
|
281
|
+
- page_size: Number of sessions per page (default: 50, max: 100)
|
|
282
|
+
|
|
283
|
+
Returns paginated results ordered by created_at descending with pagination metadata.
|
|
284
|
+
"""
|
|
285
|
+
if not settings.postgres.enabled:
|
|
286
|
+
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
287
|
+
|
|
288
|
+
repo = Repository(Session, table_name="sessions")
|
|
289
|
+
|
|
290
|
+
# Build user-scoped filters (admin can see all, regular users see only their own)
|
|
291
|
+
filters = await get_user_filter(request, x_user_id=user_id, x_tenant_id=x_tenant_id)
|
|
292
|
+
if mode:
|
|
293
|
+
filters["mode"] = mode.value
|
|
294
|
+
|
|
295
|
+
# Use CTE-based pagination with ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)
|
|
296
|
+
result = await repo.find_paginated(
|
|
297
|
+
filters,
|
|
298
|
+
page=page,
|
|
299
|
+
page_size=page_size,
|
|
300
|
+
order_by="created_at DESC",
|
|
301
|
+
partition_by="user_id",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return SessionsQueryResponse(
|
|
305
|
+
data=result["data"],
|
|
306
|
+
metadata=PaginationMetadata(
|
|
307
|
+
total=result["total"],
|
|
308
|
+
page=result["page"],
|
|
309
|
+
page_size=result["page_size"],
|
|
310
|
+
total_pages=result["total_pages"],
|
|
311
|
+
has_next=result["has_next"],
|
|
312
|
+
has_previous=result["has_previous"],
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@router.post("/sessions", response_model=Session, status_code=201, tags=["sessions"])
|
|
318
|
+
async def create_session(
|
|
319
|
+
request_body: SessionCreateRequest,
|
|
320
|
+
user: dict = Depends(require_admin),
|
|
321
|
+
x_user_id: str = Header(alias="X-User-Id", default="default"),
|
|
322
|
+
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
323
|
+
) -> Session:
|
|
324
|
+
"""
|
|
325
|
+
Create a new session.
|
|
326
|
+
|
|
327
|
+
**Requires admin role.**
|
|
328
|
+
|
|
329
|
+
For normal sessions, only name is required.
|
|
330
|
+
For evaluation sessions, you can specify:
|
|
331
|
+
- original_trace_id: The session being re-evaluated
|
|
332
|
+
- settings_overrides: Model, temperature, prompt overrides
|
|
333
|
+
- prompt: Custom prompt to test
|
|
334
|
+
|
|
335
|
+
Headers:
|
|
336
|
+
- X-User-Id: User identifier (owner of the session)
|
|
337
|
+
- X-Tenant-Id: Tenant identifier
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Created session object
|
|
341
|
+
"""
|
|
342
|
+
if not settings.postgres.enabled:
|
|
343
|
+
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
344
|
+
|
|
345
|
+
# Admin can specify x_user_id, or default to their own
|
|
346
|
+
effective_user_id = x_user_id if x_user_id != "default" else user.get("id", "default")
|
|
347
|
+
|
|
348
|
+
session = Session(
|
|
349
|
+
name=request_body.name,
|
|
350
|
+
mode=request_body.mode,
|
|
351
|
+
description=request_body.description,
|
|
352
|
+
original_trace_id=request_body.original_trace_id,
|
|
353
|
+
settings_overrides=request_body.settings_overrides,
|
|
354
|
+
prompt=request_body.prompt,
|
|
355
|
+
agent_schema_uri=request_body.agent_schema_uri,
|
|
356
|
+
user_id=effective_user_id,
|
|
357
|
+
tenant_id=x_tenant_id,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
repo = Repository(Session, table_name="sessions")
|
|
361
|
+
result = await repo.upsert(session)
|
|
362
|
+
|
|
363
|
+
logger.info(
|
|
364
|
+
f"Admin {user.get('email')} created session '{session.name}' "
|
|
365
|
+
f"(mode={session.mode}) for user={effective_user_id}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
return result # type: ignore
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@router.get("/sessions/{session_id}", response_model=Session, tags=["sessions"])
|
|
372
|
+
async def get_session(
|
|
373
|
+
request: Request,
|
|
374
|
+
session_id: str,
|
|
375
|
+
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
376
|
+
) -> Session:
|
|
377
|
+
"""
|
|
378
|
+
Get a specific session by ID.
|
|
379
|
+
|
|
380
|
+
Access Control:
|
|
381
|
+
- Regular users: Only access their own sessions
|
|
382
|
+
- Admin users: Can access any session
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
session_id: UUID or name of the session
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Session object if found
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
404: Session not found
|
|
392
|
+
403: Access denied (not owner and not admin)
|
|
393
|
+
"""
|
|
394
|
+
if not settings.postgres.enabled:
|
|
395
|
+
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
396
|
+
|
|
397
|
+
repo = Repository(Session, table_name="sessions")
|
|
398
|
+
session = await repo.get_by_id(session_id, x_tenant_id)
|
|
399
|
+
|
|
400
|
+
if not session:
|
|
401
|
+
# Try finding by name
|
|
402
|
+
sessions = await repo.find({"name": session_id, "tenant_id": x_tenant_id}, limit=1)
|
|
403
|
+
if sessions:
|
|
404
|
+
session = sessions[0]
|
|
405
|
+
else:
|
|
406
|
+
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
|
407
|
+
|
|
408
|
+
# Check access: admin or owner
|
|
409
|
+
current_user = get_current_user(request)
|
|
410
|
+
if not is_admin(current_user):
|
|
411
|
+
user_id = current_user.get("id") if current_user else None
|
|
412
|
+
if session.user_id and session.user_id != user_id:
|
|
413
|
+
raise HTTPException(status_code=403, detail="Access denied: not owner")
|
|
414
|
+
|
|
415
|
+
return session
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@router.put("/sessions/{session_id}", response_model=Session, tags=["sessions"])
|
|
419
|
+
async def update_session(
|
|
420
|
+
request: Request,
|
|
421
|
+
session_id: str,
|
|
422
|
+
request_body: SessionUpdateRequest,
|
|
423
|
+
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
424
|
+
) -> Session:
|
|
425
|
+
"""
|
|
426
|
+
Update an existing session.
|
|
427
|
+
|
|
428
|
+
Access Control:
|
|
429
|
+
- Regular users: Only update their own sessions
|
|
430
|
+
- Admin users: Can update any session
|
|
431
|
+
|
|
432
|
+
Allows updating:
|
|
433
|
+
- description
|
|
434
|
+
- settings_overrides
|
|
435
|
+
- prompt
|
|
436
|
+
- message_count (typically updated automatically)
|
|
437
|
+
- total_tokens (typically updated automatically)
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
session_id: UUID of the session
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
Updated session object
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
404: Session not found
|
|
447
|
+
403: Access denied (not owner and not admin)
|
|
448
|
+
"""
|
|
449
|
+
if not settings.postgres.enabled:
|
|
450
|
+
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
451
|
+
|
|
452
|
+
repo = Repository(Session, table_name="sessions")
|
|
453
|
+
session = await repo.get_by_id(session_id, x_tenant_id)
|
|
454
|
+
|
|
455
|
+
if not session:
|
|
456
|
+
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
|
457
|
+
|
|
458
|
+
# Check access: admin or owner
|
|
459
|
+
current_user = get_current_user(request)
|
|
460
|
+
if not is_admin(current_user):
|
|
461
|
+
user_id = current_user.get("id") if current_user else None
|
|
462
|
+
if session.user_id and session.user_id != user_id:
|
|
463
|
+
raise HTTPException(status_code=403, detail="Access denied: not owner")
|
|
464
|
+
|
|
465
|
+
# Apply updates
|
|
466
|
+
update_data = request_body.model_dump(exclude_none=True)
|
|
467
|
+
for field, value in update_data.items():
|
|
468
|
+
setattr(session, field, value)
|
|
469
|
+
|
|
470
|
+
session.updated_at = utc_now()
|
|
471
|
+
|
|
472
|
+
result = await repo.update(session)
|
|
473
|
+
return result
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models endpoint - List available LLM models.
|
|
3
|
+
|
|
4
|
+
Provides an OpenAI-compatible /v1/models endpoint listing all supported
|
|
5
|
+
LLM providers and their models using the provider:model_id syntax.
|
|
6
|
+
|
|
7
|
+
Endpoint:
|
|
8
|
+
GET /api/v1/models - List all available models
|
|
9
|
+
|
|
10
|
+
Response format matches OpenAI API for drop-in compatibility.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from rem.agentic.llm_provider_models import (
|
|
19
|
+
ModelInfo,
|
|
20
|
+
AVAILABLE_MODELS,
|
|
21
|
+
ALLOWED_MODEL_IDS,
|
|
22
|
+
is_valid_model,
|
|
23
|
+
get_valid_model_or_default,
|
|
24
|
+
get_model_by_id,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
router = APIRouter(prefix="/api/v1", tags=["models"])
|
|
28
|
+
|
|
29
|
+
# Re-export for backwards compatibility
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ModelInfo",
|
|
32
|
+
"AVAILABLE_MODELS",
|
|
33
|
+
"ALLOWED_MODEL_IDS",
|
|
34
|
+
"is_valid_model",
|
|
35
|
+
"get_valid_model_or_default",
|
|
36
|
+
"get_model_by_id",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ModelsResponse(BaseModel):
|
|
41
|
+
"""Response from /models endpoint."""
|
|
42
|
+
|
|
43
|
+
object: Literal["list"] = "list"
|
|
44
|
+
data: list[ModelInfo]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/models", response_model=ModelsResponse)
|
|
48
|
+
async def list_models() -> ModelsResponse:
|
|
49
|
+
"""
|
|
50
|
+
List all available LLM models.
|
|
51
|
+
|
|
52
|
+
Returns models from all supported providers (OpenAI, Anthropic, Google, Cerebras)
|
|
53
|
+
with the provider:model_id naming convention.
|
|
54
|
+
|
|
55
|
+
Response format is OpenAI-compatible for drop-in replacement.
|
|
56
|
+
"""
|
|
57
|
+
return ModelsResponse(data=AVAILABLE_MODELS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/models/{model_id:path}", response_model=ModelInfo)
|
|
61
|
+
async def get_model(model_id: str) -> ModelInfo:
|
|
62
|
+
"""
|
|
63
|
+
Get information about a specific model.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
model_id: Model identifier in provider:model format (e.g., "openai:gpt-4.1")
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Model information if found
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
HTTPException: 404 if model not found
|
|
73
|
+
"""
|
|
74
|
+
model = get_model_by_id(model_id)
|
|
75
|
+
if model:
|
|
76
|
+
return model
|
|
77
|
+
|
|
78
|
+
raise HTTPException(status_code=404, detail=f"Model '{model_id}' not found")
|