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.
- rem/__init__.py +129 -2
- rem/agentic/README.md +76 -0
- rem/agentic/__init__.py +15 -0
- rem/agentic/agents/__init__.py +16 -2
- rem/agentic/agents/sse_simulator.py +500 -0
- rem/agentic/context.py +28 -22
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/otel/setup.py +92 -4
- rem/agentic/providers/phoenix.py +32 -43
- rem/agentic/providers/pydantic_ai.py +142 -22
- rem/agentic/schema.py +358 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/README.md +238 -1
- rem/api/deps.py +255 -0
- rem/api/main.py +151 -37
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +17 -2
- rem/api/mcp_router/tools.py +143 -7
- rem/api/middleware/tracking.py +172 -0
- rem/api/routers/admin.py +277 -0
- rem/api/routers/auth.py +124 -0
- rem/api/routers/chat/completions.py +152 -16
- rem/api/routers/chat/models.py +7 -3
- rem/api/routers/chat/sse_events.py +526 -0
- rem/api/routers/chat/streaming.py +608 -45
- rem/api/routers/dev.py +81 -0
- rem/api/routers/feedback.py +148 -0
- rem/api/routers/messages.py +473 -0
- rem/api/routers/models.py +78 -0
- rem/api/routers/query.py +357 -0
- rem/api/routers/shared_sessions.py +406 -0
- rem/auth/middleware.py +126 -27
- rem/cli/commands/README.md +201 -70
- rem/cli/commands/ask.py +13 -10
- rem/cli/commands/cluster.py +1359 -0
- rem/cli/commands/configure.py +4 -3
- rem/cli/commands/db.py +350 -137
- rem/cli/commands/experiments.py +76 -72
- rem/cli/commands/process.py +22 -15
- rem/cli/commands/scaffold.py +47 -0
- rem/cli/commands/schema.py +95 -49
- rem/cli/main.py +29 -6
- rem/config.py +2 -2
- rem/models/core/core_model.py +7 -1
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/__init__.py +21 -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/session.py +83 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/user.py +10 -3
- rem/registry.py +373 -0
- rem/schemas/agents/rem.yaml +7 -3
- rem/services/content/providers.py +94 -140
- rem/services/content/service.py +92 -20
- rem/services/dreaming/affinity_service.py +2 -16
- rem/services/dreaming/moment_service.py +2 -15
- rem/services/embeddings/api.py +24 -17
- rem/services/embeddings/worker.py +16 -16
- rem/services/phoenix/EXPERIMENT_DESIGN.md +3 -3
- rem/services/phoenix/client.py +252 -19
- rem/services/postgres/README.md +159 -15
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +426 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
- rem/services/postgres/repository.py +132 -0
- rem/services/postgres/schema_generator.py +86 -5
- rem/services/postgres/service.py +6 -6
- rem/services/rate_limit.py +113 -0
- rem/services/rem/README.md +14 -0
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/compression.py +17 -1
- rem/services/session/reload.py +1 -1
- rem/services/user_service.py +98 -0
- rem/settings.py +169 -17
- rem/sql/background_indexes.sql +21 -16
- rem/sql/migrations/001_install.sql +231 -54
- rem/sql/migrations/002_install_models.sql +457 -393
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/utils/constants.py +97 -0
- rem/utils/date_utils.py +228 -0
- rem/utils/embeddings.py +17 -4
- rem/utils/files.py +167 -0
- rem/utils/mime_types.py +158 -0
- rem/utils/model_helpers.py +156 -1
- rem/utils/schema_loader.py +191 -35
- rem/utils/sql_types.py +3 -1
- rem/utils/vision.py +9 -14
- rem/workers/README.md +14 -14
- rem/workers/db_maintainer.py +74 -0
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/METADATA +303 -164
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/RECORD +96 -70
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/WHEEL +1 -1
- rem/sql/002_install_models.sql +0 -1068
- rem/sql/install_models.sql +0 -1038
- {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
|