remdb 0.3.157__py3-none-any.whl → 0.3.171__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/agents/agent_manager.py +2 -1
- rem/agentic/context.py +81 -3
- rem/agentic/context_builder.py +31 -6
- rem/agentic/mcp/tool_wrapper.py +6 -0
- rem/agentic/providers/pydantic_ai.py +11 -2
- rem/api/deps.py +1 -3
- rem/api/main.py +21 -2
- rem/api/mcp_router/tools.py +4 -2
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/auth.py +144 -5
- rem/api/routers/chat/completions.py +5 -3
- rem/api/routers/chat/streaming.py +18 -0
- rem/api/routers/messages.py +24 -15
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +70 -30
- rem/schemas/agents/core/agent-builder.yaml +143 -42
- rem/services/email/service.py +61 -7
- rem/services/postgres/repository.py +5 -4
- rem/services/user_service.py +41 -9
- rem/settings.py +9 -1
- rem/sql/migrations/001_install.sql +1 -1
- rem/utils/schema_loader.py +45 -7
- {remdb-0.3.157.dist-info → remdb-0.3.171.dist-info}/METADATA +1 -1
- {remdb-0.3.157.dist-info → remdb-0.3.171.dist-info}/RECORD +26 -25
- {remdb-0.3.157.dist-info → remdb-0.3.171.dist-info}/WHEEL +0 -0
- {remdb-0.3.157.dist-info → remdb-0.3.171.dist-info}/entry_points.txt +0 -0
|
@@ -128,8 +128,9 @@ async def save_agent(
|
|
|
128
128
|
)
|
|
129
129
|
|
|
130
130
|
# Create Schema entity (user-scoped)
|
|
131
|
+
# Note: tenant_id defaults to "default" for anonymous users
|
|
131
132
|
schema_entity = Schema(
|
|
132
|
-
tenant_id=user_id,
|
|
133
|
+
tenant_id=user_id or "default",
|
|
133
134
|
user_id=user_id,
|
|
134
135
|
name=name,
|
|
135
136
|
spec=spec,
|
rem/agentic/context.py
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
Agent execution context and configuration.
|
|
3
3
|
|
|
4
4
|
Design pattern for session context that can be constructed from:
|
|
5
|
+
- FastAPI Request object (preferred - extracts user from JWT via request.state)
|
|
5
6
|
- HTTP headers (X-User-Id, X-Session-Id, X-Model-Name, X-Is-Eval, etc.)
|
|
6
7
|
- Direct instantiation for testing/CLI
|
|
7
8
|
|
|
9
|
+
User ID Sources (in priority order):
|
|
10
|
+
1. request.state.user.id - From JWT token validated by auth middleware (SECURE)
|
|
11
|
+
2. X-User-Id header - Fallback for backwards compatibility (less secure)
|
|
12
|
+
|
|
8
13
|
Headers Mapping:
|
|
9
|
-
X-User-Id → context.user_id
|
|
10
14
|
X-Tenant-Id → context.tenant_id (default: "default")
|
|
11
15
|
X-Session-Id → context.session_id
|
|
12
16
|
X-Agent-Schema → context.agent_schema_uri (default: "rem")
|
|
@@ -128,13 +132,87 @@ class AgentContext(BaseModel):
|
|
|
128
132
|
logger.debug(f"No user_id from {source}, using None (anonymous/shared data)")
|
|
129
133
|
return None
|
|
130
134
|
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_request(cls, request: "Request") -> "AgentContext":
|
|
137
|
+
"""
|
|
138
|
+
Construct AgentContext from a FastAPI Request object.
|
|
139
|
+
|
|
140
|
+
This is the PREFERRED method for API endpoints. It extracts user_id
|
|
141
|
+
from the authenticated user in request.state (set by auth middleware
|
|
142
|
+
from JWT token), which is more secure than trusting X-User-Id header.
|
|
143
|
+
|
|
144
|
+
Priority for user_id:
|
|
145
|
+
1. request.state.user.id - From validated JWT token (SECURE)
|
|
146
|
+
2. X-User-Id header - Fallback for backwards compatibility
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
request: FastAPI Request object
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
AgentContext with user from JWT and other values from headers
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
@app.post("/api/v1/chat/completions")
|
|
156
|
+
async def chat(request: Request, body: ChatRequest):
|
|
157
|
+
context = AgentContext.from_request(request)
|
|
158
|
+
# context.user_id is from JWT, not header
|
|
159
|
+
"""
|
|
160
|
+
from typing import TYPE_CHECKING
|
|
161
|
+
if TYPE_CHECKING:
|
|
162
|
+
from starlette.requests import Request
|
|
163
|
+
|
|
164
|
+
# Get headers dict
|
|
165
|
+
headers = dict(request.headers)
|
|
166
|
+
normalized = {k.lower(): v for k, v in headers.items()}
|
|
167
|
+
|
|
168
|
+
# Extract user_id from authenticated user (JWT) - this is the source of truth
|
|
169
|
+
user_id = None
|
|
170
|
+
tenant_id = "default"
|
|
171
|
+
|
|
172
|
+
if hasattr(request, "state"):
|
|
173
|
+
user = getattr(request.state, "user", None)
|
|
174
|
+
if user and isinstance(user, dict):
|
|
175
|
+
user_id = user.get("id")
|
|
176
|
+
# Also get tenant_id from authenticated user if available
|
|
177
|
+
if user.get("tenant_id"):
|
|
178
|
+
tenant_id = user.get("tenant_id")
|
|
179
|
+
if user_id:
|
|
180
|
+
logger.debug(f"User ID from JWT: {user_id}")
|
|
181
|
+
|
|
182
|
+
# Fallback to X-User-Id header if no authenticated user
|
|
183
|
+
if not user_id:
|
|
184
|
+
user_id = normalized.get("x-user-id")
|
|
185
|
+
if user_id:
|
|
186
|
+
logger.debug(f"User ID from X-User-Id header (fallback): {user_id}")
|
|
187
|
+
|
|
188
|
+
# Override tenant_id from header if provided
|
|
189
|
+
header_tenant = normalized.get("x-tenant-id")
|
|
190
|
+
if header_tenant:
|
|
191
|
+
tenant_id = header_tenant
|
|
192
|
+
|
|
193
|
+
# Parse X-Is-Eval header
|
|
194
|
+
is_eval_str = normalized.get("x-is-eval", "").lower()
|
|
195
|
+
is_eval = is_eval_str in ("true", "1", "yes")
|
|
196
|
+
|
|
197
|
+
return cls(
|
|
198
|
+
user_id=user_id,
|
|
199
|
+
tenant_id=tenant_id,
|
|
200
|
+
session_id=normalized.get("x-session-id"),
|
|
201
|
+
default_model=normalized.get("x-model-name") or settings.llm.default_model,
|
|
202
|
+
agent_schema_uri=normalized.get("x-agent-schema"),
|
|
203
|
+
is_eval=is_eval,
|
|
204
|
+
)
|
|
205
|
+
|
|
131
206
|
@classmethod
|
|
132
207
|
def from_headers(cls, headers: dict[str, str]) -> "AgentContext":
|
|
133
208
|
"""
|
|
134
|
-
Construct AgentContext from HTTP headers.
|
|
209
|
+
Construct AgentContext from HTTP headers dict.
|
|
210
|
+
|
|
211
|
+
NOTE: Prefer from_request() for API endpoints as it extracts user_id
|
|
212
|
+
from the validated JWT token in request.state, which is more secure.
|
|
135
213
|
|
|
136
214
|
Reads standard headers:
|
|
137
|
-
- X-User-Id: User identifier
|
|
215
|
+
- X-User-Id: User identifier (fallback - prefer JWT)
|
|
138
216
|
- X-Tenant-Id: Tenant identifier
|
|
139
217
|
- X-Session-Id: Session identifier
|
|
140
218
|
- X-Model-Name: Model override
|
rem/agentic/context_builder.py
CHANGED
|
@@ -12,7 +12,7 @@ User Context (on-demand by default):
|
|
|
12
12
|
- System message includes REM LOOKUP hint for user profile
|
|
13
13
|
- Agent decides whether to load profile based on query
|
|
14
14
|
- More efficient for queries that don't need personalization
|
|
15
|
-
- Example: "User
|
|
15
|
+
- Example: "User: sarah@example.com. To load user profile: Use REM LOOKUP \"sarah@example.com\""
|
|
16
16
|
|
|
17
17
|
User Context (auto-inject when enabled):
|
|
18
18
|
- Set CHAT__AUTO_INJECT_USER_CONTEXT=true
|
|
@@ -40,7 +40,7 @@ Usage (on-demand, default):
|
|
|
40
40
|
|
|
41
41
|
# Messages list structure (on-demand):
|
|
42
42
|
# [
|
|
43
|
-
# {"role": "system", "content": "Today's date: 2025-11-22\nUser
|
|
43
|
+
# {"role": "system", "content": "Today's date: 2025-11-22\nUser: sarah@example.com\nTo load user profile: Use REM LOOKUP \"sarah@example.com\"\nSession ID: sess-123\nTo load session history: Use REM LOOKUP messages?session_id=sess-123"},
|
|
44
44
|
# {"role": "user", "content": "What's next for the API migration?"}
|
|
45
45
|
# ]
|
|
46
46
|
|
|
@@ -103,6 +103,7 @@ class ContextBuilder:
|
|
|
103
103
|
headers: dict[str, str],
|
|
104
104
|
new_messages: list[dict[str, str]] | None = None,
|
|
105
105
|
db: PostgresService | None = None,
|
|
106
|
+
user_id: str | None = None,
|
|
106
107
|
) -> tuple[AgentContext, list[ContextMessage]]:
|
|
107
108
|
"""
|
|
108
109
|
Build complete context from HTTP headers.
|
|
@@ -114,7 +115,7 @@ class ContextBuilder:
|
|
|
114
115
|
- Agent can retrieve full content on-demand using REM LOOKUP
|
|
115
116
|
|
|
116
117
|
User Context (on-demand by default):
|
|
117
|
-
- System message includes REM LOOKUP hint: "User
|
|
118
|
+
- System message includes REM LOOKUP hint: "User: {email}. To load user profile: Use REM LOOKUP \"{email}\""
|
|
118
119
|
- Agent decides whether to load profile based on query
|
|
119
120
|
|
|
120
121
|
User Context (auto-inject when enabled):
|
|
@@ -125,6 +126,7 @@ class ContextBuilder:
|
|
|
125
126
|
headers: HTTP request headers (case-insensitive)
|
|
126
127
|
new_messages: New messages from current request
|
|
127
128
|
db: Optional PostgresService (creates if None)
|
|
129
|
+
user_id: Override user_id from JWT token (takes precedence over X-User-Id header)
|
|
128
130
|
|
|
129
131
|
Returns:
|
|
130
132
|
Tuple of (AgentContext, messages list)
|
|
@@ -135,7 +137,7 @@ class ContextBuilder:
|
|
|
135
137
|
|
|
136
138
|
# messages structure:
|
|
137
139
|
# [
|
|
138
|
-
# {"role": "system", "content": "Today's date: 2025-11-22\nUser
|
|
140
|
+
# {"role": "system", "content": "Today's date: 2025-11-22\nUser: sarah@example.com\nTo load user profile: Use REM LOOKUP \"sarah@example.com\""},
|
|
139
141
|
# {"role": "user", "content": "Previous message"},
|
|
140
142
|
# {"role": "assistant", "content": "Start of long response... [REM LOOKUP session-123-msg-1] ...end"},
|
|
141
143
|
# {"role": "user", "content": "New message"}
|
|
@@ -147,6 +149,17 @@ class ContextBuilder:
|
|
|
147
149
|
# Extract AgentContext from headers
|
|
148
150
|
context = AgentContext.from_headers(headers)
|
|
149
151
|
|
|
152
|
+
# Override user_id if provided (from JWT token - takes precedence over header)
|
|
153
|
+
if user_id is not None:
|
|
154
|
+
context = AgentContext(
|
|
155
|
+
user_id=user_id,
|
|
156
|
+
tenant_id=context.tenant_id,
|
|
157
|
+
session_id=context.session_id,
|
|
158
|
+
default_model=context.default_model,
|
|
159
|
+
agent_schema_uri=context.agent_schema_uri,
|
|
160
|
+
is_eval=context.is_eval,
|
|
161
|
+
)
|
|
162
|
+
|
|
150
163
|
# Initialize DB if not provided and needed (for user context or session history)
|
|
151
164
|
close_db = False
|
|
152
165
|
if db is None and (settings.chat.auto_inject_user_context or context.session_id):
|
|
@@ -178,8 +191,16 @@ class ContextBuilder:
|
|
|
178
191
|
context_hint += "\n\nNo user context available (anonymous or new user)."
|
|
179
192
|
elif context.user_id:
|
|
180
193
|
# On-demand: Provide hint to use REM LOOKUP
|
|
181
|
-
|
|
182
|
-
|
|
194
|
+
# user_id is UUID5 hash of email - load user to get email for display and LOOKUP
|
|
195
|
+
user_repo = Repository(User, "users", db=db)
|
|
196
|
+
user = await user_repo.get_by_id(context.user_id, context.tenant_id)
|
|
197
|
+
if user and user.email:
|
|
198
|
+
# Show email (more useful than UUID) and LOOKUP hint
|
|
199
|
+
context_hint += f"\n\nUser: {user.email}"
|
|
200
|
+
context_hint += f"\nTo load user profile: Use REM LOOKUP \"{user.email}\""
|
|
201
|
+
else:
|
|
202
|
+
context_hint += f"\n\nUser ID: {context.user_id}"
|
|
203
|
+
context_hint += "\nUser profile not available."
|
|
183
204
|
|
|
184
205
|
# Add system context hint
|
|
185
206
|
messages.append(ContextMessage(role="system", content=context_hint))
|
|
@@ -226,6 +247,9 @@ class ContextBuilder:
|
|
|
226
247
|
"""
|
|
227
248
|
Load user profile from database and format as context.
|
|
228
249
|
|
|
250
|
+
user_id is always a UUID5 hash of email (bijection).
|
|
251
|
+
Looks up user by their id field in the database.
|
|
252
|
+
|
|
229
253
|
Returns formatted string with:
|
|
230
254
|
- User summary (generated by dreaming worker)
|
|
231
255
|
- Current projects
|
|
@@ -239,6 +263,7 @@ class ContextBuilder:
|
|
|
239
263
|
|
|
240
264
|
try:
|
|
241
265
|
user_repo = Repository(User, "users", db=db)
|
|
266
|
+
# user_id is UUID5 hash of email - look up by database id
|
|
242
267
|
user = await user_repo.get_by_id(user_id, tenant_id)
|
|
243
268
|
|
|
244
269
|
if not user:
|
rem/agentic/mcp/tool_wrapper.py
CHANGED
|
@@ -149,6 +149,12 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
149
149
|
parts = re.sub(r'_+', '_', parts).strip('_') # Clean up multiple underscores
|
|
150
150
|
func_name = f"get_{parts}"
|
|
151
151
|
|
|
152
|
+
# For parameterized URIs, append _by_{params} to avoid naming conflicts
|
|
153
|
+
# e.g., rem://agents/{name} -> get_rem_agents_by_name (distinct from get_rem_agents)
|
|
154
|
+
if template_vars:
|
|
155
|
+
param_suffix = "_by_" + "_".join(template_vars)
|
|
156
|
+
func_name = f"{func_name}{param_suffix}"
|
|
157
|
+
|
|
152
158
|
# Build description including parameter info
|
|
153
159
|
description = usage or f"Fetch {uri} resource"
|
|
154
160
|
if template_vars:
|
|
@@ -550,10 +550,18 @@ async def create_agent(
|
|
|
550
550
|
# Extract schema fields using typed helpers
|
|
551
551
|
from ..schema import get_system_prompt, get_metadata
|
|
552
552
|
|
|
553
|
+
# Track whether mcp_servers was explicitly configured (even if empty)
|
|
554
|
+
mcp_servers_explicitly_set = False
|
|
555
|
+
|
|
553
556
|
if agent_schema:
|
|
554
557
|
system_prompt = get_system_prompt(agent_schema)
|
|
555
558
|
metadata = get_metadata(agent_schema)
|
|
556
|
-
|
|
559
|
+
# Check if mcp_servers was explicitly set (could be empty list to disable)
|
|
560
|
+
if hasattr(metadata, 'mcp_servers') and metadata.mcp_servers is not None:
|
|
561
|
+
mcp_server_configs = [s.model_dump() for s in metadata.mcp_servers]
|
|
562
|
+
mcp_servers_explicitly_set = True
|
|
563
|
+
else:
|
|
564
|
+
mcp_server_configs = []
|
|
557
565
|
resource_configs = metadata.resources if hasattr(metadata, 'resources') else []
|
|
558
566
|
|
|
559
567
|
if metadata.system_prompt:
|
|
@@ -566,7 +574,8 @@ async def create_agent(
|
|
|
566
574
|
|
|
567
575
|
# Auto-detect local MCP server if not explicitly configured
|
|
568
576
|
# This makes mcp_servers config optional - agents get tools automatically
|
|
569
|
-
if
|
|
577
|
+
# But if mcp_servers: [] is explicitly set, respect that (no auto-detection)
|
|
578
|
+
if not mcp_server_configs and not mcp_servers_explicitly_set:
|
|
570
579
|
import importlib
|
|
571
580
|
import os
|
|
572
581
|
import sys
|
rem/api/deps.py
CHANGED
|
@@ -147,7 +147,6 @@ def is_admin(user: dict | None) -> bool:
|
|
|
147
147
|
async def get_user_filter(
|
|
148
148
|
request: Request,
|
|
149
149
|
x_user_id: str | None = None,
|
|
150
|
-
x_tenant_id: str = "default",
|
|
151
150
|
) -> dict[str, Any]:
|
|
152
151
|
"""
|
|
153
152
|
Get user-scoped filter dict for database queries.
|
|
@@ -158,7 +157,6 @@ async def get_user_filter(
|
|
|
158
157
|
Args:
|
|
159
158
|
request: FastAPI request
|
|
160
159
|
x_user_id: Optional user_id filter (admin only for cross-user)
|
|
161
|
-
x_tenant_id: Tenant ID for multi-tenancy
|
|
162
160
|
|
|
163
161
|
Returns:
|
|
164
162
|
Filter dict with appropriate user_id constraint
|
|
@@ -169,7 +167,7 @@ async def get_user_filter(
|
|
|
169
167
|
return await repo.find(filters)
|
|
170
168
|
"""
|
|
171
169
|
user = get_current_user(request)
|
|
172
|
-
filters: dict[str, Any] = {
|
|
170
|
+
filters: dict[str, Any] = {}
|
|
173
171
|
|
|
174
172
|
if is_admin(user):
|
|
175
173
|
# Admin can filter by any user or see all
|
rem/api/main.py
CHANGED
|
@@ -149,19 +149,38 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
149
149
|
client_host = request.client.host if request.client else "unknown"
|
|
150
150
|
user_agent = request.headers.get('user-agent', 'unknown')[:100]
|
|
151
151
|
|
|
152
|
+
# Extract auth info for logging (first 8 chars of token for debugging)
|
|
153
|
+
auth_header = request.headers.get('authorization', '')
|
|
154
|
+
auth_preview = ""
|
|
155
|
+
if auth_header.startswith('Bearer '):
|
|
156
|
+
token = auth_header[7:]
|
|
157
|
+
auth_preview = f"Bearer {token[:8]}..." if len(token) > 8 else f"Bearer {token}"
|
|
158
|
+
|
|
152
159
|
# Process request
|
|
153
160
|
response = await call_next(request)
|
|
154
161
|
|
|
162
|
+
# Extract user info set by auth middleware (after processing)
|
|
163
|
+
user = getattr(request.state, "user", None)
|
|
164
|
+
user_id = user.get("id", "none")[:12] if user else "anon"
|
|
165
|
+
user_email = user.get("email", "") if user else ""
|
|
166
|
+
|
|
155
167
|
# Determine log level based on path AND response status
|
|
156
168
|
duration_ms = (time.time() - start_time) * 1000
|
|
157
169
|
use_debug = self._should_log_at_debug(path, response.status_code)
|
|
158
170
|
log_fn = logger.debug if use_debug else logger.info
|
|
159
171
|
|
|
160
|
-
#
|
|
172
|
+
# Build user info string
|
|
173
|
+
user_info = f"user={user_id}"
|
|
174
|
+
if user_email:
|
|
175
|
+
user_info += f" ({user_email})"
|
|
176
|
+
if auth_preview:
|
|
177
|
+
user_info += f" | auth={auth_preview}"
|
|
178
|
+
|
|
179
|
+
# Log request and response together with auth info
|
|
161
180
|
log_fn(
|
|
162
181
|
f"→ REQUEST: {request.method} {path} | "
|
|
163
182
|
f"Client: {client_host} | "
|
|
164
|
-
f"
|
|
183
|
+
f"{user_info}"
|
|
165
184
|
)
|
|
166
185
|
log_fn(
|
|
167
186
|
f"← RESPONSE: {request.method} {path} | "
|
rem/api/mcp_router/tools.py
CHANGED
|
@@ -116,7 +116,8 @@ def mcp_tool_error_handler(func: Callable) -> Callable:
|
|
|
116
116
|
# Otherwise wrap in success response
|
|
117
117
|
return {"status": "success", **result}
|
|
118
118
|
except Exception as e:
|
|
119
|
-
|
|
119
|
+
# Use %s format to avoid issues with curly braces in error messages
|
|
120
|
+
logger.opt(exception=True).error("{} failed: {}", func.__name__, str(e))
|
|
120
121
|
return {
|
|
121
122
|
"status": "error",
|
|
122
123
|
"error": str(e),
|
|
@@ -380,9 +381,10 @@ async def ask_rem_agent(
|
|
|
380
381
|
from ...utils.schema_loader import load_agent_schema
|
|
381
382
|
|
|
382
383
|
# Create agent context
|
|
384
|
+
# Note: tenant_id defaults to "default" if user_id is None
|
|
383
385
|
context = AgentContext(
|
|
384
386
|
user_id=user_id,
|
|
385
|
-
tenant_id=user_id, #
|
|
387
|
+
tenant_id=user_id or "default", # Use default tenant for anonymous users
|
|
386
388
|
default_model=settings.llm.default_model,
|
|
387
389
|
)
|
|
388
390
|
|
rem/api/middleware/tracking.py
CHANGED
|
@@ -102,14 +102,14 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
|
|
|
102
102
|
# Tenant ID from header or default
|
|
103
103
|
tenant_id = request.headers.get("X-Tenant-Id", "default")
|
|
104
104
|
|
|
105
|
-
# 4. Rate Limiting
|
|
106
|
-
if settings.postgres.enabled:
|
|
105
|
+
# 4. Rate Limiting (skip if disabled via settings)
|
|
106
|
+
if settings.postgres.enabled and settings.api.rate_limit_enabled:
|
|
107
107
|
is_allowed, current, limit = await self.rate_limiter.check_rate_limit(
|
|
108
108
|
tenant_id=tenant_id,
|
|
109
109
|
identifier=identifier,
|
|
110
110
|
tier=tier
|
|
111
111
|
)
|
|
112
|
-
|
|
112
|
+
|
|
113
113
|
if not is_allowed:
|
|
114
114
|
return JSONResponse(
|
|
115
115
|
status_code=429,
|
|
@@ -141,8 +141,8 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
|
|
|
141
141
|
secure=settings.environment == "production"
|
|
142
142
|
)
|
|
143
143
|
|
|
144
|
-
# Add Rate Limit headers
|
|
145
|
-
if settings.postgres.enabled and 'limit' in locals():
|
|
144
|
+
# Add Rate Limit headers (only if rate limiting is enabled)
|
|
145
|
+
if settings.postgres.enabled and settings.api.rate_limit_enabled and 'limit' in locals():
|
|
146
146
|
response.headers["X-RateLimit-Limit"] = str(limit)
|
|
147
147
|
response.headers["X-RateLimit-Remaining"] = str(max(0, limit - current))
|
|
148
148
|
|
rem/api/routers/auth.py
CHANGED
|
@@ -102,6 +102,8 @@ from ...settings import settings
|
|
|
102
102
|
from ...services.postgres.service import PostgresService
|
|
103
103
|
from ...services.user_service import UserService
|
|
104
104
|
from ...auth.providers.email import EmailAuthProvider
|
|
105
|
+
from ...auth.jwt import JWTService, get_jwt_service
|
|
106
|
+
from ...utils.user_id import email_to_user_id
|
|
105
107
|
|
|
106
108
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
107
109
|
|
|
@@ -219,14 +221,14 @@ async def send_email_code(request: Request, body: EmailSendCodeRequest):
|
|
|
219
221
|
@router.post("/email/verify")
|
|
220
222
|
async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
221
223
|
"""
|
|
222
|
-
Verify login code and create session.
|
|
224
|
+
Verify login code and create session with JWT tokens.
|
|
223
225
|
|
|
224
226
|
Args:
|
|
225
227
|
request: FastAPI request
|
|
226
228
|
body: EmailVerifyRequest with email and code
|
|
227
229
|
|
|
228
230
|
Returns:
|
|
229
|
-
Success status with user info
|
|
231
|
+
Success status with user info and JWT tokens
|
|
230
232
|
"""
|
|
231
233
|
if not settings.email.is_configured:
|
|
232
234
|
raise HTTPException(
|
|
@@ -266,7 +268,25 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
|
266
268
|
user_id=result.user_id,
|
|
267
269
|
)
|
|
268
270
|
|
|
269
|
-
#
|
|
271
|
+
# Fetch actual user data from database to get role/tier
|
|
272
|
+
user_service = UserService(db)
|
|
273
|
+
try:
|
|
274
|
+
user_entity = await user_service.get_user_by_id(result.user_id)
|
|
275
|
+
if user_entity:
|
|
276
|
+
# Override defaults with actual database values
|
|
277
|
+
user_dict["role"] = user_entity.role or "user"
|
|
278
|
+
user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
|
|
279
|
+
user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
|
|
280
|
+
user_dict["name"] = user_entity.name or user_dict["name"]
|
|
281
|
+
except Exception as e:
|
|
282
|
+
logger.warning(f"Could not fetch user details: {e}")
|
|
283
|
+
# Continue with defaults from get_user_dict
|
|
284
|
+
|
|
285
|
+
# Generate JWT tokens
|
|
286
|
+
jwt_service = get_jwt_service()
|
|
287
|
+
tokens = jwt_service.create_tokens(user_dict)
|
|
288
|
+
|
|
289
|
+
# Store user in session (for backward compatibility)
|
|
270
290
|
request.session["user"] = user_dict
|
|
271
291
|
|
|
272
292
|
logger.info(f"User authenticated via email: {result.email}")
|
|
@@ -275,6 +295,11 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
|
275
295
|
"success": True,
|
|
276
296
|
"message": result.message,
|
|
277
297
|
"user": user_dict,
|
|
298
|
+
# JWT tokens for stateless auth
|
|
299
|
+
"access_token": tokens["access_token"],
|
|
300
|
+
"refresh_token": tokens["refresh_token"],
|
|
301
|
+
"token_type": tokens["token_type"],
|
|
302
|
+
"expires_in": tokens["expires_in"],
|
|
278
303
|
}
|
|
279
304
|
|
|
280
305
|
except HTTPException:
|
|
@@ -405,8 +430,9 @@ async def callback(provider: str, request: Request):
|
|
|
405
430
|
await user_service.link_anonymous_session(user_entity, anon_id)
|
|
406
431
|
|
|
407
432
|
# Enrich session user with DB info
|
|
433
|
+
# user_id = UUID5 hash of email (deterministic, bijection)
|
|
408
434
|
db_info = {
|
|
409
|
-
"id":
|
|
435
|
+
"id": email_to_user_id(user_info.get("email")),
|
|
410
436
|
"tenant_id": user_entity.tenant_id,
|
|
411
437
|
"tier": user_entity.tier.value if user_entity.tier else "free",
|
|
412
438
|
"roles": [user_entity.role] if user_entity.role else [],
|
|
@@ -472,7 +498,7 @@ async def logout(request: Request):
|
|
|
472
498
|
@router.get("/me")
|
|
473
499
|
async def me(request: Request):
|
|
474
500
|
"""
|
|
475
|
-
Get current user information from session.
|
|
501
|
+
Get current user information from session or JWT.
|
|
476
502
|
|
|
477
503
|
Args:
|
|
478
504
|
request: FastAPI request
|
|
@@ -480,6 +506,16 @@ async def me(request: Request):
|
|
|
480
506
|
Returns:
|
|
481
507
|
User information or 401 if not authenticated
|
|
482
508
|
"""
|
|
509
|
+
# First check for JWT in Authorization header
|
|
510
|
+
auth_header = request.headers.get("Authorization")
|
|
511
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
512
|
+
token = auth_header[7:]
|
|
513
|
+
jwt_service = get_jwt_service()
|
|
514
|
+
user = jwt_service.verify_token(token)
|
|
515
|
+
if user:
|
|
516
|
+
return user
|
|
517
|
+
|
|
518
|
+
# Fall back to session
|
|
483
519
|
user = request.session.get("user")
|
|
484
520
|
if not user:
|
|
485
521
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
@@ -487,6 +523,69 @@ async def me(request: Request):
|
|
|
487
523
|
return user
|
|
488
524
|
|
|
489
525
|
|
|
526
|
+
# =============================================================================
|
|
527
|
+
# JWT Token Endpoints
|
|
528
|
+
# =============================================================================
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class TokenRefreshRequest(BaseModel):
|
|
532
|
+
"""Request to refresh access token."""
|
|
533
|
+
refresh_token: str
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@router.post("/token/refresh")
|
|
537
|
+
async def refresh_token(body: TokenRefreshRequest):
|
|
538
|
+
"""
|
|
539
|
+
Refresh access token using refresh token.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
body: TokenRefreshRequest with refresh_token
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
New access token or 401 if refresh token is invalid
|
|
546
|
+
"""
|
|
547
|
+
jwt_service = get_jwt_service()
|
|
548
|
+
result = jwt_service.refresh_access_token(body.refresh_token)
|
|
549
|
+
|
|
550
|
+
if not result:
|
|
551
|
+
raise HTTPException(
|
|
552
|
+
status_code=401,
|
|
553
|
+
detail="Invalid or expired refresh token"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
return result
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@router.post("/token/verify")
|
|
560
|
+
async def verify_token(request: Request):
|
|
561
|
+
"""
|
|
562
|
+
Verify an access token is valid.
|
|
563
|
+
|
|
564
|
+
Pass the token in the Authorization header: Bearer <token>
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
User info if valid, 401 if invalid
|
|
568
|
+
"""
|
|
569
|
+
auth_header = request.headers.get("Authorization")
|
|
570
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
571
|
+
raise HTTPException(
|
|
572
|
+
status_code=401,
|
|
573
|
+
detail="Missing Authorization header"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
token = auth_header[7:]
|
|
577
|
+
jwt_service = get_jwt_service()
|
|
578
|
+
user = jwt_service.verify_token(token)
|
|
579
|
+
|
|
580
|
+
if not user:
|
|
581
|
+
raise HTTPException(
|
|
582
|
+
status_code=401,
|
|
583
|
+
detail="Invalid or expired token"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
return {"valid": True, "user": user}
|
|
587
|
+
|
|
588
|
+
|
|
490
589
|
# =============================================================================
|
|
491
590
|
# Development Token Endpoints (non-production only)
|
|
492
591
|
# =============================================================================
|
|
@@ -555,3 +654,43 @@ async def get_dev_token(request: Request):
|
|
|
555
654
|
"usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
|
|
556
655
|
"warning": "This token is for development/testing only and will not work in production.",
|
|
557
656
|
}
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@router.get("/dev/mock-code/{email}")
|
|
660
|
+
async def get_mock_code(email: str, request: Request):
|
|
661
|
+
"""
|
|
662
|
+
Get the mock login code for testing (non-production only).
|
|
663
|
+
|
|
664
|
+
This endpoint retrieves the code that was "sent" via email in mock mode.
|
|
665
|
+
Use this for automated testing without real email delivery.
|
|
666
|
+
|
|
667
|
+
Usage:
|
|
668
|
+
1. POST /api/auth/email/send-code with email
|
|
669
|
+
2. GET /api/auth/dev/mock-code/{email} to retrieve the code
|
|
670
|
+
3. POST /api/auth/email/verify with email and code
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
401 if in production environment
|
|
674
|
+
404 if no code found for the email
|
|
675
|
+
The code and email otherwise
|
|
676
|
+
"""
|
|
677
|
+
if settings.environment == "production":
|
|
678
|
+
raise HTTPException(
|
|
679
|
+
status_code=401,
|
|
680
|
+
detail="Mock codes are not available in production"
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
from ...services.email import EmailService
|
|
684
|
+
|
|
685
|
+
code = EmailService.get_mock_code(email)
|
|
686
|
+
if not code:
|
|
687
|
+
raise HTTPException(
|
|
688
|
+
status_code=404,
|
|
689
|
+
detail=f"No mock code found for {email}. Send a code first."
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
"email": email,
|
|
694
|
+
"code": code,
|
|
695
|
+
"warning": "This endpoint is for testing only and will not work in production.",
|
|
696
|
+
}
|
|
@@ -97,7 +97,7 @@ Context Building Flow:
|
|
|
97
97
|
- Long messages include REM LOOKUP hints: "... [REM LOOKUP session-{id}-msg-{index}] ..."
|
|
98
98
|
- Agent can retrieve full content on-demand using REM LOOKUP
|
|
99
99
|
3. User profile provided as REM LOOKUP hint (on-demand by default)
|
|
100
|
-
- Agent receives: "User
|
|
100
|
+
- Agent receives: "User: {email}. To load user profile: Use REM LOOKUP \"{email}\""
|
|
101
101
|
- Agent decides whether to load profile based on query
|
|
102
102
|
4. If CHAT__AUTO_INJECT_USER_CONTEXT=true: User profile auto-loaded and injected
|
|
103
103
|
5. Combines: system context + compressed session history + new messages
|
|
@@ -330,8 +330,8 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
330
330
|
- Useful for A/B testing, model comparison, and feedback collection
|
|
331
331
|
"""
|
|
332
332
|
# Load agent schema: use header value from context or default
|
|
333
|
-
# Extract AgentContext
|
|
334
|
-
temp_context = AgentContext.
|
|
333
|
+
# Extract AgentContext from request (gets user_id from JWT token)
|
|
334
|
+
temp_context = AgentContext.from_request(request)
|
|
335
335
|
schema_name = temp_context.agent_schema_uri or DEFAULT_AGENT_SCHEMA
|
|
336
336
|
|
|
337
337
|
# Resolve model: use body.model if provided, otherwise settings default
|
|
@@ -350,6 +350,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
350
350
|
context, messages = await ContextBuilder.build_from_headers(
|
|
351
351
|
headers=dict(request.headers),
|
|
352
352
|
new_messages=new_messages,
|
|
353
|
+
user_id=temp_context.user_id, # From JWT token (source of truth)
|
|
353
354
|
)
|
|
354
355
|
|
|
355
356
|
# Ensure session exists with metadata and eval mode if applicable
|
|
@@ -509,6 +510,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
509
510
|
context, messages = await ContextBuilder.build_from_headers(
|
|
510
511
|
headers=dict(request.headers),
|
|
511
512
|
new_messages=new_messages,
|
|
513
|
+
user_id=temp_context.user_id, # From JWT token (source of truth)
|
|
512
514
|
)
|
|
513
515
|
|
|
514
516
|
logger.info(f"Built context with {len(messages)} total messages (includes history + user context)")
|
|
@@ -835,3 +835,21 @@ async def stream_openai_response_with_save(
|
|
|
835
835
|
)
|
|
836
836
|
except Exception as e:
|
|
837
837
|
logger.error(f"Failed to save session messages: {e}", exc_info=True)
|
|
838
|
+
|
|
839
|
+
# Update session description with session_name (non-blocking, after all yields)
|
|
840
|
+
for tool_call in tool_calls:
|
|
841
|
+
if tool_call.get("tool_name") == "register_metadata" and tool_call.get("is_metadata"):
|
|
842
|
+
session_name = tool_call.get("arguments", {}).get("session_name")
|
|
843
|
+
if session_name:
|
|
844
|
+
try:
|
|
845
|
+
from ....models.entities import Session
|
|
846
|
+
from ....services.postgres import Repository
|
|
847
|
+
repo = Repository(Session, table_name="sessions")
|
|
848
|
+
session = await repo.get_by_id(session_id)
|
|
849
|
+
if session and session.description != session_name:
|
|
850
|
+
session.description = session_name
|
|
851
|
+
await repo.update(session)
|
|
852
|
+
logger.debug(f"Updated session {session_id} description to '{session_name}'")
|
|
853
|
+
except Exception as e:
|
|
854
|
+
logger.warning(f"Failed to update session description: {e}")
|
|
855
|
+
break
|