remdb 0.3.14__py3-none-any.whl → 0.3.157__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.
Files changed (112) hide show
  1. rem/agentic/README.md +76 -0
  2. rem/agentic/__init__.py +15 -0
  3. rem/agentic/agents/__init__.py +32 -2
  4. rem/agentic/agents/agent_manager.py +310 -0
  5. rem/agentic/agents/sse_simulator.py +502 -0
  6. rem/agentic/context.py +51 -27
  7. rem/agentic/context_builder.py +5 -3
  8. rem/agentic/llm_provider_models.py +301 -0
  9. rem/agentic/mcp/tool_wrapper.py +155 -18
  10. rem/agentic/otel/setup.py +93 -4
  11. rem/agentic/providers/phoenix.py +371 -108
  12. rem/agentic/providers/pydantic_ai.py +280 -57
  13. rem/agentic/schema.py +361 -21
  14. rem/agentic/tools/rem_tools.py +3 -3
  15. rem/api/README.md +215 -1
  16. rem/api/deps.py +255 -0
  17. rem/api/main.py +132 -40
  18. rem/api/mcp_router/resources.py +1 -1
  19. rem/api/mcp_router/server.py +28 -5
  20. rem/api/mcp_router/tools.py +555 -7
  21. rem/api/routers/admin.py +494 -0
  22. rem/api/routers/auth.py +278 -4
  23. rem/api/routers/chat/completions.py +402 -20
  24. rem/api/routers/chat/models.py +88 -10
  25. rem/api/routers/chat/otel_utils.py +33 -0
  26. rem/api/routers/chat/sse_events.py +542 -0
  27. rem/api/routers/chat/streaming.py +697 -45
  28. rem/api/routers/dev.py +81 -0
  29. rem/api/routers/feedback.py +268 -0
  30. rem/api/routers/messages.py +473 -0
  31. rem/api/routers/models.py +78 -0
  32. rem/api/routers/query.py +360 -0
  33. rem/api/routers/shared_sessions.py +406 -0
  34. rem/auth/__init__.py +13 -3
  35. rem/auth/middleware.py +186 -22
  36. rem/auth/providers/__init__.py +4 -1
  37. rem/auth/providers/email.py +215 -0
  38. rem/cli/commands/README.md +237 -64
  39. rem/cli/commands/cluster.py +1808 -0
  40. rem/cli/commands/configure.py +4 -7
  41. rem/cli/commands/db.py +386 -143
  42. rem/cli/commands/experiments.py +468 -76
  43. rem/cli/commands/process.py +14 -8
  44. rem/cli/commands/schema.py +97 -50
  45. rem/cli/commands/session.py +336 -0
  46. rem/cli/dreaming.py +2 -2
  47. rem/cli/main.py +29 -6
  48. rem/config.py +10 -3
  49. rem/models/core/core_model.py +7 -1
  50. rem/models/core/experiment.py +58 -14
  51. rem/models/core/rem_query.py +5 -2
  52. rem/models/entities/__init__.py +25 -0
  53. rem/models/entities/domain_resource.py +38 -0
  54. rem/models/entities/feedback.py +123 -0
  55. rem/models/entities/message.py +30 -1
  56. rem/models/entities/ontology.py +1 -1
  57. rem/models/entities/ontology_config.py +1 -1
  58. rem/models/entities/session.py +83 -0
  59. rem/models/entities/shared_session.py +180 -0
  60. rem/models/entities/subscriber.py +175 -0
  61. rem/models/entities/user.py +1 -0
  62. rem/registry.py +10 -4
  63. rem/schemas/agents/core/agent-builder.yaml +134 -0
  64. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  65. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  66. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  67. rem/schemas/agents/rem.yaml +7 -3
  68. rem/services/__init__.py +3 -1
  69. rem/services/content/service.py +92 -19
  70. rem/services/email/__init__.py +10 -0
  71. rem/services/email/service.py +459 -0
  72. rem/services/email/templates.py +360 -0
  73. rem/services/embeddings/api.py +4 -4
  74. rem/services/embeddings/worker.py +16 -16
  75. rem/services/phoenix/client.py +154 -14
  76. rem/services/postgres/README.md +197 -15
  77. rem/services/postgres/__init__.py +2 -1
  78. rem/services/postgres/diff_service.py +547 -0
  79. rem/services/postgres/pydantic_to_sqlalchemy.py +470 -140
  80. rem/services/postgres/repository.py +132 -0
  81. rem/services/postgres/schema_generator.py +205 -4
  82. rem/services/postgres/service.py +6 -6
  83. rem/services/rem/parser.py +44 -9
  84. rem/services/rem/service.py +36 -2
  85. rem/services/session/compression.py +137 -51
  86. rem/services/session/reload.py +15 -8
  87. rem/settings.py +515 -27
  88. rem/sql/background_indexes.sql +21 -16
  89. rem/sql/migrations/001_install.sql +387 -54
  90. rem/sql/migrations/002_install_models.sql +2304 -377
  91. rem/sql/migrations/003_optional_extensions.sql +326 -0
  92. rem/sql/migrations/004_cache_system.sql +548 -0
  93. rem/sql/migrations/005_schema_update.sql +145 -0
  94. rem/utils/README.md +45 -0
  95. rem/utils/__init__.py +18 -0
  96. rem/utils/date_utils.py +2 -2
  97. rem/utils/files.py +157 -1
  98. rem/utils/model_helpers.py +156 -1
  99. rem/utils/schema_loader.py +220 -22
  100. rem/utils/sql_paths.py +146 -0
  101. rem/utils/sql_types.py +3 -1
  102. rem/utils/vision.py +1 -1
  103. rem/workers/__init__.py +3 -1
  104. rem/workers/db_listener.py +579 -0
  105. rem/workers/unlogged_maintainer.py +463 -0
  106. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/METADATA +340 -229
  107. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/RECORD +109 -80
  108. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/WHEEL +1 -1
  109. rem/sql/002_install_models.sql +0 -1068
  110. rem/sql/install_models.sql +0 -1051
  111. rem/sql/migrations/003_seed_default_user.sql +0 -48
  112. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,473 @@
