remdb 0.3.0__py3-none-any.whl → 0.3.114__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (98) hide show
  1. rem/__init__.py +129 -2
  2. rem/agentic/README.md +76 -0
  3. rem/agentic/__init__.py +15 -0
  4. rem/agentic/agents/__init__.py +16 -2
  5. rem/agentic/agents/sse_simulator.py +500 -0
  6. rem/agentic/context.py +28 -22
  7. rem/agentic/llm_provider_models.py +301 -0
  8. rem/agentic/otel/setup.py +92 -4
  9. rem/agentic/providers/phoenix.py +32 -43
  10. rem/agentic/providers/pydantic_ai.py +142 -22
  11. rem/agentic/schema.py +358 -21
  12. rem/agentic/tools/rem_tools.py +3 -3
  13. rem/api/README.md +238 -1
  14. rem/api/deps.py +255 -0
  15. rem/api/main.py +151 -37
  16. rem/api/mcp_router/resources.py +1 -1
  17. rem/api/mcp_router/server.py +17 -2
  18. rem/api/mcp_router/tools.py +143 -7
  19. rem/api/middleware/tracking.py +172 -0
  20. rem/api/routers/admin.py +277 -0
  21. rem/api/routers/auth.py +124 -0
  22. rem/api/routers/chat/completions.py +152 -16
  23. rem/api/routers/chat/models.py +7 -3
  24. rem/api/routers/chat/sse_events.py +526 -0
  25. rem/api/routers/chat/streaming.py +608 -45
  26. rem/api/routers/dev.py +81 -0
  27. rem/api/routers/feedback.py +148 -0
  28. rem/api/routers/messages.py +473 -0
  29. rem/api/routers/models.py +78 -0
  30. rem/api/routers/query.py +357 -0
  31. rem/api/routers/shared_sessions.py +406 -0
  32. rem/auth/middleware.py +126 -27
  33. rem/cli/commands/README.md +201 -70
  34. rem/cli/commands/ask.py +13 -10
  35. rem/cli/commands/cluster.py +1359 -0
  36. rem/cli/commands/configure.py +4 -3
  37. rem/cli/commands/db.py +350 -137
  38. rem/cli/commands/experiments.py +76 -72
  39. rem/cli/commands/process.py +22 -15
  40. rem/cli/commands/scaffold.py +47 -0
  41. rem/cli/commands/schema.py +95 -49
  42. rem/cli/main.py +29 -6
  43. rem/config.py +2 -2
  44. rem/models/core/core_model.py +7 -1
  45. rem/models/core/rem_query.py +5 -2
  46. rem/models/entities/__init__.py +21 -0
  47. rem/models/entities/domain_resource.py +38 -0
  48. rem/models/entities/feedback.py +123 -0
  49. rem/models/entities/message.py +30 -1
  50. rem/models/entities/session.py +83 -0
  51. rem/models/entities/shared_session.py +180 -0
  52. rem/models/entities/user.py +10 -3
  53. rem/registry.py +373 -0
  54. rem/schemas/agents/rem.yaml +7 -3
  55. rem/services/content/providers.py +94 -140
  56. rem/services/content/service.py +92 -20
  57. rem/services/dreaming/affinity_service.py +2 -16
  58. rem/services/dreaming/moment_service.py +2 -15
  59. rem/services/embeddings/api.py +24 -17
  60. rem/services/embeddings/worker.py +16 -16
  61. rem/services/phoenix/EXPERIMENT_DESIGN.md +3 -3
  62. rem/services/phoenix/client.py +252 -19
  63. rem/services/postgres/README.md +159 -15
  64. rem/services/postgres/__init__.py +2 -1
  65. rem/services/postgres/diff_service.py +426 -0
  66. rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
  67. rem/services/postgres/repository.py +132 -0
  68. rem/services/postgres/schema_generator.py +86 -5
  69. rem/services/postgres/service.py +6 -6
  70. rem/services/rate_limit.py +113 -0
  71. rem/services/rem/README.md +14 -0
  72. rem/services/rem/parser.py +44 -9
  73. rem/services/rem/service.py +36 -2
  74. rem/services/session/compression.py +17 -1
  75. rem/services/session/reload.py +1 -1
  76. rem/services/user_service.py +98 -0
  77. rem/settings.py +169 -17
  78. rem/sql/background_indexes.sql +21 -16
  79. rem/sql/migrations/001_install.sql +231 -54
  80. rem/sql/migrations/002_install_models.sql +457 -393
  81. rem/sql/migrations/003_optional_extensions.sql +326 -0
  82. rem/utils/constants.py +97 -0
  83. rem/utils/date_utils.py +228 -0
  84. rem/utils/embeddings.py +17 -4
  85. rem/utils/files.py +167 -0
  86. rem/utils/mime_types.py +158 -0
  87. rem/utils/model_helpers.py +156 -1
  88. rem/utils/schema_loader.py +191 -35
  89. rem/utils/sql_types.py +3 -1
  90. rem/utils/vision.py +9 -14
  91. rem/workers/README.md +14 -14
  92. rem/workers/db_maintainer.py +74 -0
  93. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/METADATA +303 -164
  94. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/RECORD +96 -70
  95. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/WHEEL +1 -1
  96. rem/sql/002_install_models.sql +0 -1068
  97. rem/sql/install_models.sql +0 -1038
  98. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/entry_points.txt +0 -0
