remdb 0.3.202__py3-none-any.whl → 0.3.226__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/api/routers/admin.py CHANGED
@@ -31,6 +31,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Background
31
31
  from loguru import logger
32
32
  from pydantic import BaseModel
33
33
 
34
+ from .common import ErrorResponse
35
+
34
36
  from ..deps import require_admin
35
37
  from ...models.entities import Message, Session, SessionMode
36
38
  from ...services.postgres import Repository
@@ -103,7 +105,13 @@ class SystemStats(BaseModel):
103
105
  # =============================================================================
104
106
 
105
107
 
106
- @router.get("/users", response_model=UserListResponse)
108
+ @router.get(
109
+ "/users",
110
+ response_model=UserListResponse,
111
+ responses={
112
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
113
+ },
114
+ )
107
115
  async def list_all_users(
108
116
  user: dict = Depends(require_admin),
109
117
  limit: int = Query(default=50, ge=1, le=100),
@@ -155,7 +163,13 @@ async def list_all_users(
155
163
  return UserListResponse(data=summaries, total=total, has_more=has_more)
156
164
 
157
165
 
158
- @router.get("/sessions", response_model=SessionListResponse)
166
+ @router.get(
167
+ "/sessions",
168
+ response_model=SessionListResponse,
169
+ responses={
170
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
171
+ },
172
+ )
159
173
  async def list_all_sessions(
160
174
  user: dict = Depends(require_admin),
161
175
  user_id: str | None = Query(default=None, description="Filter by user ID"),
@@ -202,7 +216,13 @@ async def list_all_sessions(
202
216
  return SessionListResponse(data=sessions, total=total, has_more=has_more)
203
217
 
204
218
 
205
- @router.get("/messages", response_model=MessageListResponse)
219
+ @router.get(
220
+ "/messages",
221
+ response_model=MessageListResponse,
222
+ responses={
223
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
224
+ },
225
+ )
206
226
  async def list_all_messages(
207
227
  user: dict = Depends(require_admin),
208
228
  user_id: str | None = Query(default=None, description="Filter by user ID"),
@@ -252,7 +272,13 @@ async def list_all_messages(
252
272
  return MessageListResponse(data=messages, total=total, has_more=has_more)
253
273
 
254
274
 
255
- @router.get("/stats", response_model=SystemStats)
275
+ @router.get(
276
+ "/stats",
277
+ response_model=SystemStats,
278
+ responses={
279
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
280
+ },
281
+ )
256
282
  async def get_system_stats(
257
283
  user: dict = Depends(require_admin),
258
284
  ) -> SystemStats:
rem/api/routers/auth.py CHANGED
@@ -101,6 +101,8 @@ from authlib.integrations.starlette_client import OAuth
101
101
  from pydantic import BaseModel, EmailStr
102
102
  from loguru import logger
103
103
 
104
+ from .common import ErrorResponse
105
+
104
106
  from ...settings import settings
105
107
  from ...services.postgres.service import PostgresService
106
108
  from ...services.user_service import UserService
@@ -159,7 +161,14 @@ class EmailVerifyRequest(BaseModel):
159
161
  code: str
160
162
 
161
163
 
162
- @router.post("/email/send-code")
164
+ @router.post(
165
+ "/email/send-code",
166
+ responses={
167
+ 400: {"model": ErrorResponse, "description": "Invalid request or email rejected"},
168
+ 500: {"model": ErrorResponse, "description": "Failed to send login code"},
169
+ 501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
170
+ },
171
+ )
163
172
  async def send_email_code(request: Request, body: EmailSendCodeRequest):
164
173
  """
165
174
  Send a login code to an email address.
@@ -221,7 +230,14 @@ async def send_email_code(request: Request, body: EmailSendCodeRequest):
221
230
  await db.disconnect()
222
231
 
223
232
 
224
- @router.post("/email/verify")
233
+ @router.post(
234
+ "/email/verify",
235
+ responses={
236
+ 400: {"model": ErrorResponse, "description": "Invalid or expired code"},
237
+ 500: {"model": ErrorResponse, "description": "Failed to verify login code"},
238
+ 501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
239
+ },
240
+ )
225
241
  async def verify_email_code(request: Request, body: EmailVerifyRequest):
226
242
  """
227
243
  Verify login code and create session with JWT tokens.
@@ -319,7 +335,13 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
319
335
  # =============================================================================
320
336
 
321
337
 
322
- @router.get("/{provider}/login")
338
+ @router.get(
339
+ "/{provider}/login",
340
+ responses={
341
+ 400: {"model": ErrorResponse, "description": "Unknown OAuth provider"},
342
+ 501: {"model": ErrorResponse, "description": "Authentication is disabled"},
343
+ },
344
+ )
323
345
  async def login(provider: str, request: Request):
324
346
  """
325
347
  Initiate OAuth flow with provider.
@@ -361,7 +383,13 @@ async def login(provider: str, request: Request):
361
383
  return await client.authorize_redirect(request, redirect_uri)
362
384
 
363
385
 
364
- @router.get("/{provider}/callback")
386
+ @router.get(
387
+ "/{provider}/callback",
388
+ responses={
389
+ 400: {"model": ErrorResponse, "description": "Authentication failed or unknown provider"},
390
+ 501: {"model": ErrorResponse, "description": "Authentication is disabled"},
391
+ },
392
+ )
365
393
  async def callback(provider: str, request: Request):
366
394
  """
367
395
  OAuth callback endpoint.
@@ -498,7 +526,12 @@ async def logout(request: Request):
498
526
  return {"message": "Logged out successfully"}
499
527
 
500
528
 
501
- @router.get("/me")
529
+ @router.get(
530
+ "/me",
531
+ responses={
532
+ 401: {"model": ErrorResponse, "description": "Not authenticated"},
533
+ },
534
+ )
502
535
  async def me(request: Request):
503
536
  """
504
537
  Get current user information from session or JWT.
@@ -536,7 +569,12 @@ class TokenRefreshRequest(BaseModel):
536
569
  refresh_token: str
537
570
 
538
571
 
539
- @router.post("/token/refresh")
572
+ @router.post(
573
+ "/token/refresh",
574
+ responses={
575
+ 401: {"model": ErrorResponse, "description": "Invalid or expired refresh token"},
576
+ },
577
+ )
540
578
  async def refresh_token(body: TokenRefreshRequest):
541
579
  """
542
580
  Refresh access token using refresh token.
@@ -601,7 +639,12 @@ async def refresh_token(body: TokenRefreshRequest):
601
639
  return result
602
640
 
603
641
 
604
- @router.post("/token/verify")
642
+ @router.post(
643
+ "/token/verify",
644
+ responses={
645
+ 401: {"model": ErrorResponse, "description": "Missing, invalid, or expired token"},
646
+ },
647
+ )
605
648
  async def verify_token(request: Request):
606
649
  """
607
650
  Verify an access token is valid.
@@ -665,7 +708,12 @@ def verify_dev_token(token: str) -> bool:
665
708
  return token == expected
666
709
 
667
710
 
668
- @router.get("/dev/token")
711
+ @router.get(
712
+ "/dev/token",
713
+ responses={
714
+ 401: {"model": ErrorResponse, "description": "Dev tokens not available in production"},
715
+ },
716
+ )
669
717
  async def get_dev_token(request: Request):
670
718
  """
671
719
  Get a development token for testing (non-production only).
@@ -701,7 +749,13 @@ async def get_dev_token(request: Request):
701
749
  }
702
750
 
703
751
 
704
- @router.get("/dev/mock-code/{email}")
752
+ @router.get(
753
+ "/dev/mock-code/{email}",
754
+ responses={
755
+ 401: {"model": ErrorResponse, "description": "Mock codes not available in production"},
756
+ 404: {"model": ErrorResponse, "description": "No code found for email"},
757
+ },
758
+ )
705
759
  async def get_mock_code(email: str, request: Request):
706
760
  """
707
761
  Get the mock login code for testing (non-production only).
@@ -164,7 +164,7 @@ from .models import (
164
164
  ChatCompletionUsage,
165
165
  ChatMessage,
166
166
  )
167
- from .streaming import stream_openai_response, stream_openai_response_with_save, stream_simulator_response
167
+ from .streaming import stream_openai_response, stream_openai_response_with_save, stream_simulator_response, save_user_message
168
168
 
169
169
  router = APIRouter(prefix="/api/v1", tags=["chat"])
170
170
 
@@ -215,7 +215,7 @@ async def ensure_session_with_metadata(
215
215
  Merges request metadata with existing session metadata.
216
216
 
217
217
  Args:
218
- session_id: Session identifier (maps to Session.name)
218
+ session_id: Session UUID from X-Session-Id header
219
219
  user_id: User identifier
220
220
  tenant_id: Tenant identifier
221
221
  is_eval: Whether this is an evaluation session
@@ -228,12 +228,8 @@ async def ensure_session_with_metadata(
228
228
  try:
229
229
  repo = Repository(Session, table_name="sessions")
230
230
 
231
- # Try to load existing session by name (session_id is the name field)
232
- existing_list = await repo.find(
233
- filters={"name": session_id, "tenant_id": tenant_id},
234
- limit=1,
235
- )
236
- existing = existing_list[0] if existing_list else None
231
+ # Look up session by UUID (id field)
232
+ existing = await repo.get_by_id(session_id)
237
233
 
238
234
  if existing:
239
235
  # Merge metadata if provided
@@ -254,9 +250,10 @@ async def ensure_session_with_metadata(
254
250
  await repo.upsert(existing)
255
251
  logger.debug(f"Updated session {session_id} (eval={is_eval}, metadata keys={list(merged_metadata.keys())})")
256
252
  else:
257
- # Create new session
253
+ # Create new session with the provided UUID as the id
258
254
  session = Session(
259
- name=session_id,
255
+ id=session_id, # Use the provided UUID as session id
256
+ name=session_id, # Default name to UUID, can be updated later with LLM-generated name
260
257
  mode=SessionMode.EVALUATION if is_eval else SessionMode.NORMAL,
261
258
  user_id=user_id,
262
259
  tenant_id=tenant_id,
@@ -513,7 +510,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
513
510
 
514
511
  # Load raw session history for proper pydantic-ai message_history format
515
512
  # This enables proper tool call/return pairing for LLM API compatibility
516
- from ....services.session import SessionMessageStore, session_to_pydantic_messages
513
+ from ....services.session import SessionMessageStore, session_to_pydantic_messages, audit_session_history
517
514
  from ....agentic.schema import get_system_prompt
518
515
 
519
516
  pydantic_message_history = None
@@ -535,6 +532,15 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
535
532
  system_prompt=agent_system_prompt,
536
533
  )
537
534
  logger.debug(f"Converted {len(raw_session_history)} session messages to {len(pydantic_message_history)} pydantic-ai messages (with system prompt)")
535
+
536
+ # Audit session history if enabled (for debugging)
537
+ audit_session_history(
538
+ session_id=context.session_id,
539
+ agent_name=schema_name or "default",
540
+ prompt=body.messages[-1].content if body.messages else "",
541
+ raw_session_history=raw_session_history,
542
+ pydantic_messages_count=len(pydantic_message_history),
543
+ )
538
544
  except Exception as e:
539
545
  logger.warning(f"Failed to load session history for message_history: {e}")
540
546
  # Fall back to old behavior (concatenated prompt)
@@ -576,24 +582,13 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
576
582
 
577
583
  # Streaming mode
578
584
  if body.stream:
579
- # Save user message before streaming starts
580
- if settings.postgres.enabled and context.session_id:
581
- user_message = {
582
- "role": "user",
583
- "content": body.messages[-1].content if body.messages else "",
584
- "timestamp": datetime.utcnow().isoformat(),
585
- }
586
- try:
587
- store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
588
- await store.store_session_messages(
589
- session_id=context.session_id,
590
- messages=[user_message],
591
- user_id=context.user_id,
592
- compress=False, # User messages are typically short
593
- )
594
- logger.debug(f"Saved user message to session {context.session_id}")
595
- except Exception as e:
596
- logger.error(f"Failed to save user message: {e}", exc_info=True)
585
+ # Save user message before streaming starts (using shared utility)
586
+ if context.session_id:
587
+ await save_user_message(
588
+ session_id=context.session_id,
589
+ user_id=context.user_id,
590
+ content=body.messages[-1].content if body.messages else "",
591
+ )
597
592
 
598
593
  return StreamingResponse(
599
594
  stream_openai_response_with_save(
@@ -321,7 +321,11 @@ class MetadataEvent(BaseModel):
321
321
  # Agent info
322
322
  agent_schema: str | None = Field(
323
323
  default=None,
324
- description="Name of the agent schema used for this response (e.g., 'rem', 'query-assistant')"
324
+ description="Name of the top-level agent schema (e.g., 'siggy', 'rem')"
325
+ )
326
+ responding_agent: str | None = Field(
327
+ default=None,
328
+ description="Name of the agent that produced this response (may differ from agent_schema if delegated via ask_agent)"
325
329
  )
326
330
 
327
331
  # Session info
@@ -165,12 +165,14 @@ async def stream_openai_response(
165
165
  pending_tool_completions: list[tuple[str, str]] = []
166
166
  # Track if metadata was registered via register_metadata tool
167
167
  metadata_registered = False
168
+ # Track which agent is actually responding (may be child agent if delegated)
169
+ responding_agent: str | None = None
168
170
  # Track pending tool calls with full data for persistence
169
171
  # Maps tool_id -> {"tool_name": str, "tool_id": str, "arguments": dict}
170
172
  pending_tool_data: dict[str, dict] = {}
171
173
 
172
174
  # Import context functions for multi-agent support
173
- from ....agentic.context import set_current_context
175
+ from ....agentic.context import set_current_context, set_event_sink
174
176
 
175
177
  # Set up context for multi-agent propagation
176
178
  # This allows child agents (via ask_agent tool) to access parent context
@@ -180,6 +182,12 @@ async def stream_openai_response(
180
182
  previous_context = get_current_context()
181
183
  set_current_context(agent_context)
182
184
 
185
+ # Set up event sink for child agent event proxying
186
+ # Child agents (via ask_agent) will push their events here
187
+ import asyncio
188
+ child_event_sink: asyncio.Queue = asyncio.Queue()
189
+ set_event_sink(child_event_sink)
190
+
183
191
  try:
184
192
  # Emit initial progress event
185
193
  current_step = 1
@@ -419,6 +427,73 @@ async def stream_openai_response(
419
427
  elif Agent.is_call_tools_node(node):
420
428
  async with node.stream(agent_run.ctx) as tools_stream:
421
429
  async for tool_event in tools_stream:
430
+ # First, drain any child agent events that were pushed while tool was executing
431
+ # This handles ask_agent streaming - child events are proxied here
432
+ while not child_event_sink.empty():
433
+ try:
434
+ child_event = child_event_sink.get_nowait()
435
+ event_type = child_event.get("type", "")
436
+ child_agent = child_event.get("agent_name", "child")
437
+
438
+ if event_type == "child_tool_start":
439
+ # Emit child tool start as a nested tool call
440
+ child_tool_id = f"call_{uuid.uuid4().hex[:8]}"
441
+ # Ensure arguments is a dict or None (not empty string)
442
+ child_args = child_event.get("arguments")
443
+ if not isinstance(child_args, dict):
444
+ child_args = None
445
+ yield format_sse_event(ToolCallEvent(
446
+ tool_name=f"{child_agent}:{child_event.get('tool_name', 'tool')}",
447
+ tool_id=child_tool_id,
448
+ status="started",
449
+ arguments=child_args,
450
+ ))
451
+ elif event_type == "child_content":
452
+ # Emit child content as assistant content
453
+ # Track which child agent is responding
454
+ responding_agent = child_agent
455
+ content = child_event.get("content", "")
456
+ if content:
457
+ content_chunk = ChatCompletionStreamResponse(
458
+ id=request_id,
459
+ created=created_at,
460
+ model=model,
461
+ choices=[
462
+ ChatCompletionStreamChoice(
463
+ index=0,
464
+ delta=ChatCompletionMessageDelta(
465
+ role="assistant" if is_first_chunk else None,
466
+ content=content,
467
+ ),
468
+ finish_reason=None,
469
+ )
470
+ ],
471
+ )
472
+ is_first_chunk = False
473
+ yield f"data: {content_chunk.model_dump_json()}\n\n"
474
+ elif event_type == "child_tool_result":
475
+ # Emit child tool completion
476
+ result = child_event.get("result", {})
477
+ # Emit metadata event for child agent if it registered metadata
478
+ if isinstance(result, dict) and result.get("_metadata_event"):
479
+ responding_agent = result.get("agent_schema") or responding_agent
480
+ yield format_sse_event(MetadataEvent(
481
+ message_id=message_id,
482
+ session_id=session_id,
483
+ agent_schema=agent_schema,
484
+ responding_agent=responding_agent,
485
+ confidence=result.get("confidence"),
486
+ extra={"risk_level": result.get("risk_level")} if result.get("risk_level") else None,
487
+ ))
488
+ yield format_sse_event(ToolCallEvent(
489
+ tool_name=f"{child_agent}:tool",
490
+ tool_id=f"call_{uuid.uuid4().hex[:8]}",
491
+ status="completed",
492
+ result=str(result)[:200] if result else None,
493
+ ))
494
+ except Exception as e:
495
+ logger.warning(f"Error processing child event: {e}")
496
+
422
497
  # Tool result event - emit completion
423
498
  if isinstance(tool_event, FunctionToolResultEvent):
424
499
  # Get the tool name/id from the pending queue (FIFO)
@@ -451,6 +526,10 @@ async def stream_openai_response(
451
526
  registered_recommended_action = result_content.get("recommended_action")
452
527
  # Extra fields
453
528
  registered_extra = result_content.get("extra")
529
+ # Only set responding_agent if not already set by child events
530
+ # Child agents should take precedence - they're the actual responders
531
+ if not responding_agent:
532
+ responding_agent = result_content.get("agent_schema")
454
533
 
455
534
  logger.info(
456
535
  f"📊 Metadata registered: confidence={registered_confidence}, "
@@ -477,6 +556,7 @@ async def stream_openai_response(
477
556
  in_reply_to=in_reply_to,
478
557
  session_id=session_id,
479
558
  agent_schema=agent_schema,
559
+ responding_agent=responding_agent,
480
560
  session_name=registered_session_name,
481
561
  confidence=registered_confidence,
482
562
  sources=registered_sources,
@@ -502,6 +582,11 @@ async def stream_openai_response(
502
582
  del pending_tool_data[tool_id]
503
583
 
504
584
  if not is_metadata_event:
585
+ # NOTE: text_response fallback is DISABLED
586
+ # Child agents now stream content via child_content events (above)
587
+ # which provides real-time streaming. The text_response in tool
588
+ # result would duplicate that content, so we skip it entirely.
589
+
505
590
  # Normal tool completion - emit ToolCallEvent
506
591
  # For finalize_intake, send full result dict for frontend
507
592
  if tool_name == "finalize_intake" and isinstance(result_content, dict):
@@ -624,6 +709,7 @@ async def stream_openai_response(
624
709
  in_reply_to=in_reply_to,
625
710
  session_id=session_id,
626
711
  agent_schema=agent_schema,
712
+ responding_agent=responding_agent,
627
713
  confidence=1.0, # Default to 100% confidence
628
714
  model_version=model,
629
715
  latency_ms=latency_ms,
@@ -716,6 +802,8 @@ async def stream_openai_response(
716
802
  yield "data: [DONE]\n\n"
717
803
 
718
804
  finally:
805
+ # Clean up event sink for multi-agent streaming
806
+ set_event_sink(None)
719
807
  # Restore previous context for multi-agent support
720
808
  # This ensures nested agent calls don't pollute the parent's context
721
809
  if agent_context is not None:
@@ -823,6 +911,47 @@ async def stream_minimal_simulator(
823
911
  yield sse_string
824
912
 
825
913
 
914
+ async def save_user_message(
915
+ session_id: str,
916
+ user_id: str | None,
917
+ content: str,
918
+ ) -> None:
919
+ """
920
+ Save user message to database before streaming.
921
+
922
+ This is a shared utility used by both API and CLI to ensure consistent
923
+ user message storage.
924
+
925
+ Args:
926
+ session_id: Session ID for message storage
927
+ user_id: User ID for message storage
928
+ content: The user's message content
929
+ """
930
+ from ....utils.date_utils import utc_now, to_iso
931
+ from ....services.session import SessionMessageStore
932
+ from ....settings import settings
933
+
934
+ if not settings.postgres.enabled or not session_id:
935
+ return
936
+
937
+ user_msg = {
938
+ "role": "user",
939
+ "content": content,
940
+ "timestamp": to_iso(utc_now()),
941
+ }
942
+ try:
943
+ store = SessionMessageStore(user_id=user_id or settings.test.effective_user_id)
944
+ await store.store_session_messages(
945
+ session_id=session_id,
946
+ messages=[user_msg],
947
+ user_id=user_id,
948
+ compress=False,
949
+ )
950
+ logger.debug(f"Saved user message to session {session_id}")
951
+ except Exception as e:
952
+ logger.error(f"Failed to save user message: {e}", exc_info=True)
953
+
954
+
826
955
  async def stream_openai_response_with_save(
827
956
  agent: Agent,
828
957
  prompt: str,
@@ -842,6 +971,9 @@ async def stream_openai_response_with_save(
842
971
  This accumulates all text content during streaming and saves it to the database
843
972
  after the stream completes.
844
973
 
974
+ NOTE: Call save_user_message() BEFORE this function to save the user's message.
975
+ This function only saves tool calls and assistant responses.
976
+
845
977
  Args:
846
978
  agent: Pydantic AI agent instance
847
979
  prompt: User prompt
@@ -899,6 +1031,9 @@ async def stream_openai_response_with_save(
899
1031
  delta = data["choices"][0].get("delta", {})
900
1032
  content = delta.get("content")
901
1033
  if content:
1034
+ # DEBUG: Check for [Calling markers in content
1035
+ if "[Calling" in content:
1036
+ logger.warning(f"DEBUG: Found [Calling in content chunk: {repr(content[:100])}")
902
1037
  accumulated_content.append(content)
903
1038
  except (json.JSONDecodeError, KeyError, IndexError):
904
1039
  pass # Skip non-JSON or malformed chunks
@@ -931,8 +1066,34 @@ async def stream_openai_response_with_save(
931
1066
  messages_to_store.append(tool_message)
932
1067
 
933
1068
  # Then store assistant text response (if any)
1069
+ # Priority: direct TextPartDelta content > tool call text_response
1070
+ # When an agent delegates via ask_agent, the child's text_response becomes
1071
+ # the parent's assistant response (the parent is just orchestrating)
1072
+ full_content = None
1073
+
934
1074
  if accumulated_content:
935
1075
  full_content = "".join(accumulated_content)
1076
+ logger.warning(f"DEBUG: Using accumulated_content ({len(accumulated_content)} chunks, {len(full_content)} chars)")
1077
+ logger.warning(f"DEBUG: First 200 chars: {repr(full_content[:200])}")
1078
+ else:
1079
+ logger.warning("DEBUG: accumulated_content is empty, checking text_response fallback")
1080
+ # No direct text from TextPartDelta - check tool results for text_response
1081
+ # This handles multi-agent delegation where child agent output is the response
1082
+ for tool_call in tool_calls:
1083
+ if not tool_call:
1084
+ continue
1085
+ result = tool_call.get("result")
1086
+ if isinstance(result, dict) and result.get("text_response"):
1087
+ text_response = result["text_response"]
1088
+ if text_response and str(text_response).strip():
1089
+ full_content = str(text_response)
1090
+ logger.debug(
1091
+ f"Using text_response from {tool_call.get('tool_name', 'tool')} "
1092
+ f"({len(full_content)} chars) as assistant message"
1093
+ )
1094
+ break
1095
+
1096
+ if full_content:
936
1097
  assistant_message = {
937
1098
  "id": message_id, # Use pre-generated ID for consistency with metadata event
938
1099
  "role": "assistant",
@@ -954,7 +1115,7 @@ async def stream_openai_response_with_save(
954
1115
  )
955
1116
  logger.debug(
956
1117
  f"Saved {len(tool_calls)} tool calls and "
957
- f"{'assistant response' if accumulated_content else 'no text'} "
1118
+ f"{'assistant response' if full_content else 'no text'} "
958
1119
  f"to session {session_id}"
959
1120
  )
960
1121
  except Exception as e:
@@ -0,0 +1,18 @@
1
+ """
2
+ Common models shared across API routers.
3
+ """
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ErrorResponse(BaseModel):
9
+ """Standard error response format for HTTPException errors.
10
+
11
+ This is different from FastAPI's HTTPValidationError which is used
12
+ for Pydantic validation failures (422 errors with loc/msg/type array).
13
+
14
+ HTTPException errors return this simpler format:
15
+ {"detail": "Error message here"}
16
+ """
17
+
18
+ detail: str = Field(description="Error message describing what went wrong")
rem/api/routers/dev.py CHANGED
@@ -11,6 +11,7 @@ Endpoints:
11
11
  from fastapi import APIRouter, HTTPException, Request
12
12
  from loguru import logger
13
13
 
14
+ from .common import ErrorResponse
14
15
  from ...settings import settings
15
16
 
16
17
  router = APIRouter(prefix="/api/dev", tags=["dev"])
@@ -45,7 +46,12 @@ def verify_dev_token(token: str) -> bool:
45
46
  return token == expected
46
47
 
47
48
 
48
- @router.get("/token")
49
+ @router.get(
50
+ "/token",
51
+ responses={
52
+ 401: {"model": ErrorResponse, "description": "Dev tokens not available in production"},
53
+ },
54
+ )
49
55
  async def get_dev_token(request: Request):
50
56
  """
51
57
  Get a development token for testing (non-production only).
@@ -63,6 +63,8 @@ from fastapi import APIRouter, Header, HTTPException, Request, Response
63
63
  from loguru import logger
64
64
  from pydantic import BaseModel, Field
65
65
 
66
+ from .common import ErrorResponse
67
+
66
68
  from ..deps import get_user_id_from_request
67
69
  from ...models.entities import Feedback
68
70
  from ...services.postgres import Repository
@@ -121,7 +123,13 @@ class FeedbackResponse(BaseModel):
121
123
  # =============================================================================
122
124
 
123
125
 
124
- @router.post("/messages/feedback", response_model=FeedbackResponse)
126
+ @router.post(
127
+ "/messages/feedback",
128
+ response_model=FeedbackResponse,
129
+ responses={
130
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
131
+ },
132
+ )
125
133
  async def submit_feedback(
126
134
  request: Request,
127
135
  response: Response,