remdb 0.3.141__py3-none-any.whl → 0.3.163__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 (44) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +310 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +18 -3
  5. rem/api/deps.py +3 -5
  6. rem/api/main.py +22 -3
  7. rem/api/mcp_router/server.py +2 -0
  8. rem/api/mcp_router/tools.py +90 -0
  9. rem/api/middleware/tracking.py +5 -5
  10. rem/api/routers/auth.py +346 -5
  11. rem/api/routers/chat/completions.py +4 -2
  12. rem/api/routers/chat/streaming.py +77 -22
  13. rem/api/routers/messages.py +24 -15
  14. rem/auth/__init__.py +13 -3
  15. rem/auth/jwt.py +352 -0
  16. rem/auth/middleware.py +108 -6
  17. rem/auth/providers/__init__.py +4 -1
  18. rem/auth/providers/email.py +215 -0
  19. rem/cli/commands/experiments.py +32 -46
  20. rem/models/core/experiment.py +4 -14
  21. rem/models/entities/__init__.py +4 -0
  22. rem/models/entities/subscriber.py +175 -0
  23. rem/models/entities/user.py +1 -0
  24. rem/schemas/agents/core/agent-builder.yaml +134 -0
  25. rem/services/__init__.py +3 -1
  26. rem/services/content/service.py +4 -3
  27. rem/services/email/__init__.py +10 -0
  28. rem/services/email/service.py +511 -0
  29. rem/services/email/templates.py +360 -0
  30. rem/services/postgres/README.md +38 -0
  31. rem/services/postgres/diff_service.py +19 -3
  32. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  33. rem/services/postgres/repository.py +5 -4
  34. rem/services/session/compression.py +113 -50
  35. rem/services/session/reload.py +14 -7
  36. rem/services/user_service.py +29 -0
  37. rem/settings.py +199 -4
  38. rem/sql/migrations/005_schema_update.sql +145 -0
  39. rem/utils/README.md +45 -0
  40. rem/utils/files.py +157 -1
  41. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/METADATA +7 -5
  42. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/RECORD +44 -35
  43. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/WHEEL +0 -0
  44. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/entry_points.txt +0 -0
