remdb 0.3.114__py3-none-any.whl → 0.3.172__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (83) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/agents/sse_simulator.py +2 -0
  4. rem/agentic/context.py +103 -5
  5. rem/agentic/context_builder.py +36 -9
  6. rem/agentic/mcp/tool_wrapper.py +161 -18
  7. rem/agentic/otel/setup.py +1 -0
  8. rem/agentic/providers/phoenix.py +371 -108
  9. rem/agentic/providers/pydantic_ai.py +172 -30
  10. rem/agentic/schema.py +8 -4
  11. rem/api/deps.py +3 -5
  12. rem/api/main.py +26 -4
  13. rem/api/mcp_router/resources.py +15 -10
  14. rem/api/mcp_router/server.py +11 -3
  15. rem/api/mcp_router/tools.py +418 -4
  16. rem/api/middleware/tracking.py +5 -5
  17. rem/api/routers/admin.py +218 -1
  18. rem/api/routers/auth.py +349 -6
  19. rem/api/routers/chat/completions.py +255 -7
  20. rem/api/routers/chat/models.py +81 -7
  21. rem/api/routers/chat/otel_utils.py +33 -0
  22. rem/api/routers/chat/sse_events.py +17 -1
  23. rem/api/routers/chat/streaming.py +126 -19
  24. rem/api/routers/feedback.py +134 -14
  25. rem/api/routers/messages.py +24 -15
  26. rem/api/routers/query.py +6 -3
  27. rem/auth/__init__.py +13 -3
  28. rem/auth/jwt.py +352 -0
  29. rem/auth/middleware.py +115 -10
  30. rem/auth/providers/__init__.py +4 -1
  31. rem/auth/providers/email.py +215 -0
  32. rem/cli/commands/README.md +42 -0
  33. rem/cli/commands/cluster.py +617 -168
  34. rem/cli/commands/configure.py +4 -7
  35. rem/cli/commands/db.py +66 -22
  36. rem/cli/commands/experiments.py +468 -76
  37. rem/cli/commands/schema.py +6 -5
  38. rem/cli/commands/session.py +336 -0
  39. rem/cli/dreaming.py +2 -2
  40. rem/cli/main.py +2 -0
  41. rem/config.py +8 -1
  42. rem/models/core/experiment.py +58 -14
  43. rem/models/entities/__init__.py +4 -0
  44. rem/models/entities/ontology.py +1 -1
  45. rem/models/entities/ontology_config.py +1 -1
  46. rem/models/entities/subscriber.py +175 -0
  47. rem/models/entities/user.py +1 -0
  48. rem/schemas/agents/core/agent-builder.yaml +235 -0
  49. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  50. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  51. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  52. rem/services/__init__.py +3 -1
  53. rem/services/content/service.py +4 -3
  54. rem/services/email/__init__.py +10 -0
  55. rem/services/email/service.py +513 -0
  56. rem/services/email/templates.py +360 -0
  57. rem/services/phoenix/client.py +59 -18
  58. rem/services/postgres/README.md +38 -0
  59. rem/services/postgres/diff_service.py +127 -6
  60. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  61. rem/services/postgres/repository.py +5 -4
  62. rem/services/postgres/schema_generator.py +205 -4
  63. rem/services/session/compression.py +120 -50
  64. rem/services/session/reload.py +14 -7
  65. rem/services/user_service.py +41 -9
  66. rem/settings.py +442 -23
  67. rem/sql/migrations/001_install.sql +156 -0
  68. rem/sql/migrations/002_install_models.sql +1951 -88
  69. rem/sql/migrations/004_cache_system.sql +548 -0
  70. rem/sql/migrations/005_schema_update.sql +145 -0
  71. rem/utils/README.md +45 -0
  72. rem/utils/__init__.py +18 -0
  73. rem/utils/files.py +157 -1
  74. rem/utils/schema_loader.py +139 -10
  75. rem/utils/sql_paths.py +146 -0
  76. rem/utils/vision.py +1 -1
  77. rem/workers/__init__.py +3 -1
  78. rem/workers/db_listener.py +579 -0
  79. rem/workers/unlogged_maintainer.py +463 -0
  80. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
  81. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
  82. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
  83. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
