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.
@@ -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
@@ -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 ID: sarah@example.com. To load user profile: Use REM LOOKUP users/sarah@example.com"
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 ID: sarah@example.com\nTo load user profile: Use REM LOOKUP users/sarah@example.com\nSession ID: sess-123\nTo load session history: Use REM LOOKUP messages?session_id=sess-123"},
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 ID: {user_id}. To load user profile: Use REM LOOKUP users/{user_id}"
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 ID: sarah@example.com\nTo load user profile: Use REM LOOKUP users/sarah@example.com"},
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
- context_hint += f"\n\nUser ID: {context.user_id}"
182
- context_hint += f"\nTo load user profile: Use REM LOOKUP users/{context.user_id}"
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:
@@ -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
- mcp_server_configs = [s.model_dump() for s in metadata.mcp_servers] if hasattr(metadata, 'mcp_servers') and metadata.mcp_servers else []
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 not mcp_server_configs:
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] = {"tenant_id": x_tenant_id}
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
- # Log request and response together
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"User-Agent: {user_agent}"
183
+ f"{user_info}"
165
184
  )
166
185
  log_fn(
167
186
  f"← RESPONSE: {request.method} {path} | "
@@ -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
- logger.error(f"{func.__name__} failed: {e}", exc_info=True)
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, # Set tenant_id to user_id for backward compat
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
 
@@ -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
- # Store user in session
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": str(user_entity.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 ID: {user_id}. To load user profile: Use REM LOOKUP users/{user_id}"
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 first to get schema name
334
- temp_context = AgentContext.from_headers(dict(request.headers))
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