@@ -1040,3 +1040,93 @@ async def get_schema(
1040
1040
  logger.info(f"Retrieved schema for table '{table_name}' with {len(column_defs)} columns")
1041
1041
 
1042
1042
  return result
1043
+
1044
+
1045
+ @mcp_tool_error_handler
1046
+ async def save_agent(
1047
+ name: str,
1048
+ description: str,
1049
+ properties: dict[str, Any] | None = None,
1050
+ required: list[str] | None = None,
1051
+ tools: list[str] | None = None,
1052
+ tags: list[str] | None = None,
1053
+ version: str = "1.0.0",
1054
+ user_id: str | None = None,
1055
+ ) -> dict[str, Any]:
1056
+ """
1057
+ Save an agent schema to REM, making it available for use.
1058
+
1059
+ This tool creates or updates an agent definition in the user's schema space.
1060
+ The agent becomes immediately available for conversations.
1061
+
1062
+ **Default Tools**: All agents automatically get `search_rem` and `register_metadata`
1063
+ tools unless explicitly overridden.
1064
+
1065
+ Args:
1066
+ name: Agent name in kebab-case (e.g., "code-reviewer", "sales-assistant").
1067
+ Must be unique within the user's schema space.
1068
+ description: The agent's system prompt. This is the full instruction set
1069
+ that defines the agent's behavior, personality, and capabilities.
1070
+ Use markdown formatting for structure.
1071
+ properties: Output schema properties as a dict. Each property should have:
1072
+ - type: "string", "number", "boolean", "array", "object"
1073
+ - description: What this field captures
1074
+ Example: {"answer": {"type": "string", "description": "Response to user"}}
1075
+ If not provided, defaults to a simple {"answer": {"type": "string"}} schema.
1076
+ required: List of required property names. Defaults to ["answer"] if not provided.
1077
+ tools: List of tool names the agent can use. Defaults to ["search_rem", "register_metadata"].
1078
+ tags: Optional tags for categorizing the agent.
1079
+ version: Semantic version string (default: "1.0.0").
1080
+ user_id: User identifier for scoping. Uses authenticated user if not provided.
1081
+
1082
+ Returns:
1083
+ Dict with:
1084
+ - status: "success" or "error"
1085
+ - agent_name: Name of the saved agent
1086
+ - version: Version saved
1087
+ - message: Human-readable status
1088
+
1089
+ Examples:
1090
+ # Create a simple agent
1091
+ save_agent(
1092
+ name="greeting-bot",
1093
+ description="You are a friendly greeter. Say hello warmly.",
1094
+ properties={"answer": {"type": "string", "description": "Greeting message"}},
1095
+ required=["answer"]
1096
+ )
1097
+
1098
+ # Create agent with structured output
1099
+ save_agent(
1100
+ name="sentiment-analyzer",
1101
+ description="Analyze sentiment of text provided by the user.",
1102
+ properties={
1103
+ "answer": {"type": "string", "description": "Analysis explanation"},
1104
+ "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
1105
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1}
1106
+ },
1107
+ required=["answer", "sentiment"],
1108
+ tags=["analysis", "nlp"]
1109
+ )
1110
+ """
1111
+ from ...agentic.agents.agent_manager import save_agent as _save_agent
1112
+
1113
+ # Get user_id from context if not provided
1114
+ user_id = AgentContext.get_user_id_or_default(user_id, source="save_agent")
1115
+
1116
+ # Delegate to agent_manager
1117
+ result = await _save_agent(
1118
+ name=name,
1119
+ description=description,
1120
+ user_id=user_id,
1121
+ properties=properties,
1122
+ required=required,
1123
+ tools=tools,
1124
+ tags=tags,
1125
+ version=version,
1126
+ )
1127
+
1128
+ # Add helpful message for Slack users
1129
+ if result.get("status") == "success":
1130
+ result["message"] = f"Agent '{name}' saved. Use `/custom-agent {name}` to chat with it."
1131
+
1132
+ return result
@@ -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
@@ -1,20 +1,68 @@
1
1
  """
2
- OAuth 2.1 Authentication Router.
2
+ Authentication Router.
3
3
 
4
- Leverages Authlib for standards-compliant OAuth/OIDC implementation.
5
- Minimal custom code - Authlib handles PKCE, token validation, JWKS.
4
+ Supports multiple authentication methods:
5
+ 1. Email (passwordless): POST /api/auth/email/send-code, POST /api/auth/email/verify
6
+ 2. OAuth (Google, Microsoft): GET /api/auth/{provider}/login, GET /api/auth/{provider}/callback
6
7
 
7
8
  Endpoints:
9
+ - POST /api/auth/email/send-code - Send login code to email
10
+ - POST /api/auth/email/verify - Verify code and create session
8
11
  - GET /api/auth/{provider}/login - Initiate OAuth flow
9
12
  - GET /api/auth/{provider}/callback - OAuth callback
10
13
  - POST /api/auth/logout - Clear session
11
14
  - GET /api/auth/me - Current user info
12
15
 
13
16
  Supported providers:
17
+ - email: Passwordless email login
14
18
  - google: Google OAuth 2.0 / OIDC
15
19
  - microsoft: Microsoft Entra ID OIDC
16
20
 
17
- Design Pattern (OAuth 2.1 + PKCE):
21
+ =============================================================================
22
+ Email Authentication Access Control
23
+ =============================================================================
24
+
25
+ The email auth provider implements a tiered access control system:
26
+
27
+ Access Control Flow (send-code):
28
+ User requests login code
29
+ ├── User exists in database?
30
+ │ ├── Yes → Check user.tier
31
+ │ │ ├── tier == BLOCKED → Reject "Account is blocked"
32
+ │ │ └── tier != BLOCKED → Allow (send code, existing users grandfathered)
33
+ │ └── No (new user) → Check EMAIL__TRUSTED_EMAIL_DOMAINS
34
+ │ ├── Setting configured → domain in trusted list?
35
+ │ │ ├── Yes → Create user & send code
36
+ │ │ └── No → Reject "Email domain not allowed for signup"
37
+ │ └── Not configured (empty) → Create user & send code (no restrictions)
38
+
39
+ Key Behaviors:
40
+ - Existing users: Always allowed to login (unless tier=BLOCKED)
41
+ - New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
42
+ - No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
43
+
44
+ User Tiers (models.entities.UserTier):
45
+ - BLOCKED: Cannot login (rejected at send-code)
46
+ - ANONYMOUS: Rate-limited anonymous access
47
+ - FREE: Standard free tier
48
+ - BASIC/PRO: Paid tiers with additional features
49
+
50
+ Configuration:
51
+ # Allow only specific domains for new signups
52
+ EMAIL__TRUSTED_EMAIL_DOMAINS=siggymd.ai,example.com
53
+
54
+ # Allow all domains (no restrictions)
55
+ EMAIL__TRUSTED_EMAIL_DOMAINS=
56
+
57
+ Example blocking a user:
58
+ user = await user_repo.get_by_id(user_id, tenant_id="default")
59
+ user.tier = UserTier.BLOCKED
60
+ await user_repo.upsert(user)
61
+
62
+ =============================================================================
63
+ OAuth Design Pattern (OAuth 2.1 + PKCE)
64
+ =============================================================================
65
+
18
66
  1. User clicks "Login with Google"
19
67
  2. /login generates state + PKCE code_verifier
20
68
  3. Store code_verifier in session
@@ -37,6 +85,7 @@ Environment variables:
37
85
  AUTH__MICROSOFT__CLIENT_ID=<microsoft-client-id>
38
86
  AUTH__MICROSOFT__CLIENT_SECRET=<microsoft-client-secret>
39
87
  AUTH__MICROSOFT__TENANT=common
88
+ EMAIL__TRUSTED_EMAIL_DOMAINS=example.com # Optional: restrict new signups
40
89
 
41
90
  References:
42
91
  - Authlib: https://docs.authlib.org/en/latest/
@@ -46,11 +95,14 @@ References:
46
95
  from fastapi import APIRouter, HTTPException, Request
47
96
  from fastapi.responses import RedirectResponse
48
97
  from authlib.integrations.starlette_client import OAuth
98
+ from pydantic import BaseModel, EmailStr
49
99
  from loguru import logger
50
100
 
51
101
  from ...settings import settings
52
102
  from ...services.postgres.service import PostgresService
53
103
  from ...services.user_service import UserService
104
+ from ...auth.providers.email import EmailAuthProvider
105
+ from ...auth.jwt import JWTService, get_jwt_service
54
106
 
55
107
  router = APIRouter(prefix="/api/auth", tags=["auth"])
56
108
 
@@ -87,6 +139,182 @@ if settings.auth.microsoft.client_id:
87
139
  logger.info(f"Microsoft OAuth provider registered (tenant: {tenant})")
88
140
 
89
141
 
142
+ # =============================================================================
143
+ # Email Authentication Endpoints
144
+ # =============================================================================
145
+
146
+
147
+ class EmailSendCodeRequest(BaseModel):
148
+ """Request to send login code."""
149
+ email: EmailStr
150
+
151
+
152
+ class EmailVerifyRequest(BaseModel):
153
+ """Request to verify login code."""
154
+ email: EmailStr
155
+ code: str
156
+
157
+
158
+ @router.post("/email/send-code")
159
+ async def send_email_code(request: Request, body: EmailSendCodeRequest):
160
+ """
161
+ Send a login code to an email address.
162
+
163
+ Creates user if not exists (using deterministic UUID from email).
164
+ Stores code in user metadata with expiry.
165
+
166
+ Args:
167
+ request: FastAPI request
168
+ body: EmailSendCodeRequest with email
169
+
170
+ Returns:
171
+ Success status and message
172
+ """
173
+ if not settings.email.is_configured:
174
+ raise HTTPException(
175
+ status_code=501,
176
+ detail="Email authentication is not configured"
177
+ )
178
+
179
+ # Get database connection
180
+ if not settings.postgres.enabled:
181
+ raise HTTPException(
182
+ status_code=501,
183
+ detail="Database is required for email authentication"
184
+ )
185
+
186
+ db = PostgresService()
187
+ try:
188
+ await db.connect()
189
+
190
+ # Initialize email auth provider
191
+ email_auth = EmailAuthProvider()
192
+
193
+ # Send code
194
+ result = await email_auth.send_code(
195
+ email=body.email,
196
+ db=db,
197
+ )
198
+
199
+ if result.success:
200
+ return {
201
+ "success": True,
202
+ "message": result.message,
203
+ "email": result.email,
204
+ }
205
+ else:
206
+ raise HTTPException(
207
+ status_code=400,
208
+ detail=result.message or result.error
209
+ )
210
+
211
+ except HTTPException:
212
+ raise
213
+ except Exception as e:
214
+ logger.error(f"Error sending login code: {e}")
215
+ raise HTTPException(status_code=500, detail="Failed to send login code")
216
+ finally:
217
+ await db.disconnect()
218
+
219
+
220
+ @router.post("/email/verify")
221
+ async def verify_email_code(request: Request, body: EmailVerifyRequest):
222
+ """
223
+ Verify login code and create session with JWT tokens.
224
+
225
+ Args:
226
+ request: FastAPI request
227
+ body: EmailVerifyRequest with email and code
228
+
229
+ Returns:
230
+ Success status with user info and JWT tokens
231
+ """
232
+ if not settings.email.is_configured:
233
+ raise HTTPException(
234
+ status_code=501,
235
+ detail="Email authentication is not configured"
236
+ )
237
+
238
+ if not settings.postgres.enabled:
239
+ raise HTTPException(
240
+ status_code=501,
241
+ detail="Database is required for email authentication"
242
+ )
243
+
244
+ db = PostgresService()
245
+ try:
246
+ await db.connect()
247
+
248
+ # Initialize email auth provider
249
+ email_auth = EmailAuthProvider()
250
+
251
+ # Verify code
252
+ result = await email_auth.verify_code(
253
+ email=body.email,
254
+ code=body.code,
255
+ db=db,
256
+ )
257
+
258
+ if not result.success:
259
+ raise HTTPException(
260
+ status_code=400,
261
+ detail=result.message or result.error
262
+ )
263
+
264
+ # Create session - compatible with OAuth session format
265
+ user_dict = email_auth.get_user_dict(
266
+ email=result.email,
267
+ user_id=result.user_id,
268
+ )
269
+
270
+ # Fetch actual user data from database to get role/tier
271
+ user_service = UserService(db)
272
+ try:
273
+ user_entity = await user_service.get_user_by_id(result.user_id)
274
+ if user_entity:
275
+ # Override defaults with actual database values
276
+ user_dict["role"] = user_entity.role or "user"
277
+ user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
278
+ user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
279
+ user_dict["name"] = user_entity.name or user_dict["name"]
280
+ except Exception as e:
281
+ logger.warning(f"Could not fetch user details: {e}")
282
+ # Continue with defaults from get_user_dict
283
+
284
+ # Generate JWT tokens
285
+ jwt_service = get_jwt_service()
286
+ tokens = jwt_service.create_tokens(user_dict)
287
+
288
+ # Store user in session (for backward compatibility)
289
+ request.session["user"] = user_dict
290
+
291
+ logger.info(f"User authenticated via email: {result.email}")
292
+
293
+ return {
294
+ "success": True,
295
+ "message": result.message,
296
+ "user": user_dict,
297
+ # JWT tokens for stateless auth
298
+ "access_token": tokens["access_token"],
299
+ "refresh_token": tokens["refresh_token"],
300
+ "token_type": tokens["token_type"],
301
+ "expires_in": tokens["expires_in"],
302
+ }
303
+
304
+ except HTTPException:
305
+ raise
306
+ except Exception as e:
307
+ logger.error(f"Error verifying login code: {e}")
308
+ raise HTTPException(status_code=500, detail="Failed to verify login code")
309
+ finally:
310
+ await db.disconnect()
311
+
312
+
313
+ # =============================================================================
314
+ # OAuth Authentication Endpoints
315
+ # =============================================================================
316
+
317
+
90
318
  @router.get("/{provider}/login")
91
319
  async def login(provider: str, request: Request):
92
320
  """
@@ -268,7 +496,7 @@ async def logout(request: Request):
268
496
  @router.get("/me")
269
497
  async def me(request: Request):
270
498
  """
271
- Get current user information from session.
499
+ Get current user information from session or JWT.
272
500
 
273
501
  Args:
274
502
  request: FastAPI request
@@ -276,6 +504,16 @@ async def me(request: Request):
276
504
  Returns:
277
505
  User information or 401 if not authenticated
278
506
  """
507
+ # First check for JWT in Authorization header
508
+ auth_header = request.headers.get("Authorization")
509
+ if auth_header and auth_header.startswith("Bearer "):
510
+ token = auth_header[7:]
511
+ jwt_service = get_jwt_service()
512
+ user = jwt_service.verify_token(token)
513
+ if user:
514
+ return user
515
+
516
+ # Fall back to session
279
517
  user = request.session.get("user")
280
518
  if not user:
281
519
  raise HTTPException(status_code=401, detail="Not authenticated")
@@ -283,6 +521,69 @@ async def me(request: Request):
283
521
  return user
284
522
 
285
523
 
524
+ # =============================================================================
525
+ # JWT Token Endpoints
526
+ # =============================================================================
527
+
528
+
529
+ class TokenRefreshRequest(BaseModel):
530
+ """Request to refresh access token."""
531
+ refresh_token: str
532
+
533
+
534
+ @router.post("/token/refresh")
535
+ async def refresh_token(body: TokenRefreshRequest):
536
+ """
537
+ Refresh access token using refresh token.
538
+
539
+ Args:
540
+ body: TokenRefreshRequest with refresh_token
541
+
542
+ Returns:
543
+ New access token or 401 if refresh token is invalid
544
+ """
545
+ jwt_service = get_jwt_service()
546
+ result = jwt_service.refresh_access_token(body.refresh_token)
547
+
548
+ if not result:
549
+ raise HTTPException(
550
+ status_code=401,
551
+ detail="Invalid or expired refresh token"
552
+ )
553
+
554
+ return result
555
+
556
+
557
+ @router.post("/token/verify")
558
+ async def verify_token(request: Request):
559
+ """
560
+ Verify an access token is valid.
561
+
562
+ Pass the token in the Authorization header: Bearer <token>
563
+
564
+ Returns:
565
+ User info if valid, 401 if invalid
566
+ """
567
+ auth_header = request.headers.get("Authorization")
568
+ if not auth_header or not auth_header.startswith("Bearer "):
569
+ raise HTTPException(
570
+ status_code=401,
571
+ detail="Missing Authorization header"
572
+ )
573
+
574
+ token = auth_header[7:]
575
+ jwt_service = get_jwt_service()
576
+ user = jwt_service.verify_token(token)
577
+
578
+ if not user:
579
+ raise HTTPException(
580
+ status_code=401,
581
+ detail="Invalid or expired token"
582
+ )
583
+
584
+ return {"valid": True, "user": user}
585
+
586
+
286
587
  # =============================================================================
287
588
  # Development Token Endpoints (non-production only)
288
589
  # =============================================================================
@@ -351,3 +652,43 @@ async def get_dev_token(request: Request):
351
652
  "usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
352
653
  "warning": "This token is for development/testing only and will not work in production.",
353
654
  }
