remdb 0.3.127__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.
- rem/agentic/agents/__init__.py +16 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/context.py +81 -3
- rem/agentic/context_builder.py +36 -9
- rem/agentic/mcp/tool_wrapper.py +132 -15
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +163 -45
- rem/agentic/schema.py +8 -4
- rem/api/deps.py +3 -5
- rem/api/main.py +22 -3
- rem/api/mcp_router/resources.py +15 -10
- rem/api/mcp_router/server.py +2 -0
- rem/api/mcp_router/tools.py +94 -2
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/auth.py +349 -6
- rem/api/routers/chat/completions.py +5 -3
- rem/api/routers/chat/streaming.py +95 -22
- rem/api/routers/messages.py +24 -15
- rem/auth/__init__.py +13 -3
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +115 -10
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/configure.py +3 -4
- rem/cli/commands/experiments.py +226 -50
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +2 -0
- rem/models/core/experiment.py +58 -14
- rem/models/entities/__init__.py +4 -0
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +4 -3
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +513 -0
- rem/services/email/templates.py +360 -0
- rem/services/postgres/README.md +38 -0
- rem/services/postgres/diff_service.py +19 -3
- rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
- rem/services/postgres/repository.py +5 -4
- rem/services/session/compression.py +113 -50
- rem/services/session/reload.py +14 -7
- rem/services/user_service.py +41 -9
- rem/settings.py +292 -5
- rem/sql/migrations/001_install.sql +1 -1
- rem/sql/migrations/002_install_models.sql +91 -91
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/files.py +157 -1
- rem/utils/schema_loader.py +45 -7
- rem/utils/vision.py +1 -1
- {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/METADATA +7 -5
- {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/RECORD +62 -52
- {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
- {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
rem/api/routers/auth.py
CHANGED
|
@@ -1,20 +1,68 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Authentication Router.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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,15 @@ 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
|
|
106
|
+
from ...utils.user_id import email_to_user_id
|
|
54
107
|
|
|
55
108
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
56
109
|
|
|
@@ -87,6 +140,182 @@ if settings.auth.microsoft.client_id:
|
|
|
87
140
|
logger.info(f"Microsoft OAuth provider registered (tenant: {tenant})")
|
|
88
141
|
|
|
89
142
|
|
|
143
|
+
# =============================================================================
|
|
144
|
+
# Email Authentication Endpoints
|
|
145
|
+
# =============================================================================
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class EmailSendCodeRequest(BaseModel):
|
|
149
|
+
"""Request to send login code."""
|
|
150
|
+
email: EmailStr
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class EmailVerifyRequest(BaseModel):
|
|
154
|
+
"""Request to verify login code."""
|
|
155
|
+
email: EmailStr
|
|
156
|
+
code: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@router.post("/email/send-code")
|
|
160
|
+
async def send_email_code(request: Request, body: EmailSendCodeRequest):
|
|
161
|
+
"""
|
|
162
|
+
Send a login code to an email address.
|
|
163
|
+
|
|
164
|
+
Creates user if not exists (using deterministic UUID from email).
|
|
165
|
+
Stores code in user metadata with expiry.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
request: FastAPI request
|
|
169
|
+
body: EmailSendCodeRequest with email
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Success status and message
|
|
173
|
+
"""
|
|
174
|
+
if not settings.email.is_configured:
|
|
175
|
+
raise HTTPException(
|
|
176
|
+
status_code=501,
|
|
177
|
+
detail="Email authentication is not configured"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Get database connection
|
|
181
|
+
if not settings.postgres.enabled:
|
|
182
|
+
raise HTTPException(
|
|
183
|
+
status_code=501,
|
|
184
|
+
detail="Database is required for email authentication"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
db = PostgresService()
|
|
188
|
+
try:
|
|
189
|
+
await db.connect()
|
|
190
|
+
|
|
191
|
+
# Initialize email auth provider
|
|
192
|
+
email_auth = EmailAuthProvider()
|
|
193
|
+
|
|
194
|
+
# Send code
|
|
195
|
+
result = await email_auth.send_code(
|
|
196
|
+
email=body.email,
|
|
197
|
+
db=db,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if result.success:
|
|
201
|
+
return {
|
|
202
|
+
"success": True,
|
|
203
|
+
"message": result.message,
|
|
204
|
+
"email": result.email,
|
|
205
|
+
}
|
|
206
|
+
else:
|
|
207
|
+
raise HTTPException(
|
|
208
|
+
status_code=400,
|
|
209
|
+
detail=result.message or result.error
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
except HTTPException:
|
|
213
|
+
raise
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error(f"Error sending login code: {e}")
|
|
216
|
+
raise HTTPException(status_code=500, detail="Failed to send login code")
|
|
217
|
+
finally:
|
|
218
|
+
await db.disconnect()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.post("/email/verify")
|
|
222
|
+
async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
223
|
+
"""
|
|
224
|
+
Verify login code and create session with JWT tokens.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
request: FastAPI request
|
|
228
|
+
body: EmailVerifyRequest with email and code
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Success status with user info and JWT tokens
|
|
232
|
+
"""
|
|
233
|
+
if not settings.email.is_configured:
|
|
234
|
+
raise HTTPException(
|
|
235
|
+
status_code=501,
|
|
236
|
+
detail="Email authentication is not configured"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if not settings.postgres.enabled:
|
|
240
|
+
raise HTTPException(
|
|
241
|
+
status_code=501,
|
|
242
|
+
detail="Database is required for email authentication"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
db = PostgresService()
|
|
246
|
+
try:
|
|
247
|
+
await db.connect()
|
|
248
|
+
|
|
249
|
+
# Initialize email auth provider
|
|
250
|
+
email_auth = EmailAuthProvider()
|
|
251
|
+
|
|
252
|
+
# Verify code
|
|
253
|
+
result = await email_auth.verify_code(
|
|
254
|
+
email=body.email,
|
|
255
|
+
code=body.code,
|
|
256
|
+
db=db,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if not result.success:
|
|
260
|
+
raise HTTPException(
|
|
261
|
+
status_code=400,
|
|
262
|
+
detail=result.message or result.error
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Create session - compatible with OAuth session format
|
|
266
|
+
user_dict = email_auth.get_user_dict(
|
|
267
|
+
email=result.email,
|
|
268
|
+
user_id=result.user_id,
|
|
269
|
+
)
|
|
270
|
+
|
|
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)
|
|
290
|
+
request.session["user"] = user_dict
|
|
291
|
+
|
|
292
|
+
logger.info(f"User authenticated via email: {result.email}")
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
"success": True,
|
|
296
|
+
"message": result.message,
|
|
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"],
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
except HTTPException:
|
|
306
|
+
raise
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.error(f"Error verifying login code: {e}")
|
|
309
|
+
raise HTTPException(status_code=500, detail="Failed to verify login code")
|
|
310
|
+
finally:
|
|
311
|
+
await db.disconnect()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# =============================================================================
|
|
315
|
+
# OAuth Authentication Endpoints
|
|
316
|
+
# =============================================================================
|
|
317
|
+
|
|
318
|
+
|
|
90
319
|
@router.get("/{provider}/login")
|
|
91
320
|
async def login(provider: str, request: Request):
|
|
92
321
|
"""
|
|
@@ -201,8 +430,9 @@ async def callback(provider: str, request: Request):
|
|
|
201
430
|
await user_service.link_anonymous_session(user_entity, anon_id)
|
|
202
431
|
|
|
203
432
|
# Enrich session user with DB info
|
|
433
|
+
# user_id = UUID5 hash of email (deterministic, bijection)
|
|
204
434
|
db_info = {
|
|
205
|
-
"id":
|
|
435
|
+
"id": email_to_user_id(user_info.get("email")),
|
|
206
436
|
"tenant_id": user_entity.tenant_id,
|
|
207
437
|
"tier": user_entity.tier.value if user_entity.tier else "free",
|
|
208
438
|
"roles": [user_entity.role] if user_entity.role else [],
|
|
@@ -268,7 +498,7 @@ async def logout(request: Request):
|
|
|
268
498
|
@router.get("/me")
|
|
269
499
|
async def me(request: Request):
|
|
270
500
|
"""
|
|
271
|
-
Get current user information from session.
|
|
501
|
+
Get current user information from session or JWT.
|
|
272
502
|
|
|
273
503
|
Args:
|
|
274
504
|
request: FastAPI request
|
|
@@ -276,6 +506,16 @@ async def me(request: Request):
|
|
|
276
506
|
Returns:
|
|
277
507
|
User information or 401 if not authenticated
|
|
278
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
|
|
279
519
|
user = request.session.get("user")
|
|
280
520
|
if not user:
|
|
281
521
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
@@ -283,6 +523,69 @@ async def me(request: Request):
|
|
|
283
523
|
return user
|
|
284
524
|
|
|
285
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
|
+
|
|
286
589
|
# =============================================================================
|
|
287
590
|
# Development Token Endpoints (non-production only)
|
|
288
591
|
# =============================================================================
|
|
@@ -351,3 +654,43 @@ async def get_dev_token(request: Request):
|
|
|
351
654
|
"usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
|
|
352
655
|
"warning": "This token is for development/testing only and will not work in production.",
|
|
353
656
|
}
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@router.get("/dev/mock-code/{email}")
|
|
660
|
+
async def get_mock_code(email: str, request: Request):
|
|
661
|
+
"""
|
|
662
|
+
Get the mock login code for testing (non-production only).
|
|
663
|
+
|
|
664
|
+
This endpoint retrieves the code that was "sent" via email in mock mode.
|
|
665
|
+
Use this for automated testing without real email delivery.
|
|
666
|
+
|
|
667
|
+
Usage:
|
|
668
|
+
1. POST /api/auth/email/send-code with email
|
|
669
|
+
2. GET /api/auth/dev/mock-code/{email} to retrieve the code
|
|
670
|
+
3. POST /api/auth/email/verify with email and code
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
401 if in production environment
|
|
674
|
+
404 if no code found for the email
|
|
675
|
+
The code and email otherwise
|
|
676
|
+
"""
|
|
677
|
+
if settings.environment == "production":
|
|
678
|
+
raise HTTPException(
|
|
679
|
+
status_code=401,
|
|
680
|
+
detail="Mock codes are not available in production"
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
from ...services.email import EmailService
|
|
684
|
+
|
|
685
|
+
code = EmailService.get_mock_code(email)
|
|
686
|
+
if not code:
|
|
687
|
+
raise HTTPException(
|
|
688
|
+
status_code=404,
|
|
689
|
+
detail=f"No mock code found for {email}. Send a code first."
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
"email": email,
|
|
694
|
+
"code": code,
|
|
695
|
+
"warning": "This endpoint is for testing only and will not work in production.",
|
|
696
|
+
}
|
|
@@ -97,7 +97,7 @@ Context Building Flow:
|
|
|
97
97
|
- Long messages include REM LOOKUP hints: "... [REM LOOKUP session-{id}-msg-{index}] ..."
|
|
98
98
|
- Agent can retrieve full content on-demand using REM LOOKUP
|
|
99
99
|
3. User profile provided as REM LOOKUP hint (on-demand by default)
|
|
100
|
-
- Agent receives: "User
|
|
100
|
+
- Agent receives: "User: {email}. To load user profile: Use REM LOOKUP \"{email}\""
|
|
101
101
|
- Agent decides whether to load profile based on query
|
|
102
102
|
4. If CHAT__AUTO_INJECT_USER_CONTEXT=true: User profile auto-loaded and injected
|
|
103
103
|
5. Combines: system context + compressed session history + new messages
|
|
@@ -330,8 +330,8 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
330
330
|
- Useful for A/B testing, model comparison, and feedback collection
|
|
331
331
|
"""
|
|
332
332
|
# Load agent schema: use header value from context or default
|
|
333
|
-
# Extract AgentContext
|
|
334
|
-
temp_context = AgentContext.
|
|
333
|
+
# Extract AgentContext from request (gets user_id from JWT token)
|
|
334
|
+
temp_context = AgentContext.from_request(request)
|
|
335
335
|
schema_name = temp_context.agent_schema_uri or DEFAULT_AGENT_SCHEMA
|
|
336
336
|
|
|
337
337
|
# Resolve model: use body.model if provided, otherwise settings default
|
|
@@ -350,6 +350,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
350
350
|
context, messages = await ContextBuilder.build_from_headers(
|
|
351
351
|
headers=dict(request.headers),
|
|
352
352
|
new_messages=new_messages,
|
|
353
|
+
user_id=temp_context.user_id, # From JWT token (source of truth)
|
|
353
354
|
)
|
|
354
355
|
|
|
355
356
|
# Ensure session exists with metadata and eval mode if applicable
|
|
@@ -509,6 +510,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
509
510
|
context, messages = await ContextBuilder.build_from_headers(
|
|
510
511
|
headers=dict(request.headers),
|
|
511
512
|
new_messages=new_messages,
|
|
513
|
+
user_id=temp_context.user_id, # From JWT token (source of truth)
|
|
512
514
|
)
|
|
513
515
|
|
|
514
516
|
logger.info(f"Built context with {len(messages)} total messages (includes history + user context)")
|
|
@@ -76,6 +76,9 @@ async def stream_openai_response(
|
|
|
76
76
|
agent_schema: str | None = None,
|
|
77
77
|
# Mutable container to capture trace context (deterministic, not AI-dependent)
|
|
78
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,
|
|
79
82
|
) -> AsyncGenerator[str, None]:
|
|
80
83
|
"""
|
|
81
84
|
Stream Pydantic AI agent responses with rich SSE events.
|
|
@@ -146,6 +149,9 @@ async def stream_openai_response(
|
|
|
146
149
|
pending_tool_completions: list[tuple[str, str]] = []
|
|
147
150
|
# Track if metadata was registered via register_metadata tool
|
|
148
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] = {}
|
|
149
155
|
|
|
150
156
|
try:
|
|
151
157
|
# Emit initial progress event
|
|
@@ -299,6 +305,13 @@ async def stream_openai_response(
|
|
|
299
305
|
arguments=args_dict
|
|
300
306
|
))
|
|
301
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
|
+
|
|
302
315
|
# Update progress
|
|
303
316
|
current_step = 2
|
|
304
317
|
total_steps = 4 # Added tool execution step
|
|
@@ -421,6 +434,15 @@ async def stream_openai_response(
|
|
|
421
434
|
hidden=False,
|
|
422
435
|
))
|
|
423
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
|
+
|
|
424
446
|
if not is_metadata_event:
|
|
425
447
|
# Normal tool completion - emit ToolCallEvent
|
|
426
448
|
result_str = str(result_content)
|
|
@@ -728,6 +750,9 @@ async def stream_openai_response_with_save(
|
|
|
728
750
|
# Accumulate content during streaming
|
|
729
751
|
accumulated_content = []
|
|
730
752
|
|
|
753
|
+
# Capture tool calls for persistence (especially register_metadata)
|
|
754
|
+
tool_calls: list = []
|
|
755
|
+
|
|
731
756
|
async for chunk in stream_openai_response(
|
|
732
757
|
agent=agent,
|
|
733
758
|
prompt=prompt,
|
|
@@ -737,6 +762,7 @@ async def stream_openai_response_with_save(
|
|
|
737
762
|
session_id=session_id,
|
|
738
763
|
message_id=message_id,
|
|
739
764
|
trace_context_out=trace_context, # Pass container to capture trace IDs
|
|
765
|
+
tool_calls_out=tool_calls, # Capture tool calls for persistence
|
|
740
766
|
):
|
|
741
767
|
yield chunk
|
|
742
768
|
|
|
@@ -755,28 +781,75 @@ async def stream_openai_response_with_save(
|
|
|
755
781
|
except (json.JSONDecodeError, KeyError, IndexError):
|
|
756
782
|
pass # Skip non-JSON or malformed chunks
|
|
757
783
|
|
|
758
|
-
# After streaming completes, save
|
|
759
|
-
|
|
760
|
-
|
|
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:
|
|
761
787
|
# Get captured trace context from container (deterministically captured inside agent execution)
|
|
762
788
|
captured_trace_id = trace_context.get("trace_id")
|
|
763
789
|
captured_span_id = trace_context.get("span_id")
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|