remdb 0.3.157__py3-none-any.whl → 0.3.180__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 +43 -14
- rem/agentic/providers/pydantic_ai.py +76 -34
- rem/agentic/schema.py +4 -3
- rem/agentic/tools/rem_tools.py +11 -0
- rem/api/deps.py +1 -3
- rem/api/main.py +21 -2
- rem/api/mcp_router/resources.py +75 -14
- rem/api/mcp_router/server.py +27 -24
- rem/api/mcp_router/tools.py +83 -2
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/auth.py +152 -10
- 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/cli/commands/ask.py +1 -1
- rem/cli/commands/db.py +98 -44
- rem/models/entities/ontology.py +93 -101
- rem/schemas/agents/core/agent-builder.yaml +143 -42
- rem/services/email/service.py +72 -9
- rem/services/postgres/register_type.py +1 -1
- rem/services/postgres/repository.py +5 -4
- rem/services/user_service.py +41 -9
- rem/settings.py +15 -1
- rem/sql/background_indexes.sql +5 -0
- rem/sql/migrations/001_install.sql +33 -4
- rem/sql/migrations/002_install_models.sql +186 -168
- rem/utils/model_helpers.py +101 -0
- rem/utils/schema_loader.py +45 -7
- {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/METADATA +1 -1
- {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/RECORD +37 -36
- {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/WHEEL +0 -0
- {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/entry_points.txt +0 -0
rem/api/mcp_router/resources.py
CHANGED
|
@@ -349,6 +349,53 @@ def register_agent_resources(mcp: FastMCP):
|
|
|
349
349
|
except Exception as e:
|
|
350
350
|
return f"# Available Agents\n\nError listing agents: {e}"
|
|
351
351
|
|
|
352
|
+
@mcp.resource("rem://agents/{agent_name}")
|
|
353
|
+
def get_agent_schema(agent_name: str) -> str:
|
|
354
|
+
"""
|
|
355
|
+
Get a specific agent schema by name.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
agent_name: Name of the agent (e.g., "ask_rem", "agent-builder")
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Full agent schema as YAML string, or error message if not found.
|
|
362
|
+
"""
|
|
363
|
+
import importlib.resources
|
|
364
|
+
import yaml
|
|
365
|
+
from pathlib import Path
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
# Find packaged agent schemas
|
|
369
|
+
agents_ref = importlib.resources.files("rem") / "schemas" / "agents"
|
|
370
|
+
agents_dir = Path(str(agents_ref))
|
|
371
|
+
|
|
372
|
+
if not agents_dir.exists():
|
|
373
|
+
return f"# Agent Not Found\n\nNo agent schemas directory found."
|
|
374
|
+
|
|
375
|
+
# Search for agent file (try multiple extensions)
|
|
376
|
+
for ext in [".yaml", ".yml", ".json"]:
|
|
377
|
+
# Try exact match first
|
|
378
|
+
agent_file = agents_dir / f"{agent_name}{ext}"
|
|
379
|
+
if agent_file.exists():
|
|
380
|
+
with open(agent_file, "r") as f:
|
|
381
|
+
content = f.read()
|
|
382
|
+
return f"# Agent Schema: {agent_name}\n\n```yaml\n{content}\n```"
|
|
383
|
+
|
|
384
|
+
# Try recursive search
|
|
385
|
+
matches = list(agents_dir.rglob(f"{agent_name}{ext}"))
|
|
386
|
+
if matches:
|
|
387
|
+
with open(matches[0], "r") as f:
|
|
388
|
+
content = f.read()
|
|
389
|
+
return f"# Agent Schema: {agent_name}\n\n```yaml\n{content}\n```"
|
|
390
|
+
|
|
391
|
+
# Not found - list available agents
|
|
392
|
+
available = [f.stem for f in agents_dir.rglob("*.yaml")] + \
|
|
393
|
+
[f.stem for f in agents_dir.rglob("*.yml")]
|
|
394
|
+
return f"# Agent Not Found\n\nAgent '{agent_name}' not found.\n\nAvailable agents: {', '.join(sorted(set(available)))}"
|
|
395
|
+
|
|
396
|
+
except Exception as e:
|
|
397
|
+
return f"# Error\n\nError loading agent '{agent_name}': {e}"
|
|
398
|
+
|
|
352
399
|
|
|
353
400
|
def register_file_resources(mcp: FastMCP):
|
|
354
401
|
"""
|
|
@@ -501,10 +548,11 @@ async def load_resource(uri: str) -> dict | str:
|
|
|
501
548
|
Load an MCP resource by URI.
|
|
502
549
|
|
|
503
550
|
This function is called by the read_resource tool to dispatch to
|
|
504
|
-
registered resource handlers.
|
|
551
|
+
registered resource handlers. Supports both regular resources and
|
|
552
|
+
parameterized resource templates (e.g., rem://agents/{agent_name}).
|
|
505
553
|
|
|
506
554
|
Args:
|
|
507
|
-
uri: Resource URI (e.g., "rem://
|
|
555
|
+
uri: Resource URI (e.g., "rem://agents", "rem://agents/ask_rem", "rem://status")
|
|
508
556
|
|
|
509
557
|
Returns:
|
|
510
558
|
Resource data (dict or string)
|
|
@@ -512,9 +560,10 @@ async def load_resource(uri: str) -> dict | str:
|
|
|
512
560
|
Raises:
|
|
513
561
|
ValueError: If URI is invalid or resource not found
|
|
514
562
|
"""
|
|
515
|
-
|
|
563
|
+
import inspect
|
|
516
564
|
from fastmcp import FastMCP
|
|
517
565
|
|
|
566
|
+
# Create temporary MCP instance with resources
|
|
518
567
|
mcp = FastMCP(name="temp")
|
|
519
568
|
|
|
520
569
|
# Register all resources
|
|
@@ -523,14 +572,26 @@ async def load_resource(uri: str) -> dict | str:
|
|
|
523
572
|
register_file_resources(mcp)
|
|
524
573
|
register_status_resources(mcp)
|
|
525
574
|
|
|
526
|
-
#
|
|
527
|
-
|
|
528
|
-
if
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
#
|
|
536
|
-
|
|
575
|
+
# 1. Try exact match in regular resources
|
|
576
|
+
resources = await mcp.get_resources()
|
|
577
|
+
if uri in resources:
|
|
578
|
+
resource = resources[uri]
|
|
579
|
+
result = resource.fn()
|
|
580
|
+
if inspect.iscoroutine(result):
|
|
581
|
+
result = await result
|
|
582
|
+
return result if result else {"error": "Resource returned None"}
|
|
583
|
+
|
|
584
|
+
# 2. Try matching against parameterized resource templates
|
|
585
|
+
templates = await mcp.get_resource_templates()
|
|
586
|
+
for template_uri, template in templates.items():
|
|
587
|
+
params = template.matches(uri)
|
|
588
|
+
if params is not None:
|
|
589
|
+
# Template matched - call function with extracted parameters
|
|
590
|
+
result = template.fn(**params)
|
|
591
|
+
if inspect.iscoroutine(result):
|
|
592
|
+
result = await result
|
|
593
|
+
return result if result else {"error": "Resource returned None"}
|
|
594
|
+
|
|
595
|
+
# 3. Not found - include both resources and templates in error
|
|
596
|
+
available = list(resources.keys()) + list(templates.keys())
|
|
597
|
+
raise ValueError(f"Resource not found: {uri}. Available resources: {available}")
|
rem/api/mcp_router/server.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
MCP server creation and configuration for REM.
|
|
3
3
|
|
|
4
|
-
Design Pattern
|
|
4
|
+
Design Pattern
|
|
5
5
|
1. Create FastMCP server with tools and resources
|
|
6
6
|
2. Register tools using @mcp.tool() decorator
|
|
7
7
|
3. Register resources using resource registration functions
|
|
@@ -20,10 +20,30 @@ FastMCP Features:
|
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
import importlib.metadata
|
|
23
|
+
from functools import wraps
|
|
23
24
|
|
|
24
25
|
from fastmcp import FastMCP
|
|
26
|
+
from loguru import logger
|
|
25
27
|
|
|
26
28
|
from ...settings import settings
|
|
29
|
+
from .prompts import register_prompts
|
|
30
|
+
from .resources import (
|
|
31
|
+
register_agent_resources,
|
|
32
|
+
register_file_resources,
|
|
33
|
+
register_schema_resources,
|
|
34
|
+
register_status_resources,
|
|
35
|
+
)
|
|
36
|
+
from .tools import (
|
|
37
|
+
ask_rem_agent,
|
|
38
|
+
get_schema,
|
|
39
|
+
ingest_into_rem,
|
|
40
|
+
list_schema,
|
|
41
|
+
read_resource,
|
|
42
|
+
register_metadata,
|
|
43
|
+
save_agent,
|
|
44
|
+
search_rem,
|
|
45
|
+
test_error_handling,
|
|
46
|
+
)
|
|
27
47
|
|
|
28
48
|
# Get package version
|
|
29
49
|
try:
|
|
@@ -174,18 +194,7 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
|
|
|
174
194
|
),
|
|
175
195
|
)
|
|
176
196
|
|
|
177
|
-
# Register REM tools
|
|
178
|
-
from .tools import (
|
|
179
|
-
ask_rem_agent,
|
|
180
|
-
get_schema,
|
|
181
|
-
ingest_into_rem,
|
|
182
|
-
list_schema,
|
|
183
|
-
read_resource,
|
|
184
|
-
register_metadata,
|
|
185
|
-
save_agent,
|
|
186
|
-
search_rem,
|
|
187
|
-
)
|
|
188
|
-
|
|
197
|
+
# Register core REM tools
|
|
189
198
|
mcp.tool()(search_rem)
|
|
190
199
|
mcp.tool()(ask_rem_agent)
|
|
191
200
|
mcp.tool()(read_resource)
|
|
@@ -194,10 +203,13 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
|
|
|
194
203
|
mcp.tool()(get_schema)
|
|
195
204
|
mcp.tool()(save_agent)
|
|
196
205
|
|
|
206
|
+
# Register test tool only in development environment (not staging/production)
|
|
207
|
+
if settings.environment not in ("staging", "production"):
|
|
208
|
+
mcp.tool()(test_error_handling)
|
|
209
|
+
logger.debug("Registered test_error_handling tool (dev environment only)")
|
|
210
|
+
|
|
197
211
|
# File ingestion tool (with local path support for local servers)
|
|
198
212
|
# Wrap to inject is_local parameter
|
|
199
|
-
from functools import wraps
|
|
200
|
-
|
|
201
213
|
@wraps(ingest_into_rem)
|
|
202
214
|
async def ingest_into_rem_wrapper(
|
|
203
215
|
file_uri: str,
|
|
@@ -216,18 +228,9 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
|
|
|
216
228
|
mcp.tool()(ingest_into_rem_wrapper)
|
|
217
229
|
|
|
218
230
|
# Register prompts
|
|
219
|
-
from .prompts import register_prompts
|
|
220
|
-
|
|
221
231
|
register_prompts(mcp)
|
|
222
232
|
|
|
223
233
|
# Register schema resources
|
|
224
|
-
from .resources import (
|
|
225
|
-
register_agent_resources,
|
|
226
|
-
register_file_resources,
|
|
227
|
-
register_schema_resources,
|
|
228
|
-
register_status_resources,
|
|
229
|
-
)
|
|
230
|
-
|
|
231
234
|
register_schema_resources(mcp)
|
|
232
235
|
register_agent_resources(mcp)
|
|
233
236
|
register_file_resources(mcp)
|
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
|
|
|
@@ -1130,3 +1132,82 @@ async def save_agent(
|
|
|
1130
1132
|
result["message"] = f"Agent '{name}' saved. Use `/custom-agent {name}` to chat with it."
|
|
1131
1133
|
|
|
1132
1134
|
return result
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
# =============================================================================
|
|
1138
|
+
# Test/Debug Tools (for development only)
|
|
1139
|
+
# =============================================================================
|
|
1140
|
+
|
|
1141
|
+
@mcp_tool_error_handler
|
|
1142
|
+
async def test_error_handling(
|
|
1143
|
+
error_type: Literal["exception", "error_response", "timeout", "success"] = "success",
|
|
1144
|
+
delay_seconds: float = 0,
|
|
1145
|
+
error_message: str = "Test error occurred",
|
|
1146
|
+
) -> dict[str, Any]:
|
|
1147
|
+
"""
|
|
1148
|
+
Test tool for simulating different error scenarios.
|
|
1149
|
+
|
|
1150
|
+
**FOR DEVELOPMENT/TESTING ONLY** - This tool helps verify that error
|
|
1151
|
+
handling works correctly through the streaming layer.
|
|
1152
|
+
|
|
1153
|
+
Args:
|
|
1154
|
+
error_type: Type of error to simulate:
|
|
1155
|
+
- "success": Returns successful response (default)
|
|
1156
|
+
- "exception": Raises an exception (tests @mcp_tool_error_handler)
|
|
1157
|
+
- "error_response": Returns {"status": "error", ...} dict
|
|
1158
|
+
- "timeout": Delays for 60 seconds (simulates timeout)
|
|
1159
|
+
delay_seconds: Optional delay before responding (0-10 seconds)
|
|
1160
|
+
error_message: Custom error message for error scenarios
|
|
1161
|
+
|
|
1162
|
+
Returns:
|
|
1163
|
+
Dict with test results or error information
|
|
1164
|
+
|
|
1165
|
+
Examples:
|
|
1166
|
+
# Test successful response
|
|
1167
|
+
test_error_handling(error_type="success")
|
|
1168
|
+
|
|
1169
|
+
# Test exception handling
|
|
1170
|
+
test_error_handling(error_type="exception", error_message="Database connection failed")
|
|
1171
|
+
|
|
1172
|
+
# Test error response format
|
|
1173
|
+
test_error_handling(error_type="error_response", error_message="Resource not found")
|
|
1174
|
+
|
|
1175
|
+
# Test with delay
|
|
1176
|
+
test_error_handling(error_type="success", delay_seconds=2)
|
|
1177
|
+
"""
|
|
1178
|
+
import asyncio
|
|
1179
|
+
|
|
1180
|
+
logger.info(f"test_error_handling called: type={error_type}, delay={delay_seconds}")
|
|
1181
|
+
|
|
1182
|
+
# Apply delay (capped at 10 seconds for safety)
|
|
1183
|
+
if delay_seconds > 0:
|
|
1184
|
+
await asyncio.sleep(min(delay_seconds, 10))
|
|
1185
|
+
|
|
1186
|
+
if error_type == "exception":
|
|
1187
|
+
# This tests the @mcp_tool_error_handler decorator
|
|
1188
|
+
raise RuntimeError(f"TEST EXCEPTION: {error_message}")
|
|
1189
|
+
|
|
1190
|
+
elif error_type == "error_response":
|
|
1191
|
+
# This tests how the streaming layer handles error status responses
|
|
1192
|
+
return {
|
|
1193
|
+
"status": "error",
|
|
1194
|
+
"error": error_message,
|
|
1195
|
+
"error_code": "TEST_ERROR",
|
|
1196
|
+
"recoverable": True,
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
elif error_type == "timeout":
|
|
1200
|
+
# Simulate a very long operation (for testing client-side timeouts)
|
|
1201
|
+
await asyncio.sleep(60)
|
|
1202
|
+
return {"status": "success", "message": "Timeout test completed (should not reach here)"}
|
|
1203
|
+
|
|
1204
|
+
else: # success
|
|
1205
|
+
return {
|
|
1206
|
+
"status": "success",
|
|
1207
|
+
"message": "Test completed successfully",
|
|
1208
|
+
"test_data": {
|
|
1209
|
+
"error_type": error_type,
|
|
1210
|
+
"delay_applied": delay_seconds,
|
|
1211
|
+
"timestamp": str(asyncio.get_event_loop().time()),
|
|
1212
|
+
},
|
|
1213
|
+
}
|
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
|
@@ -30,14 +30,17 @@ Access Control Flow (send-code):
|
|
|
30
30
|
│ ├── Yes → Check user.tier
|
|
31
31
|
│ │ ├── tier == BLOCKED → Reject "Account is blocked"
|
|
32
32
|
│ │ └── tier != BLOCKED → Allow (send code, existing users grandfathered)
|
|
33
|
-
│ └── No (new user) → Check
|
|
34
|
-
│ ├──
|
|
35
|
-
│
|
|
36
|
-
│
|
|
37
|
-
│
|
|
33
|
+
│ └── No (new user) → Check subscriber list first
|
|
34
|
+
│ ├── Email in subscribers table? → Allow (create user & send code)
|
|
35
|
+
│ └── Not a subscriber → Check EMAIL__TRUSTED_EMAIL_DOMAINS
|
|
36
|
+
│ ├── Setting configured → domain in trusted list?
|
|
37
|
+
│ │ ├── Yes → Create user & send code
|
|
38
|
+
│ │ └── No → Reject "Email domain not allowed for signup"
|
|
39
|
+
│ └── Not configured (empty) → Create user & send code (no restrictions)
|
|
38
40
|
|
|
39
41
|
Key Behaviors:
|
|
40
42
|
- Existing users: Always allowed to login (unless tier=BLOCKED)
|
|
43
|
+
- Subscribers: Always allowed to login (regardless of email domain)
|
|
41
44
|
- New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
|
|
42
45
|
- No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
|
|
43
46
|
|
|
@@ -102,6 +105,8 @@ from ...settings import settings
|
|
|
102
105
|
from ...services.postgres.service import PostgresService
|
|
103
106
|
from ...services.user_service import UserService
|
|
104
107
|
from ...auth.providers.email import EmailAuthProvider
|
|
108
|
+
from ...auth.jwt import JWTService, get_jwt_service
|
|
109
|
+
from ...utils.user_id import email_to_user_id
|
|
105
110
|
|
|
106
111
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
107
112
|
|
|
@@ -219,14 +224,14 @@ async def send_email_code(request: Request, body: EmailSendCodeRequest):
|
|
|
219
224
|
@router.post("/email/verify")
|
|
220
225
|
async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
221
226
|
"""
|
|
222
|
-
Verify login code and create session.
|
|
227
|
+
Verify login code and create session with JWT tokens.
|
|
223
228
|
|
|
224
229
|
Args:
|
|
225
230
|
request: FastAPI request
|
|
226
231
|
body: EmailVerifyRequest with email and code
|
|
227
232
|
|
|
228
233
|
Returns:
|
|
229
|
-
Success status with user info
|
|
234
|
+
Success status with user info and JWT tokens
|
|
230
235
|
"""
|
|
231
236
|
if not settings.email.is_configured:
|
|
232
237
|
raise HTTPException(
|
|
@@ -266,7 +271,25 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
|
266
271
|
user_id=result.user_id,
|
|
267
272
|
)
|
|
268
273
|
|
|
269
|
-
#
|
|
274
|
+
# Fetch actual user data from database to get role/tier
|
|
275
|
+
user_service = UserService(db)
|
|
276
|
+
try:
|
|
277
|
+
user_entity = await user_service.get_user_by_id(result.user_id)
|
|
278
|
+
if user_entity:
|
|
279
|
+
# Override defaults with actual database values
|
|
280
|
+
user_dict["role"] = user_entity.role or "user"
|
|
281
|
+
user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
|
|
282
|
+
user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
|
|
283
|
+
user_dict["name"] = user_entity.name or user_dict["name"]
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.warning(f"Could not fetch user details: {e}")
|
|
286
|
+
# Continue with defaults from get_user_dict
|
|
287
|
+
|
|
288
|
+
# Generate JWT tokens
|
|
289
|
+
jwt_service = get_jwt_service()
|
|
290
|
+
tokens = jwt_service.create_tokens(user_dict)
|
|
291
|
+
|
|
292
|
+
# Store user in session (for backward compatibility)
|
|
270
293
|
request.session["user"] = user_dict
|
|
271
294
|
|
|
272
295
|
logger.info(f"User authenticated via email: {result.email}")
|
|
@@ -275,6 +298,11 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
|
275
298
|
"success": True,
|
|
276
299
|
"message": result.message,
|
|
277
300
|
"user": user_dict,
|
|
301
|
+
# JWT tokens for stateless auth
|
|
302
|
+
"access_token": tokens["access_token"],
|
|
303
|
+
"refresh_token": tokens["refresh_token"],
|
|
304
|
+
"token_type": tokens["token_type"],
|
|
305
|
+
"expires_in": tokens["expires_in"],
|
|
278
306
|
}
|
|
279
307
|
|
|
280
308
|
except HTTPException:
|
|
@@ -405,8 +433,9 @@ async def callback(provider: str, request: Request):
|
|
|
405
433
|
await user_service.link_anonymous_session(user_entity, anon_id)
|
|
406
434
|
|
|
407
435
|
# Enrich session user with DB info
|
|
436
|
+
# user_id = UUID5 hash of email (deterministic, bijection)
|
|
408
437
|
db_info = {
|
|
409
|
-
"id":
|
|
438
|
+
"id": email_to_user_id(user_info.get("email")),
|
|
410
439
|
"tenant_id": user_entity.tenant_id,
|
|
411
440
|
"tier": user_entity.tier.value if user_entity.tier else "free",
|
|
412
441
|
"roles": [user_entity.role] if user_entity.role else [],
|
|
@@ -472,7 +501,7 @@ async def logout(request: Request):
|
|
|
472
501
|
@router.get("/me")
|
|
473
502
|
async def me(request: Request):
|
|
474
503
|
"""
|
|
475
|
-
Get current user information from session.
|
|
504
|
+
Get current user information from session or JWT.
|
|
476
505
|
|
|
477
506
|
Args:
|
|
478
507
|
request: FastAPI request
|
|
@@ -480,6 +509,16 @@ async def me(request: Request):
|
|
|
480
509
|
Returns:
|
|
481
510
|
User information or 401 if not authenticated
|
|
482
511
|
"""
|
|
512
|
+
# First check for JWT in Authorization header
|
|
513
|
+
auth_header = request.headers.get("Authorization")
|
|
514
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
515
|
+
token = auth_header[7:]
|
|
516
|
+
jwt_service = get_jwt_service()
|
|
517
|
+
user = jwt_service.verify_token(token)
|
|
518
|
+
if user:
|
|
519
|
+
return user
|
|
520
|
+
|
|
521
|
+
# Fall back to session
|
|
483
522
|
user = request.session.get("user")
|
|
484
523
|
if not user:
|
|
485
524
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
@@ -487,6 +526,69 @@ async def me(request: Request):
|
|
|
487
526
|
return user
|
|
488
527
|
|
|
489
528
|
|
|
529
|
+
# =============================================================================
|
|
530
|
+
# JWT Token Endpoints
|
|
531
|
+
# =============================================================================
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class TokenRefreshRequest(BaseModel):
|
|
535
|
+
"""Request to refresh access token."""
|
|
536
|
+
refresh_token: str
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@router.post("/token/refresh")
|
|
540
|
+
async def refresh_token(body: TokenRefreshRequest):
|
|
541
|
+
"""
|
|
542
|
+
Refresh access token using refresh token.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
body: TokenRefreshRequest with refresh_token
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
New access token or 401 if refresh token is invalid
|
|
549
|
+
"""
|
|
550
|
+
jwt_service = get_jwt_service()
|
|
551
|
+
result = jwt_service.refresh_access_token(body.refresh_token)
|
|
552
|
+
|
|
553
|
+
if not result:
|
|
554
|
+
raise HTTPException(
|
|
555
|
+
status_code=401,
|
|
556
|
+
detail="Invalid or expired refresh token"
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@router.post("/token/verify")
|
|
563
|
+
async def verify_token(request: Request):
|
|
564
|
+
"""
|
|
565
|
+
Verify an access token is valid.
|
|
566
|
+
|
|
567
|
+
Pass the token in the Authorization header: Bearer <token>
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
User info if valid, 401 if invalid
|
|
571
|
+
"""
|
|
572
|
+
auth_header = request.headers.get("Authorization")
|
|
573
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
574
|
+
raise HTTPException(
|
|
575
|
+
status_code=401,
|
|
576
|
+
detail="Missing Authorization header"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
token = auth_header[7:]
|
|
580
|
+
jwt_service = get_jwt_service()
|
|
581
|
+
user = jwt_service.verify_token(token)
|
|
582
|
+
|
|
583
|
+
if not user:
|
|
584
|
+
raise HTTPException(
|
|
585
|
+
status_code=401,
|
|
586
|
+
detail="Invalid or expired token"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
return {"valid": True, "user": user}
|
|
590
|
+
|
|
591
|
+
|
|
490
592
|
# =============================================================================
|
|
491
593
|
# Development Token Endpoints (non-production only)
|
|
492
594
|
# =============================================================================
|
|
@@ -555,3 +657,43 @@ async def get_dev_token(request: Request):
|
|
|
555
657
|
"usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
|
|
556
658
|
"warning": "This token is for development/testing only and will not work in production.",
|
|
557
659
|
}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@router.get("/dev/mock-code/{email}")
|
|
663
|
+
async def get_mock_code(email: str, request: Request):
|
|
664
|
+
"""
|
|
665
|
+
Get the mock login code for testing (non-production only).
|
|
666
|
+
|
|
667
|
+
This endpoint retrieves the code that was "sent" via email in mock mode.
|
|
668
|
+
Use this for automated testing without real email delivery.
|
|
669
|
+
|
|
670
|
+
Usage:
|
|
671
|
+
1. POST /api/auth/email/send-code with email
|
|
672
|
+
2. GET /api/auth/dev/mock-code/{email} to retrieve the code
|
|
673
|
+
3. POST /api/auth/email/verify with email and code
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
401 if in production environment
|
|
677
|
+
404 if no code found for the email
|
|
678
|
+
The code and email otherwise
|
|
679
|
+
"""
|
|
680
|
+
if settings.environment == "production":
|
|
681
|
+
raise HTTPException(
|
|
682
|
+
status_code=401,
|
|
683
|
+
detail="Mock codes are not available in production"
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
from ...services.email import EmailService
|
|
687
|
+
|
|
688
|
+
code = EmailService.get_mock_code(email)
|
|
689
|
+
if not code:
|
|
690
|
+
raise HTTPException(
|
|
691
|
+
status_code=404,
|
|
692
|
+
detail=f"No mock code found for {email}. Send a code first."
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
"email": email,
|
|
697
|
+
"code": code,
|
|
698
|
+
"warning": "This endpoint is for testing only and will not work in production.",
|
|
699
|
+
}
|
|
@@ -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
|