@@ -47,6 +47,7 @@ from pydantic_ai.messages import (
47
47
  ToolCallPart,
48
48
  )
49
49
 
50
+ from .otel_utils import get_current_trace_context, get_tracer
50
51
  from .models import (
51
52
  ChatCompletionMessageDelta,
52
53
  ChatCompletionStreamChoice,
@@ -73,6 +74,11 @@ async def stream_openai_response(
73
74
  session_id: str | None = None,
74
75
  # Agent info for metadata
75
76
  agent_schema: str | None = None,
77
+ # Mutable container to capture trace context (deterministic, not AI-dependent)
78
+ trace_context_out: dict | None = None,
79
+ # Mutable container to capture tool calls for persistence
80
+ # Format: list of {"tool_name": str, "tool_id": str, "arguments": dict, "result": any}
81
+ tool_calls_out: list | None = None,
76
82
  ) -> AsyncGenerator[str, None]:
77
83
  """
78
84
  Stream Pydantic AI agent responses with rich SSE events.
@@ -143,6 +149,9 @@ async def stream_openai_response(
143
149
  pending_tool_completions: list[tuple[str, str]] = []
144
150
  # Track if metadata was registered via register_metadata tool
145
151
  metadata_registered = False
152
+ # Track pending tool calls with full data for persistence
153
+ # Maps tool_id -> {"tool_name": str, "tool_id": str, "arguments": dict}
154
+ pending_tool_data: dict[str, dict] = {}
146
155
 
147
156
  try:
148
157
  # Emit initial progress event
@@ -156,6 +165,14 @@ async def stream_openai_response(
156
165
 
157
166
  # Use agent.iter() to get complete execution with tool calls
158
167
  async with agent.iter(prompt) as agent_run:
168
+ # Capture trace context IMMEDIATELY inside agent execution
169
+ # This is deterministic - it's the OTEL context from Pydantic AI instrumentation
170
+ # NOT dependent on any AI-generated content
171
+ captured_trace_id, captured_span_id = get_current_trace_context()
172
+ if trace_context_out is not None:
173
+ trace_context_out["trace_id"] = captured_trace_id
174
+ trace_context_out["span_id"] = captured_span_id
175
+
159
176
  async for node in agent_run:
160
177
  # Check if this is a model request node (includes tool calls)
161
178
  if Agent.is_model_request_node(node):
@@ -288,6 +305,13 @@ async def stream_openai_response(
288
305
  arguments=args_dict
289
306
  ))
290
307
 
308
+ # Track tool call data for persistence (especially register_metadata)
309
+ pending_tool_data[tool_id] = {
310
+ "tool_name": tool_name,
311
+ "tool_id": tool_id,
312
+ "arguments": args_dict,
313
+ }
314
+
291
315
  # Update progress
292
316
  current_step = 2
293
317
  total_steps = 4 # Added tool execution step
@@ -366,6 +390,8 @@ async def stream_openai_response(
366
390
  registered_sources = result_content.get("sources")
367
391
  registered_references = result_content.get("references")
368
392
  registered_flags = result_content.get("flags")
393
+ # Session naming
394
+ registered_session_name = result_content.get("session_name")
369
395
  # Risk assessment fields
370
396
  registered_risk_level = result_content.get("risk_level")
371
397
  registered_risk_score = result_content.get("risk_score")
@@ -376,6 +402,7 @@ async def stream_openai_response(
376
402
 
377
403
  logger.info(
378
404
  f"📊 Metadata registered: confidence={registered_confidence}, "
405
+ f"session_name={registered_session_name}, "
379
406
  f"risk_level={registered_risk_level}, sources={registered_sources}"
380
407
  )
381
408
 
@@ -398,6 +425,7 @@ async def stream_openai_response(
398
425
  in_reply_to=in_reply_to,
399
426
  session_id=session_id,
400
427
  agent_schema=agent_schema,
428
+ session_name=registered_session_name,
401
429
  confidence=registered_confidence,
402
430
  sources=registered_sources,
403
431
  model_version=model,
@@ -406,6 +434,15 @@ async def stream_openai_response(
406
434
  hidden=False,
407
435
  ))
408
436
 
437
+ # Capture tool call with result for persistence
438
+ # Special handling for register_metadata - always capture full data
439
+ if tool_calls_out is not None and tool_id in pending_tool_data:
440
+ tool_data = pending_tool_data[tool_id]
441
+ tool_data["result"] = result_content
442
+ tool_data["is_metadata"] = is_metadata_event
443
+ tool_calls_out.append(tool_data)
444
+ del pending_tool_data[tool_id]
445
+
409
446
  if not is_metadata_event:
410
447
  # Normal tool completion - emit ToolCallEvent
411
448
  result_str = str(result_content)
@@ -528,6 +565,9 @@ async def stream_openai_response(
528
565
  model_version=model,
529
566
  latency_ms=latency_ms,
530
567
  token_count=token_count,
568
+ # Include deterministic trace context captured from OTEL
569
+ trace_id=captured_trace_id,
570
+ span_id=captured_span_id,
531
571
  ))
532
572
 
533
573
  # Mark all progress complete
@@ -699,9 +739,20 @@ async def stream_openai_response_with_save(
699
739
  from ....services.session import SessionMessageStore
700
740
  from ....settings import settings
701
741
 
742
+ # Pre-generate message_id so it can be sent in metadata event
743
+ # This allows frontend to use it for feedback before DB persistence
744
+ message_id = str(uuid.uuid4())
745
+
746
+ # Mutable container for capturing trace context from inside agent execution
747
+ # This is deterministic - captured from OTEL instrumentation, not AI-generated
748
+ trace_context: dict = {}
749
+
702
750
  # Accumulate content during streaming
703
751
  accumulated_content = []
704
752
 
753
+ # Capture tool calls for persistence (especially register_metadata)
754
+ tool_calls: list = []
755
+
705
756
  async for chunk in stream_openai_response(
706
757
  agent=agent,
707
758
  prompt=prompt,
@@ -709,6 +760,9 @@ async def stream_openai_response_with_save(
709
760
  request_id=request_id,
710
761
  agent_schema=agent_schema,
711
762
  session_id=session_id,
763
+ message_id=message_id,
764
+ trace_context_out=trace_context, # Pass container to capture trace IDs
765
+ tool_calls_out=tool_calls, # Capture tool calls for persistence
712
766
  ):
713
767
  yield chunk
714
768
 
@@ -727,22 +781,75 @@ async def stream_openai_response_with_save(
727
781
  except (json.JSONDecodeError, KeyError, IndexError):
728
782
  pass # Skip non-JSON or malformed chunks
729
783
 
730
- # After streaming completes, save the assistant response
731
- if settings.postgres.enabled and session_id and accumulated_content:
732
- full_content = "".join(accumulated_content)
733
- assistant_message = {
734
- "role": "assistant",
735
- "content": full_content,
736
- "timestamp": to_iso(utc_now()),
737
- }
738
- try:
739
- store = SessionMessageStore(user_id=user_id or settings.test.effective_user_id)
740
- await store.store_session_messages(
741
- session_id=session_id,
742
- messages=[assistant_message],
743
- user_id=user_id,
744
- compress=True, # Compress long assistant responses
745
- )
746
- logger.debug(f"Saved assistant response to session {session_id} ({len(full_content)} chars)")
747
- except Exception as e:
748
- logger.error(f"Failed to save assistant response: {e}", exc_info=True)
784
+ # After streaming completes, save tool calls and assistant response
785
+ # Note: All messages stored UNCOMPRESSED. Compression happens on reload.
786
+ if settings.postgres.enabled and session_id:
787
+ # Get captured trace context from container (deterministically captured inside agent execution)
788
+ captured_trace_id = trace_context.get("trace_id")
789
+ captured_span_id = trace_context.get("span_id")
790
+ timestamp = to_iso(utc_now())
791
+
792
+ messages_to_store = []
793
+
794
+ # First, store tool call messages (message_type: "tool")
795
+ for tool_call in tool_calls:
796
+ tool_message = {
797
+ "role": "tool",
798
+ "content": json.dumps(tool_call.get("result", {}), default=str),
799
+ "timestamp": timestamp,
800
+ "trace_id": captured_trace_id,
801
+ "span_id": captured_span_id,
802
+ # Store tool call details in a way that can be reconstructed
803
+ "tool_call_id": tool_call.get("tool_id"),
804
+ "tool_name": tool_call.get("tool_name"),
805
+ "tool_arguments": tool_call.get("arguments"),
806
+ }
807
+ messages_to_store.append(tool_message)
808
+
809
+ # Then store assistant text response (if any)
810
+ if accumulated_content:
811
+ full_content = "".join(accumulated_content)
812
+ assistant_message = {
813
+ "id": message_id, # Use pre-generated ID for consistency with metadata event
814
+ "role": "assistant",
815
+ "content": full_content,
816
+ "timestamp": timestamp,
817
+ "trace_id": captured_trace_id,
818
+ "span_id": captured_span_id,
819
+ }
820
+ messages_to_store.append(assistant_message)
821
+
822
+ if messages_to_store:
823
+ try:
824
+ store = SessionMessageStore(user_id=user_id or settings.test.effective_user_id)
825
+ await store.store_session_messages(
826
+ session_id=session_id,
827
+ messages=messages_to_store,
828
+ user_id=user_id,
829
+ compress=False, # Store uncompressed; compression happens on reload
830
+ )
831
+ logger.debug(
832
+ f"Saved {len(tool_calls)} tool calls and "
833
+ f"{'assistant response' if accumulated_content else 'no text'} "
834
+ f"to session {session_id}"
835
+ )
836
+ except Exception as e:
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
@@ -7,16 +7,64 @@ Endpoints:
7
7
  POST /api/v1/messages/feedback - Submit feedback on a message
8
8
 
9
9
  Trace Integration:
10
- - Feedback can reference trace_id/span_id for OTEL integration
11
- - Phoenix sync attaches feedback as span annotations (async)
10
+ - Feedback auto-resolves trace_id/span_id from the message in the database
11
+ - Phoenix sync attaches feedback as span annotations when trace info is available
12
+
13
+ HTTP Status Codes:
14
+ - 201: Feedback saved AND synced to Phoenix as annotation (phoenix_synced=true)
15
+ - 200: Feedback accepted and saved to DB, but NOT synced to Phoenix
16
+ (missing trace_id/span_id, Phoenix disabled, or sync failed)
17
+
18
+ IMPORTANT - Testing Requirements:
19
+ ╔════════════════════════════════════════════════════════════════════════════════════════════════════╗
20
+ ║ 1. Use 'rem' agent (NOT 'simulator') - only real agents capture traces ║
21
+ ║ 2. Session IDs MUST be UUIDs - use python3 -c "import uuid; print(uuid.uuid4())" ║
22
+ ║ 3. Port-forward OTEL collector: kubectl port-forward -n observability ║
23
+ ║ svc/otel-collector-collector 4318:4318 ║
24
+ ║ 4. Port-forward Phoenix: kubectl port-forward -n siggy svc/phoenix 6006:6006 ║
25
+ ║ 5. Set environment variables when starting the API: ║
26
+ ║ OTEL__ENABLED=true PHOENIX__ENABLED=true PHOENIX_API_KEY=<jwt> uvicorn ... ║
27
+ ║ 6. Get PHOENIX_API_KEY: ║
28
+ ║ kubectl get secret -n siggy rem-phoenix-api-key -o jsonpath='{.data.PHOENIX_API_KEY}' ║
29
+ ║ | base64 -d ║
30
+ ╚════════════════════════════════════════════════════════════════════════════════════════════════════╝
31
+
32
+ Usage:
33
+ # 1. Send a chat message with X-Session-Id header (MUST be UUID!)
34
+ SESSION_ID=$(python3 -c "import uuid; print(uuid.uuid4())")
35
+ curl -X POST http://localhost:8000/api/v1/chat/completions \\
36
+ -H "Content-Type: application/json" \\
37
+ -H "X-Session-Id: $SESSION_ID" \\
38
+ -H "X-Agent-Schema: rem" \\
39
+ -d '{"messages": [{"role": "user", "content": "hello"}], "stream": true}'
40
+
41
+ # 2. Extract message_id from the 'metadata' SSE event:
42
+ # event: metadata
43
+ # data: {"message_id": "728882f8-...", "trace_id": "e53c701c...", ...}
44
+
45
+ # 3. Submit feedback referencing that message (trace_id auto-resolved from DB)
46
+ curl -X POST http://localhost:8000/api/v1/messages/feedback \\
47
+ -H "Content-Type: application/json" \\
48
+ -H "X-Tenant-Id: default" \\
49
+ -d '{
50
+ "session_id": "'$SESSION_ID'",
51
+ "message_id": "<message-id-from-metadata>",
52
+ "rating": 1,
53
+ "categories": ["helpful"],
54
+ "comment": "Great response!"
55
+ }'
56
+
57
+ # 4. Check response:
58
+ # - 201 + phoenix_synced=true = annotation synced to Phoenix (check Phoenix UI at :6006)
59
+ # - 200 + phoenix_synced=false = feedback saved but not synced (missing trace info)
12
60
  """
13
61
 
14
- from fastapi import APIRouter, Header, HTTPException, Request
62
+ from fastapi import APIRouter, Header, HTTPException, Request, Response
15
63
  from loguru import logger
16
64
  from pydantic import BaseModel, Field
17
65
 
18
66
  from ..deps import get_user_id_from_request
19
- from ...models.entities import Feedback, Message
67
+ from ...models.entities import Feedback
20
68
  from ...services.postgres import Repository
21
69
  from ...settings import settings
22
70
 
@@ -73,9 +121,10 @@ class FeedbackResponse(BaseModel):
73
121
  # =============================================================================
74
122
 
75
123
 
76
- @router.post("/messages/feedback", response_model=FeedbackResponse, status_code=201)
124
+ @router.post("/messages/feedback", response_model=FeedbackResponse)
77
125
  async def submit_feedback(
78
126
  request: Request,
127
+ response: Response,
79
128
  request_body: FeedbackCreateRequest,
80
129
  x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
81
130
  ) -> FeedbackResponse:
@@ -89,8 +138,12 @@ async def submit_feedback(
89
138
  - Provided explicitly in the request
90
139
  - Auto-resolved from the message if message_id is provided
91
140
 
141
+ HTTP Status Codes:
142
+ - 201: Feedback saved AND synced to Phoenix (phoenix_synced=true)
143
+ - 200: Feedback accepted but NOT synced (missing trace info, disabled, or failed)
144
+
92
145
  Returns:
93
- Created feedback object
146
+ Created feedback object with phoenix_synced indicating sync status
94
147
  """
95
148
  if not settings.postgres.enabled:
96
149
  raise HTTPException(status_code=503, detail="Database not enabled")
@@ -102,11 +155,44 @@ async def submit_feedback(
102
155
  span_id = request_body.span_id
103
156
 
104
157
  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
158
+ # Look up message by ID to get trace context
159
+ # Note: Messages are stored with tenant_id=user_id (not x_tenant_id header)
160
+ # so we query by ID only - UUIDs are globally unique
161
+ from ...services.postgres import PostgresService
162
+ import uuid
163
+
164
+ logger.info(f"Looking up trace context for message_id={request_body.message_id}")
165
+
166
+ # Convert message_id string to UUID for database query
167
+ try:
168
+ message_uuid = uuid.UUID(request_body.message_id)
169
+ except ValueError as e:
170
+ logger.warning(f"Invalid message_id format '{request_body.message_id}': {e}")
171
+ message_uuid = None
172
+
173
+ if message_uuid:
174
+ db = PostgresService()
175
+ # Ensure connection (same pattern as Repository)
176
+ if not db.pool:
177
+ await db.connect()
178
+
179
+ if db.pool:
180
+ query = """
181
+ SELECT trace_id, span_id FROM messages
182
+ WHERE id = $1 AND deleted_at IS NULL
183
+ LIMIT 1
184
+ """
185
+ async with db.pool.acquire() as conn:
186
+ row = await conn.fetchrow(query, message_uuid)
187
+ logger.info(f"Database query result for message {request_body.message_id}: row={row}")
188
+ if row:
189
+ trace_id = trace_id or row["trace_id"]
190
+ span_id = span_id or row["span_id"]
191
+ logger.info(f"Found trace context for message {request_body.message_id}: trace_id={trace_id}, span_id={span_id}")
192
+ else:
193
+ logger.warning(f"No message found in database with id={request_body.message_id}")
194
+ else:
195
+ logger.warning(f"Database pool not available for message lookup after connect attempt")
110
196
 
111
197
  feedback = Feedback(
112
198
  session_id=request_body.session_id,
@@ -130,9 +216,43 @@ async def submit_feedback(
130
216
  f"message={request_body.message_id}, rating={request_body.rating}"
131
217
  )
132
218
 
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}")
219
+ # Sync to Phoenix if trace_id/span_id available and Phoenix is enabled
220
+ phoenix_synced = False
221
+ phoenix_annotation_id = None
222
+
223
+ if trace_id and span_id and settings.phoenix.enabled:
224
+ try:
225
+ from ...services.phoenix import PhoenixClient
226
+
227
+ phoenix_client = PhoenixClient()
228
+ phoenix_annotation_id = phoenix_client.sync_user_feedback(
229
+ span_id=span_id,
230
+ rating=request_body.rating,
231
+ categories=request_body.categories,
232
+ comment=request_body.comment,
233
+ feedback_id=str(result.id),
234
+ trace_id=trace_id,
235
+ )
236
+
237
+ if phoenix_annotation_id:
238
+ phoenix_synced = True
239
+ # Update the feedback record with sync status
240
+ result.phoenix_synced = True
241
+ result.phoenix_annotation_id = phoenix_annotation_id
242
+ await repo.upsert(result)
243
+ logger.info(f"Feedback synced to Phoenix: annotation_id={phoenix_annotation_id}")
244
+ else:
245
+ logger.warning(f"Phoenix sync returned no annotation ID for feedback {result.id}")
246
+
247
+ except Exception as e:
248
+ logger.error(f"Failed to sync feedback to Phoenix: {e}")
249
+ # Don't fail the request if Phoenix sync fails
250
+ elif trace_id and span_id:
251
+ logger.debug(f"Feedback has trace info but Phoenix disabled: trace={trace_id}, span={span_id}")
252
+
253
+ # Set HTTP status code based on Phoenix sync result
254
+ # 201 = synced to Phoenix, 200 = accepted but not synced
255
+ response.status_code = 201 if phoenix_synced else 200
136
256
 
137
257
  return FeedbackResponse(
138
258
  id=str(result.id),
@@ -134,7 +134,6 @@ async def list_messages(
134
134
  ),
135
135
  limit: int = Query(default=50, ge=1, le=100, description="Max results to return"),
136
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
137
  ) -> MessageListResponse:
139
138
  """
140
139
  List messages with optional filters.
@@ -158,15 +157,18 @@ async def list_messages(
158
157
 
159
158
  repo = Repository(Message, table_name="messages")
160
159
 
160
+ # Get current user for logging
161
+ current_user = get_current_user(request)
162
+ jwt_user_id = current_user.get("id") if current_user else None
163
+
161
164
  # If mine=true, force filter to current user's ID from JWT
162
165
  effective_user_id = user_id
163
166
  if mine:
164
- current_user = get_current_user(request)
165
167
  if current_user:
166
168
  effective_user_id = current_user.get("id")
167
169
 
168
170
  # 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)
171
+ filters = await get_user_filter(request, x_user_id=effective_user_id)
170
172
 
171
173
  # Apply optional filters
172
174
  if session_id:
@@ -174,6 +176,13 @@ async def list_messages(
174
176
  if message_type:
175
177
  filters["message_type"] = message_type
176
178
 
179
+ # Log the query parameters for debugging
180
+ logger.debug(
181
+ f"[messages] Query: session_id={session_id} | "
182
+ f"jwt_user_id={jwt_user_id} | "
183
+ f"filters={filters}"
184
+ )
185
+
177
186
  # For date filtering, we need custom SQL (not supported by basic Repository)
178
187
  # For now, fetch all matching base filters and filter in Python
179
188
  # TODO: Extend Repository to support date range filters
@@ -206,6 +215,12 @@ async def list_messages(
206
215
  # Get total count for pagination info
207
216
  total = await repo.count(filters)
208
217
 
218
+ # Log result count
219
+ logger.debug(
220
+ f"[messages] Result: returned={len(messages)} | total={total} | "
221
+ f"session_id={session_id}"
222
+ )
223
+
209
224
  return MessageListResponse(data=messages, total=total, has_more=has_more)
210
225
 
211
226
 
@@ -213,7 +228,6 @@ async def list_messages(
213
228
  async def get_message(
214
229
  request: Request,
215
230
  message_id: str,
216
- x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
217
231
  ) -> Message:
218
232
  """
219
233
  Get a specific message by ID.
@@ -236,7 +250,7 @@ async def get_message(
236
250
  raise HTTPException(status_code=503, detail="Database not enabled")
237
251
 
238
252
  repo = Repository(Message, table_name="messages")
239
- message = await repo.get_by_id(message_id, x_tenant_id)
253
+ message = await repo.get_by_id(message_id)
240
254
 
241
255
  if not message:
242
256
  raise HTTPException(status_code=404, detail=f"Message '{message_id}' not found")
@@ -263,7 +277,6 @@ async def list_sessions(
263
277
  mode: SessionMode | None = Query(default=None, description="Filter by session mode"),
264
278
  page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
265
279
  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
280
  ) -> SessionsQueryResponse:
268
281
  """
269
282
  List sessions with optional filters and page-based pagination.
@@ -288,7 +301,7 @@ async def list_sessions(
288
301
  repo = Repository(Session, table_name="sessions")
289
302
 
290
303
  # 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)
304
+ filters = await get_user_filter(request, x_user_id=user_id)
292
305
  if mode:
293
306
  filters["mode"] = mode.value
294
307
 
@@ -319,7 +332,6 @@ async def create_session(
319
332
  request_body: SessionCreateRequest,
320
333
  user: dict = Depends(require_admin),
321
334
  x_user_id: str = Header(alias="X-User-Id", default="default"),
322
- x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
323
335
  ) -> Session:
324
336
  """
325
337
  Create a new session.
@@ -334,7 +346,6 @@ async def create_session(
334
346
 
335
347
  Headers:
336
348
  - X-User-Id: User identifier (owner of the session)
337
- - X-Tenant-Id: Tenant identifier
338
349
 
339
350
  Returns:
340
351
  Created session object
@@ -354,7 +365,7 @@ async def create_session(
354
365
  prompt=request_body.prompt,
355
366
  agent_schema_uri=request_body.agent_schema_uri,
356
367
  user_id=effective_user_id,
357
- tenant_id=x_tenant_id,
368
+ tenant_id="default", # tenant_id not used for filtering, set to default
358
369
  )
359
370
 
360
371
  repo = Repository(Session, table_name="sessions")
@@ -372,7 +383,6 @@ async def create_session(
372
383
  async def get_session(
373
384
  request: Request,
374
385
  session_id: str,
375
- x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
376
386
  ) -> Session:
377
387
  """
378
388
  Get a specific session by ID.
@@ -395,11 +405,11 @@ async def get_session(
395
405
  raise HTTPException(status_code=503, detail="Database not enabled")
396
406
 
397
407
  repo = Repository(Session, table_name="sessions")
398
- session = await repo.get_by_id(session_id, x_tenant_id)
408
+ session = await repo.get_by_id(session_id)
399
409
 
400
410
  if not session:
401
411
  # Try finding by name
402
- sessions = await repo.find({"name": session_id, "tenant_id": x_tenant_id}, limit=1)
412
+ sessions = await repo.find({"name": session_id}, limit=1)
403
413
  if sessions:
404
414
  session = sessions[0]
405
415
  else:
@@ -420,7 +430,6 @@ async def update_session(
420
430
  request: Request,
421
431
  session_id: str,
422
432
  request_body: SessionUpdateRequest,
423
- x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
424
433
  ) -> Session:
425
434
  """
426
435
  Update an existing session.
@@ -450,7 +459,7 @@ async def update_session(
450
459
  raise HTTPException(status_code=503, detail="Database not enabled")
451
460
 
452
461
  repo = Repository(Session, table_name="sessions")
453
- session = await repo.get_by_id(session_id, x_tenant_id)
462
+ session = await repo.get_by_id(session_id)
454
463
 
455
464
  if not session:
456
465
  raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
rem/api/routers/query.py CHANGED
@@ -216,7 +216,7 @@ class QueryResponse(BaseModel):
216
216
  @router.post("/query", response_model=QueryResponse)
217
217
  async def execute_query(
218
218
  request: QueryRequest,
219
- x_user_id: str = Header(..., description="User ID for query isolation"),
219
+ x_user_id: str | None = Header(default=None, description="User ID for query isolation (optional, uses default if not provided)"),
220
220
  ) -> QueryResponse:
221
221
  """
222
222
  Execute a REM query.
@@ -265,6 +265,9 @@ async def execute_query(
265
265
 
266
266
  rem_service = RemService(db)
267
267
 
268
+ # Use effective_user_id from settings if not provided
269
+ effective_user_id = x_user_id or settings.test.effective_user_id
270
+
268
271
  if request.mode == QueryMode.STAGED_PLAN:
269
272
  # Staged plan mode - execute multi-stage query plan
270
273
  # TODO: Implementation pending in RemService.execute_staged_plan()
@@ -295,7 +298,7 @@ async def execute_query(
295
298
 
296
299
  result = await rem_service.ask_rem(
297
300
  natural_query=request.query,
298
- tenant_id=x_user_id,
301
+ tenant_id=effective_user_id,
299
302
  llm_model=request.model,
300
303
  plan_mode=request.plan_only,
301
304
  )
@@ -333,7 +336,7 @@ async def execute_query(
333
336
  rem_query = RemQuery.model_validate({
334
337
  "query_type": query_type,
335
338
  "parameters": parameters,
336
- "user_id": x_user_id,
339
+ "user_id": effective_user_id,
337
340
  })
338
341
 
339
342
  result = await rem_service.execute_query(rem_query)
rem/auth/__init__.py CHANGED
@@ -1,26 +1,36 @@
1
1
  """
2
2
  REM Authentication Module.
3
3
 
4
- OAuth 2.1 compliant authentication with support for:
4
+ Authentication with support for:
5
+ - Email passwordless login (verification codes)
5
6
  - Google OAuth
6
7
  - Microsoft Entra ID (Azure AD) OIDC
7
8
  - Custom OIDC providers
8
9
 
9
10
  Design Pattern:
10
11
  - Provider-agnostic base classes
11
- - PKCE (Proof Key for Code Exchange) for all flows
12
+ - PKCE (Proof Key for Code Exchange) for OAuth flows
12
13
  - State parameter for CSRF protection
13
14
  - Nonce for ID token replay protection
14
15
  - Token validation with JWKS
15
- - Clean separation: providers/ for OAuth logic, middleware.py for FastAPI integration
16
+ - Clean separation: providers/ for auth logic, middleware.py for FastAPI integration
17
+
18
+ Email Auth Flow:
19
+ 1. POST /api/auth/email/send-code with {email}
20
+ 2. User receives code via email
21
+ 3. POST /api/auth/email/verify with {email, code}
22
+ 4. Session created, user authenticated
16
23
  """
17
24
 
18
25
  from .providers.base import OAuthProvider
26
+ from .providers.email import EmailAuthProvider, EmailAuthResult
19
27
  from .providers.google import GoogleOAuthProvider
20
28
  from .providers.microsoft import MicrosoftOAuthProvider
21
29
 
22
30
  __all__ = [
23
31
  "OAuthProvider",
32
+ "EmailAuthProvider",
33
+ "EmailAuthResult",
24
34
  "GoogleOAuthProvider",
25
35
  "MicrosoftOAuthProvider",
26
36
  ]