655
+
656
+
657
+ @router.get("/dev/mock-code/{email}")
658
+ async def get_mock_code(email: str, request: Request):
659
+ """
660
+ Get the mock login code for testing (non-production only).
661
+
662
+ This endpoint retrieves the code that was "sent" via email in mock mode.
663
+ Use this for automated testing without real email delivery.
664
+
665
+ Usage:
666
+ 1. POST /api/auth/email/send-code with email
667
+ 2. GET /api/auth/dev/mock-code/{email} to retrieve the code
668
+ 3. POST /api/auth/email/verify with email and code
669
+
670
+ Returns:
671
+ 401 if in production environment
672
+ 404 if no code found for the email
673
+ The code and email otherwise
674
+ """
675
+ if settings.environment == "production":
676
+ raise HTTPException(
677
+ status_code=401,
678
+ detail="Mock codes are not available in production"
679
+ )
680
+
681
+ from ...services.email import EmailService
682
+
683
+ code = EmailService.get_mock_code(email)
684
+ if not code:
685
+ raise HTTPException(
686
+ status_code=404,
687
+ detail=f"No mock code found for {email}. Send a code first."
688
+ )
689
+
690
+ return {
691
+ "email": email,
692
+ "code": code,
693
+ "warning": "This endpoint is for testing only and will not work in production.",
694
+ }
@@ -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)")