rem/api/routers/dev.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ Development utilities router (non-production only).
3
+
4
+ Provides testing endpoints that are available in development/staging environments
5
+ regardless of auth configuration. These endpoints are NEVER available in production.
6
+
7
+ Endpoints:
8
+ - GET /api/dev/token - Get a dev token for test-user
9
+ """
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+ from loguru import logger
13
+
14
+ from ...settings import settings
15
+
16
+ router = APIRouter(prefix="/api/dev", tags=["dev"])
17
+
18
+
19
+ def generate_dev_token() -> str:
20
+ """
21
+ Generate a dev token for testing.
22
+
23
+ Token format: dev_<hmac_signature>
24
+ The signature is based on the session secret to ensure only valid tokens work.
25
+ """
26
+ import hashlib
27
+ import hmac
28
+
29
+ # Use session secret as key
30
+ secret = settings.auth.session_secret or "dev-secret"
31
+ message = "test-user:dev-token"
32
+
33
+ signature = hmac.new(
34
+ secret.encode(),
35
+ message.encode(),
36
+ hashlib.sha256
37
+ ).hexdigest()[:32]
38
+
39
+ return f"dev_{signature}"
40
+
41
+
42
+ def verify_dev_token(token: str) -> bool:
43
+ """Verify a dev token is valid."""
44
+ expected = generate_dev_token()
45
+ return token == expected
46
+
47
+
48
+ @router.get("/token")
49
+ async def get_dev_token(request: Request):
50
+ """
51
+ Get a development token for testing (non-production only).
52
+
53
+ This token can be used as a Bearer token to authenticate as the
54
+ test user (test-user / test@rem.local) without going through OAuth.
55
+
56
+ Usage:
57
+ curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/...
58
+
59
+ Returns:
60
+ 401 if in production environment
61
+ Token and usage instructions otherwise
62
+ """
63
+ if settings.environment == "production":
64
+ raise HTTPException(
65
+ status_code=401,
66
+ detail="Dev tokens are not available in production"
67
+ )
68
+
69
+ token = generate_dev_token()
70
+
71
+ return {
72
+ "token": token,
73
+ "type": "Bearer",
74
+ "user": {
75
+ "id": "test-user",
76
+ "email": "test@rem.local",
77
+ "name": "Test User",
78
+ },
79
+ "usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
80
+ "warning": "This token is for development/testing only and will not work in production.",
81
+ }
@@ -0,0 +1,148 @@
1
+ """
2
+ Message feedback endpoint.
3
+
4
+ Provides endpoint for submitting feedback on messages.
5
+
6
+ Endpoints:
7
+ POST /api/v1/messages/feedback - Submit feedback on a message
8
+
9
+ Trace Integration:
10
+ - Feedback can reference trace_id/span_id for OTEL integration
11
+ - Phoenix sync attaches feedback as span annotations (async)
12
+ """
13
+
14
+ from fastapi import APIRouter, Header, HTTPException, Request
15
+ from loguru import logger
16
+ from pydantic import BaseModel, Field
17
+
18
+ from ..deps import get_user_id_from_request
19
+ from ...models.entities import Feedback, Message
20
+ from ...services.postgres import Repository
21
+ from ...settings import settings
22
+
23
+ router = APIRouter(prefix="/api/v1", tags=["messages"])
24
+
25
+
26
+ # =============================================================================
27
+ # Request/Response Models
28
+ # =============================================================================
29
+
30
+
31
+ class FeedbackCreateRequest(BaseModel):
32
+ """Request to submit feedback."""
33
+
34
+ session_id: str = Field(description="Session ID this feedback relates to")
35
+ message_id: str | None = Field(
36
+ default=None, description="Specific message ID (null for session-level)"
37
+ )
38
+ rating: int | None = Field(
39
+ default=None,
40
+ ge=-1,
41
+ le=5,
42
+ description="Rating: -1 (thumbs down), 1 (thumbs up), or 1-5 scale",
43
+ )
44
+ categories: list[str] = Field(
45
+ default_factory=list, description="Feedback categories"
46
+ )
47
+ comment: str | None = Field(default=None, description="Free-text comment")
48
+ trace_id: str | None = Field(
49
+ default=None, description="OTEL trace ID (auto-resolved if message has it)"
50
+ )
51
+ span_id: str | None = Field(
52
+ default=None, description="OTEL span ID (auto-resolved if message has it)"
53
+ )
54
+
55
+
56
+ class FeedbackResponse(BaseModel):
57
+ """Response after submitting feedback."""
58
+
59
+ id: str
60
+ session_id: str
61
+ message_id: str | None
62
+ rating: int | None
63
+ categories: list[str]
64
+ comment: str | None
65
+ trace_id: str | None
66
+ span_id: str | None
67
+ phoenix_synced: bool
68
+ created_at: str
69
+
70
+
71
+ # =============================================================================
72
+ # Feedback Endpoint
73
+ # =============================================================================
74
+
75
+
76
+ @router.post("/messages/feedback", response_model=FeedbackResponse, status_code=201)
77
+ async def submit_feedback(
78
+ request: Request,
79
+ request_body: FeedbackCreateRequest,
80
+ x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
81
+ ) -> FeedbackResponse:
82
+ """
83
+ Submit feedback on a message or session.
84
+
85
+ If message_id is provided, feedback is attached to that specific message.
86
+ If only session_id is provided, feedback applies to the entire session.
87
+
88
+ Trace IDs (trace_id, span_id) can be:
89
+ - Provided explicitly in the request
90
+ - Auto-resolved from the message if message_id is provided
91
+
92
+ Returns:
93
+ Created feedback object
94
+ """
95
+ if not settings.postgres.enabled:
96
+ raise HTTPException(status_code=503, detail="Database not enabled")
97
+
98
+ effective_user_id = get_user_id_from_request(request)
99
+
100
+ # Resolve trace_id/span_id from message if not provided
101
+ trace_id = request_body.trace_id
102
+ span_id = request_body.span_id
103
+
104
+ if request_body.message_id and (not trace_id or not span_id):
105
+ message_repo = Repository(Message, table_name="messages")
106
+ message = await message_repo.get_by_id(request_body.message_id, x_tenant_id)
107
+ if message:
108
+ trace_id = trace_id or message.trace_id
109
+ span_id = span_id or message.span_id
110
+
111
+ feedback = Feedback(
112
+ session_id=request_body.session_id,
113
+ message_id=request_body.message_id,
114
+ rating=request_body.rating,
115
+ categories=request_body.categories,
116
+ comment=request_body.comment,
117
+ trace_id=trace_id,
118
+ span_id=span_id,
119
+ phoenix_synced=False,
120
+ annotator_kind="HUMAN",
121
+ user_id=effective_user_id,
122
+ tenant_id=x_tenant_id,
123
+ )
124
+
125
+ repo = Repository(Feedback, table_name="feedbacks")
126
+ result = await repo.upsert(feedback)
127
+
128
+ logger.info(
129
+ f"Feedback submitted: session={request_body.session_id}, "
130
+ f"message={request_body.message_id}, rating={request_body.rating}"
131
+ )
132
+
133
+ # TODO: Async sync to Phoenix if trace_id/span_id available
134
+ if trace_id and span_id:
135
+ logger.debug(f"Feedback has trace info: trace={trace_id}, span={span_id}")
136
+
137
+ return FeedbackResponse(
138
+ id=str(result.id),
139
+ session_id=result.session_id,
140
+ message_id=result.message_id,
141
+ rating=result.rating,
142
+ categories=result.categories,
143
+ comment=result.comment,
144
+ trace_id=result.trace_id,
145
+ span_id=result.span_id,
146
+ phoenix_synced=result.phoenix_synced,
147
+ created_at=result.created_at.isoformat() if result.created_at else "",
148
+ )
@@ -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