1
+ """
2
+ Messages and Sessions endpoints.
3
+
4
+ Provides endpoints for:
5
+ - Listing and filtering messages by date, user_id, session_id
6
+ - Creating and managing sessions (normal or evaluation mode)
7
+
8
+ Endpoints:
9
+ GET /api/v1/messages - List messages with filters
10
+ GET /api/v1/messages/{id} - Get a specific message
11
+
12
+ GET /api/v1/sessions - List sessions
13
+ POST /api/v1/sessions - Create a session
14
+ GET /api/v1/sessions/{id} - Get a specific session
15
+ PUT /api/v1/sessions/{id} - Update a session
16
+ """
17
+
18
+ from datetime import datetime
19
+ from typing import Literal
20
+ from uuid import UUID
21
+
22
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
23
+ from loguru import logger
24
+ from pydantic import BaseModel, Field
25
+
26
+ from ..deps import (
27
+ get_current_user,
28
+ get_user_filter,
29
+ is_admin,
30
+ require_admin,
31
+ require_auth,
32
+ )
33
+ from ...models.entities import Message, Session, SessionMode
34
+ from ...services.postgres import Repository, get_postgres_service
35
+ from ...settings import settings
36
+ from ...utils.date_utils import parse_iso, utc_now
37
+
38
+ router = APIRouter(prefix="/api/v1")
39
+
40
+
41
+ # =============================================================================
42
+ # Request/Response Models
43
+ # =============================================================================
44
+
45
+
46
+ class MessageListResponse(BaseModel):
47
+ """Response for message list endpoint."""
48
+
49
+ object: Literal["list"] = "list"
50
+ data: list[Message]
51
+ total: int
52
+ has_more: bool
53
+
54
+
55
+ class SessionCreateRequest(BaseModel):
56
+ """Request to create a new session."""
57
+
58
+ name: str = Field(description="Session name/identifier")
59
+ mode: SessionMode = Field(
60
+ default=SessionMode.NORMAL, description="Session mode: 'normal' or 'evaluation'"
61
+ )
62
+ description: str | None = Field(default=None, description="Session description")
63
+ original_trace_id: str | None = Field(
64
+ default=None,
65
+ description="For evaluation: ID of the original session being evaluated",
66
+ )
67
+ settings_overrides: dict | None = Field(
68
+ default=None,
69
+ description="Settings overrides (model, temperature, max_tokens, system_prompt)",
70
+ )
71
+ prompt: str | None = Field(default=None, description="Custom prompt for this session")
72
+ agent_schema_uri: str | None = Field(
73
+ default=None, description="Agent schema URI for this session"
74
+ )
75
+
76
+
77
+ class SessionUpdateRequest(BaseModel):
78
+ """Request to update a session."""
79
+
80
+ description: str | None = None
81
+ settings_overrides: dict | None = None
82
+ prompt: str | None = None
83
+ message_count: int | None = None
84
+ total_tokens: int | None = None
85
+
86
+
87
+ class SessionListResponse(BaseModel):
88
+ """Response for session list endpoint (deprecated, use SessionsQueryResponse)."""
89
+
90
+ object: Literal["list"] = "list"
91
+ data: list[Session]
92
+ total: int
93
+ has_more: bool
94
+
95
+
96
+ class PaginationMetadata(BaseModel):
97
+ """Pagination metadata for paginated responses."""
98
+
99
+ total: int = Field(description="Total number of records matching filters")
100
+ page: int = Field(description="Current page number (1-indexed)")
101
+ page_size: int = Field(description="Number of records per page")
102
+ total_pages: int = Field(description="Total number of pages")
103
+ has_next: bool = Field(description="Whether there are more pages after this one")
104
+ has_previous: bool = Field(description="Whether there are pages before this one")
105
+
106
+
107
+ class SessionsQueryResponse(BaseModel):
108
+ """Response for paginated sessions query."""
109
+
110
+ object: Literal["list"] = "list"
111
+ data: list[Session] = Field(description="List of sessions for the current page")
112
+ metadata: PaginationMetadata = Field(description="Pagination metadata")
113
+
114
+
115
+ # =============================================================================
116
+ # Messages Endpoints
117
+ # =============================================================================
118
+
119
+
120
+ @router.get("/messages", response_model=MessageListResponse, tags=["messages"])
121
+ async def list_messages(
122
+ request: Request,
123
+ mine: bool = Query(default=False, description="Only show my messages (uses JWT identity)"),
124
+ user_id: str | None = Query(default=None, description="Filter by user ID (admin only for cross-user)"),
125
+ session_id: str | None = Query(default=None, description="Filter by session ID"),
126
+ start_date: str | None = Query(
127
+ default=None, description="Filter messages after this ISO date"
128
+ ),
129
+ end_date: str | None = Query(
130
+ default=None, description="Filter messages before this ISO date"
131
+ ),
132
+ message_type: str | None = Query(
133
+ default=None, description="Filter by message type (user, assistant, system, tool)"
134
+ ),
135
+ limit: int = Query(default=50, ge=1, le=100, description="Max results to return"),
136
+ offset: int = Query(default=0, ge=0, description="Offset for pagination"),
137
+ x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
138
+ ) -> MessageListResponse:
139
+ """
140
+ List messages with optional filters.
141
+
142
+ Access Control:
143
+ - Regular users: Only see their own messages
144
+ - Admin users: Can filter by any user_id or see all messages
145
+ - mine=true: Forces filter to current user (useful for admins to see only their own)
146
+
147
+ Filters can be combined:
148
+ - mine: Only show messages owned by current JWT user (overrides user_id)
149
+ - user_id: Filter by the user who created/owns the message (admin only for cross-user)
150
+ - session_id: Filter by conversation session
151
+ - start_date/end_date: Filter by creation time range (ISO 8601 format)
152
+ - message_type: Filter by role (user, assistant, system, tool)
153
+
154
+ Returns paginated results ordered by created_at descending.
155
+ """
156
+ if not settings.postgres.enabled:
157
+ raise HTTPException(status_code=503, detail="Database not enabled")
158
+
159
+ repo = Repository(Message, table_name="messages")
160
+
161
+ # If mine=true, force filter to current user's ID from JWT
162
+ effective_user_id = user_id
163
+ if mine:
164
+ current_user = get_current_user(request)
165
+ if current_user:
166
+ effective_user_id = current_user.get("id")
167
+
168
+ # Build user-scoped filters (admin can see all, regular users see only their own)
169
+ filters = await get_user_filter(request, x_user_id=effective_user_id, x_tenant_id=x_tenant_id)
170
+
171
+ # Apply optional filters
172
+ if session_id:
173
+ filters["session_id"] = session_id
174
+ if message_type:
175
+ filters["message_type"] = message_type
176
+
177
+ # For date filtering, we need custom SQL (not supported by basic Repository)
178
+ # For now, fetch all matching base filters and filter in Python
179
+ # TODO: Extend Repository to support date range filters
180
+ messages = await repo.find(
181
+ filters,
182
+ order_by="created_at DESC",
183
+ limit=limit + 1, # Fetch one extra to determine has_more
184
+ offset=offset,
185
+ )
186
+
187
+ # Apply date filters in Python if provided
188
+ if start_date or end_date:
189
+ start_dt = parse_iso(start_date) if start_date else None
190
+ end_dt = parse_iso(end_date) if end_date else None
191
+
192
+ filtered = []
193
+ for msg in messages:
194
+ if start_dt and msg.created_at < start_dt:
195
+ continue
196
+ if end_dt and msg.created_at > end_dt:
197
+ continue
198
+ filtered.append(msg)
199
+ messages = filtered
200
+
201
+ # Determine if there are more results
202
+ has_more = len(messages) > limit
203
+ if has_more:
204
+ messages = messages[:limit]
205
+
206
+ # Get total count for pagination info
207
+ total = await repo.count(filters)
208
+
209
+ return MessageListResponse(data=messages, total=total, has_more=has_more)
210
+
211
+
212
+ @router.get("/messages/{message_id}", response_model=Message, tags=["messages"])
213
+ async def get_message(
214
+ request: Request,
215
+ message_id: str,
216
+ x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
217
+ ) -> Message:
218
+ """
219
+ Get a specific message by ID.
220
+
221
+ Access Control:
222
+ - Regular users: Only access their own messages
223
+ - Admin users: Can access any message
224
+
225
+ Args:
226
+ message_id: UUID of the message
227
+
228
+ Returns:
229
+ Message object if found
230
+
231
+ Raises:
232
+ 404: Message not found
233
+ 403: Access denied (not owner and not admin)
234
+ """
235
+ if not settings.postgres.enabled:
236
+ raise HTTPException(status_code=503, detail="Database not enabled")
237
+
238
+ repo = Repository(Message, table_name="messages")
239
+ message = await repo.get_by_id(message_id, x_tenant_id)
240
+
241
+ if not message:
242
+ raise HTTPException(status_code=404, detail=f"Message '{message_id}' not found")
243
+
244
+ # Check access: admin or owner
245
+ current_user = get_current_user(request)
246
+ if not is_admin(current_user):
247
+ user_id = current_user.get("id") if current_user else None
248
+ if message.user_id and message.user_id != user_id:
249
+ raise HTTPException(status_code=403, detail="Access denied: not owner")
250
+
251
+ return message
252
+
253
+
254
+ # =============================================================================
255
+ # Sessions Endpoints
256
+ # =============================================================================
257
+
258
+
259
+ @router.get("/sessions", response_model=SessionsQueryResponse, tags=["sessions"])
260
+ async def list_sessions(
261
+ request: Request,
262
+ user_id: str | None = Query(default=None, description="Filter by user ID (admin only for cross-user)"),
263
+ mode: SessionMode | None = Query(default=None, description="Filter by session mode"),
264
+ page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
265
+ page_size: int = Query(default=50, ge=1, le=100, description="Number of results per page"),
266
+ x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
267
+ ) -> SessionsQueryResponse:
268
+ """
269
+ List sessions with optional filters and page-based pagination.
270
+
271
+ Access Control:
272
+ - Regular users: Only see their own sessions
273
+ - Admin users: Can filter by any user_id or see all sessions
274
+
275
+ Filters:
276
+ - user_id: Filter by session owner (admin only for cross-user)
277
+ - mode: Filter by session mode (normal or evaluation)
278
+
279
+ Pagination:
280
+ - page: Page number (1-indexed, default: 1)
281
+ - page_size: Number of sessions per page (default: 50, max: 100)
282
+
283
+ Returns paginated results ordered by created_at descending with pagination metadata.
284
+ """
285
+ if not settings.postgres.enabled:
286
+ raise HTTPException(status_code=503, detail="Database not enabled")
287
+
288
+ repo = Repository(Session, table_name="sessions")
289
+
290
+ # Build user-scoped filters (admin can see all, regular users see only their own)
291
+ filters = await get_user_filter(request, x_user_id=user_id, x_tenant_id=x_tenant_id)
292
+ if mode:
293
+ filters["mode"] = mode.value
294
+
295
+ # Use CTE-based pagination with ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)
296
+ result = await repo.find_paginated(
297
+ filters,
298
+ page=page,
299
+ page_size=page_size,
300
+ order_by="created_at DESC",
301
+ partition_by="user_id",
302
+ )
303
+
304
+ return SessionsQueryResponse(
305
+ data=result["data"],
306
+ metadata=PaginationMetadata(
307
+ total=result["total"],
308
+ page=result["page"],
309
+ page_size=result["page_size"],
310
+ total_pages=result["total_pages"],
311
+ has_next=result["has_next"],
312
+ has_previous=result["has_previous"],
313
+ ),
314
+ )
315
+
316
+
317
+ @router.post("/sessions", response_model=Session, status_code=201, tags=["sessions"])
318
+ async def create_session(
319
+ request_body: SessionCreateRequest,
320
+ user: dict = Depends(require_admin),
321
+ x_user_id: str = Header(alias="X-User-Id", default="default"),
322
+ x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
323
+ ) -> Session:
324
+ """
325
+ Create a new session.
326
+
327
+ **Requires admin role.**
328
+
329
+ For normal sessions, only name is required.
330
+ For evaluation sessions, you can specify:
331
+ - original_trace_id: The session being re-evaluated
332
+ - settings_overrides: Model, temperature, prompt overrides
333
+ - prompt: Custom prompt to test
334
+
335
+ Headers:
336
+ - X-User-Id: User identifier (owner of the session)
337
+ - X-Tenant-Id: Tenant identifier
338
+
339
+ Returns:
340
+ Created session object
341
+ """
342
+ if not settings.postgres.enabled:
343
+ raise HTTPException(status_code=503, detail="Database not enabled")
344
+
345
+ # Admin can specify x_user_id, or default to their own
346
+ effective_user_id = x_user_id if x_user_id != "default" else user.get("id", "default")
347
+
348
+ session = Session(
349
+ name=request_body.name,
350
+ mode=request_body.mode,
351
+ description=request_body.description,
352
+ original_trace_id=request_body.original_trace_id,
353
+ settings_overrides=request_body.settings_overrides,
354
+ prompt=request_body.prompt,
355
+ agent_schema_uri=request_body.agent_schema_uri,
356
+ user_id=effective_user_id,
357
+ tenant_id=x_tenant_id,
358
+ )
359
+
360
+ repo = Repository(Session, table_name="sessions")
361
+ result = await repo.upsert(session)
362
+
363
+ logger.info(
364
+ f"Admin {user.get('email')} created session '{session.name}' "
365
+ f"(mode={session.mode}) for user={effective_user_id}"
366
+ )
367
+
368
+ return result # type: ignore
369
+
370
+
371
+ @router.get("/sessions/{session_id}", response_model=Session, tags=["sessions"])
372
+ async def get_session(
373
+ request: Request,
374
+ session_id: str,
375
+ x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
376
+ ) -> Session:
377
+ """
378
+ Get a specific session by ID.
379
+
380
+ Access Control:
381
+ - Regular users: Only access their own sessions
382
+ - Admin users: Can access any session
383
+
384
+ Args:
385
+ session_id: UUID or name of the session
386
+
387
+ Returns:
388
+ Session object if found
389
+
390
+ Raises:
391
+ 404: Session not found
392
+ 403: Access denied (not owner and not admin)
393
+ """
394
+ if not settings.postgres.enabled:
395
+ raise HTTPException(status_code=503, detail="Database not enabled")
396
+
397
+ repo = Repository(Session, table_name="sessions")
398
+ session = await repo.get_by_id(session_id, x_tenant_id)
399
+
400
+ if not session:
401
+ # Try finding by name
402
+ sessions = await repo.find({"name": session_id, "tenant_id": x_tenant_id}, limit=1)
403
+ if sessions:
404
+ session = sessions[0]
405
+ else:
406
+ raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
407
+
408
+ # Check access: admin or owner
409
+ current_user = get_current_user(request)
410
+ if not is_admin(current_user):
411
+ user_id = current_user.get("id") if current_user else None
412
+ if session.user_id and session.user_id != user_id:
413
+ raise HTTPException(status_code=403, detail="Access denied: not owner")
414
+
415
+ return session
416
+
417
+
418
+ @router.put("/sessions/{session_id}", response_model=Session, tags=["sessions"])
419
+ async def update_session(
420
+ request: Request,
421
+ session_id: str,
422
+ request_body: SessionUpdateRequest,
423
+ x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
424
+ ) -> Session:
425
+ """
426
+ Update an existing session.
427
+
428
+ Access Control:
429
+ - Regular users: Only update their own sessions
430
+ - Admin users: Can update any session
431
+
432
+ Allows updating:
433
+ - description
434
+ - settings_overrides
435
+ - prompt
436
+ - message_count (typically updated automatically)
437
+ - total_tokens (typically updated automatically)
438
+
439
+ Args:
440
+ session_id: UUID of the session
441
+
442
+ Returns:
443
+ Updated session object
444
+
445
+ Raises:
446
+ 404: Session not found
447
+ 403: Access denied (not owner and not admin)
448
+ """
449
+ if not settings.postgres.enabled:
450
+ raise HTTPException(status_code=503, detail="Database not enabled")
451
+
452
+ repo = Repository(Session, table_name="sessions")
453
+ session = await repo.get_by_id(session_id, x_tenant_id)
454
+
455
+ if not session:
456
+ raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
457
+
458
+ # Check access: admin or owner
459
+ current_user = get_current_user(request)
460
+ if not is_admin(current_user):
461
+ user_id = current_user.get("id") if current_user else None
462
+ if session.user_id and session.user_id != user_id:
463
+ raise HTTPException(status_code=403, detail="Access denied: not owner")
464
+
465
+ # Apply updates
466
+ update_data = request_body.model_dump(exclude_none=True)
467
+ for field, value in update_data.items():
468
+ setattr(session, field, value)
469
+
470
+ session.updated_at = utc_now()
471
+
472
+ result = await repo.update(session)
473
+ return result
@@ -0,0 +1,78 @@
1
+ """
2
+ Models endpoint - List available LLM models.
3
+
4
+ Provides an OpenAI-compatible /v1/models endpoint listing all supported
5
+ LLM providers and their models using the provider:model_id syntax.
6
+
7
+ Endpoint:
8
+ GET /api/v1/models - List all available models
9
+
10
+ Response format matches OpenAI API for drop-in compatibility.
11
+ """
12
+
13
+ from typing import Literal
14
+
15
+ from fastapi import APIRouter, HTTPException
16
+ from pydantic import BaseModel, Field
17
+
18
+ from rem.agentic.llm_provider_models import (
19
+ ModelInfo,
20
+ AVAILABLE_MODELS,
21
+ ALLOWED_MODEL_IDS,
22
+ is_valid_model,
23
+ get_valid_model_or_default,
24
+ get_model_by_id,
25
+ )
26
+
27
+ router = APIRouter(prefix="/api/v1", tags=["models"])
28
+
29
+ # Re-export for backwards compatibility
30
+ __all__ = [
31
+ "ModelInfo",
32
+ "AVAILABLE_MODELS",
33
+ "ALLOWED_MODEL_IDS",
34
+ "is_valid_model",
35
+ "get_valid_model_or_default",
36
+ "get_model_by_id",
37
+ ]
38
+
39
+
40
+ class ModelsResponse(BaseModel):
41
+ """Response from /models endpoint."""
42
+
43
+ object: Literal["list"] = "list"
44
+ data: list[ModelInfo]
45
+
46
+
47
+ @router.get("/models", response_model=ModelsResponse)
48
+ async def list_models() -> ModelsResponse:
49
+ """
50
+ List all available LLM models.
51
+
52
+ Returns models from all supported providers (OpenAI, Anthropic, Google, Cerebras)
53
+ with the provider:model_id naming convention.
54
+
55
+ Response format is OpenAI-compatible for drop-in replacement.
56
+ """
57
+ return ModelsResponse(data=AVAILABLE_MODELS)
58
+
59
+
60
+ @router.get("/models/{model_id:path}", response_model=ModelInfo)
61
+ async def get_model(model_id: str) -> ModelInfo:
62
+ """
63
+ Get information about a specific model.
64
+
65
+ Args:
66
+ model_id: Model identifier in provider:model format (e.g., "openai:gpt-4.1")
67
+
68
+ Returns:
69
+ Model information if found
70
+
71
+ Raises:
72
+ HTTPException: 404 if model not found
73
+ """
74
+ model = get_model_by_id(model_id)
75
+ if model:
76
+ return model
77
+
78
+ raise HTTPException(status_code=404, detail=f"Model '{model_id}' not found")