"""AI Assistant plugin routes — providers, models, presets, agents, sessions, messages, tools, and streaming chat. """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db, set_tenant_context from app.core.visibility import apply_visibility_filter from app.deps import get_current_user, require_permission from app.plugins.builtins.ai_assistant.models import ( AIAgent, AIChatFolder, AIModel, AIPreset, AIProvider, ) from app.plugins.builtins.ai_assistant.schemas import ( AIAgentCreate, AIAgentUpdate, AIModelCreate, AIModelUpdate, AIPresetCreate, AIPresetUpdate, AIProviderCreate, AIProviderUpdate, ChatFolderCreate, ChatFolderUpdate, ChatSendRequest, ) from app.plugins.builtins.ai_assistant.services import ( agent_to_response, folder_to_response, get_agent_by_id, get_comm_messages, get_default_agent, get_preset_by_id, get_provider_by_id, model_to_response, preset_to_response, provider_to_response, stream_chat_comm, ) from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry router = APIRouter(prefix="/api/v1/ai", tags=["ai-assistant"]) # ─── Providers ─── @router.get("/providers", dependencies=[Depends(require_permission("ai:config"))]) async def list_providers( current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) result = await db.execute( select(AIProvider).where(AIProvider.tenant_id == tenant_id) ) providers = list(result.scalars().all()) return [provider_to_response(p) for p in providers] @router.post("/providers", dependencies=[Depends(require_permission("ai:config"))]) async def create_provider( data: AIProviderCreate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) await set_tenant_context(db, tenant_id) # If is_default, unset other defaults if data.is_default: existing = await db.execute( select(AIProvider) .where(AIProvider.tenant_id == tenant_id) .where(AIProvider.is_default.is_(True)) ) for p in existing.scalars().all(): p.is_default = False provider = AIProvider( name=data.name, provider_type=data.provider_type, api_key=data.api_key, base_url=data.base_url, is_active=data.is_active, is_default=data.is_default, config=data.config, tenant_id=tenant_id, ) db.add(provider) await db.flush() await db.refresh(provider) await db.commit() return provider_to_response(provider) @router.put("/providers/{provider_id}", dependencies=[Depends(require_permission("ai:config"))]) async def update_provider( provider_id: str, data: AIProviderUpdate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) provider = await get_provider_by_id(db, uuid.UUID(provider_id), tenant_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") if data.is_default: existing = await db.execute( select(AIProvider) .where(AIProvider.tenant_id == tenant_id) .where(AIProvider.is_default.is_(True)) .where(AIProvider.id != provider.id) ) for p in existing.scalars().all(): p.is_default = False for field, val in data.model_dump(exclude_unset=True).items(): setattr(provider, field, val) await db.flush() await db.refresh(provider) await db.commit() return provider_to_response(provider) @router.delete("/providers/{provider_id}", dependencies=[Depends(require_permission("ai:config"))]) async def delete_provider( provider_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) provider = await get_provider_by_id(db, uuid.UUID(provider_id), tenant_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") await db.delete(provider) await db.commit() return {"ok": True} # ─── Models ─── @router.get("/models", dependencies=[Depends(require_permission("ai:config"))]) async def list_models( provider_id: str | None = Query(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) stmt = select(AIModel).where(AIModel.tenant_id == tenant_id) if provider_id: stmt = stmt.where(AIModel.provider_id == uuid.UUID(provider_id)) result = await db.execute(stmt) models = list(result.scalars().all()) return [model_to_response(m) for m in models] @router.post("/models", dependencies=[Depends(require_permission("ai:config"))]) async def create_model( data: AIModelCreate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) await set_tenant_context(db, tenant_id) model = AIModel( provider_id=uuid.UUID(data.provider_id), model_id=data.model_id, display_name=data.display_name, context_window=data.context_window, supports_tools=data.supports_tools, supports_streaming=data.supports_streaming, is_active=data.is_active, config=data.config, tenant_id=tenant_id, ) db.add(model) await db.flush() await db.refresh(model) await db.commit() return model_to_response(model) @router.put("/models/{model_id}", dependencies=[Depends(require_permission("ai:config"))]) async def update_model( model_id: str, data: AIModelUpdate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) result = await db.execute( select(AIModel) .where(AIModel.id == uuid.UUID(model_id)) .where(AIModel.tenant_id == tenant_id) ) model = result.scalar_one_or_none() if not model: raise HTTPException(status_code=404, detail="Model not found") for field, val in data.model_dump(exclude_unset=True).items(): setattr(model, field, val) await db.flush() await db.refresh(model) await db.commit() return model_to_response(model) @router.delete("/models/{model_id}", dependencies=[Depends(require_permission("ai:config"))]) async def delete_model( model_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) result = await db.execute( select(AIModel) .where(AIModel.id == uuid.UUID(model_id)) .where(AIModel.tenant_id == tenant_id) ) model = result.scalar_one_or_none() if not model: raise HTTPException(status_code=404, detail="Model not found") await db.delete(model) await db.commit() return {"ok": True} # ─── Presets ─── @router.get("/presets", dependencies=[Depends(require_permission("ai:config"))]) async def list_presets( current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) result = await db.execute( select(AIPreset).where(AIPreset.tenant_id == tenant_id) ) presets = list(result.scalars().all()) return [preset_to_response(p) for p in presets] @router.post("/presets", dependencies=[Depends(require_permission("ai:config"))]) async def create_preset( data: AIPresetCreate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) await set_tenant_context(db, tenant_id) preset = AIPreset( name=data.name, model_id=data.model_id, provider_id=uuid.UUID(data.provider_id) if data.provider_id else None, temperature=data.temperature, max_tokens=data.max_tokens, top_p=data.top_p, system_prompt=data.system_prompt, config=data.config, is_active=data.is_active, tenant_id=tenant_id, ) db.add(preset) await db.flush() await db.refresh(preset) await db.commit() return preset_to_response(preset) @router.put("/presets/{preset_id}", dependencies=[Depends(require_permission("ai:config"))]) async def update_preset( preset_id: str, data: AIPresetUpdate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) preset = await get_preset_by_id(db, uuid.UUID(preset_id), tenant_id) if not preset: raise HTTPException(status_code=404, detail="Preset not found") update_data = data.model_dump(exclude_unset=True) if "provider_id" in update_data and update_data["provider_id"]: update_data["provider_id"] = uuid.UUID(update_data["provider_id"]) for field, val in update_data.items(): setattr(preset, field, val) await db.flush() await db.refresh(preset) await db.commit() return preset_to_response(preset) @router.delete("/presets/{preset_id}", dependencies=[Depends(require_permission("ai:config"))]) async def delete_preset( preset_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) preset = await get_preset_by_id(db, uuid.UUID(preset_id), tenant_id) if not preset: raise HTTPException(status_code=404, detail="Preset not found") await db.delete(preset) await db.commit() return {"ok": True} # ─── Agents ─── @router.get("/agents", dependencies=[Depends(require_permission("ai:read"))]) async def list_agents( current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("is_system_admin", False) query = select(AIAgent).where(AIAgent.tenant_id == tenant_id) query = await apply_visibility_filter( db, query, "ai_agent", AIAgent, user_id, tenant_id, is_system_admin ) result = await db.execute(query) agents = list(result.scalars().all()) return [agent_to_response(a) for a in agents] @router.post("/agents", dependencies=[Depends(require_permission("ai:agents"))]) async def create_agent( data: AIAgentCreate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) await set_tenant_context(db, tenant_id) agent = AIAgent( name=data.name, description=data.description, system_prompt=data.system_prompt, preset_id=uuid.UUID(data.preset_id) if data.preset_id else None, tool_ids=data.tool_ids, is_active=data.is_active, config=data.config, tenant_id=tenant_id, owner_id=user_id, ) db.add(agent) await db.flush() await db.refresh(agent) await db.commit() return agent_to_response(agent) @router.put("/agents/{agent_id}", dependencies=[Depends(require_permission("ai:agents"))]) async def update_agent( agent_id: str, data: AIAgentUpdate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) agent = await get_agent_by_id(db, uuid.UUID(agent_id), tenant_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") update_data = data.model_dump(exclude_unset=True) if "preset_id" in update_data and update_data["preset_id"]: update_data["preset_id"] = uuid.UUID(update_data["preset_id"]) for field, val in update_data.items(): setattr(agent, field, val) await db.flush() await db.refresh(agent) await db.commit() return agent_to_response(agent) @router.delete("/agents/{agent_id}", dependencies=[Depends(require_permission("ai:agents"))]) async def delete_agent( agent_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) agent = await get_agent_by_id(db, uuid.UUID(agent_id), tenant_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") if agent.is_default: raise HTTPException(status_code=400, detail="Cannot delete default agent") await db.delete(agent) await db.commit() return {"ok": True} # ─── Tools ─── @router.get("/tools", dependencies=[Depends(require_permission("ai:read"))]) async def list_tools( current_user: dict = Depends(get_current_user), ): registry = get_tool_registry() items = registry.list_for_api() return {"items": items, "total": len(items)} # ─── Chat Folders ─── @router.get("/folders", dependencies=[Depends(require_permission("ai:read"))]) async def list_folders( current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) result = await db.execute( select(AIChatFolder) .where(AIChatFolder.tenant_id == tenant_id) .where(AIChatFolder.user_id == user_id) .order_by(AIChatFolder.name.asc()) ) folders = list(result.scalars().all()) return [folder_to_response(f) for f in folders] @router.post("/folders", dependencies=[Depends(require_permission("ai:write"))]) async def create_folder( data: ChatFolderCreate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) await set_tenant_context(db, tenant_id) folder = AIChatFolder( name=data.name, parent_id=uuid.UUID(data.parent_id) if data.parent_id else None, user_id=user_id, tenant_id=tenant_id, ) db.add(folder) await db.flush() await db.refresh(folder) await db.commit() return folder_to_response(folder) @router.put("/folders/{folder_id}", dependencies=[Depends(require_permission("ai:write"))]) async def update_folder( folder_id: str, data: ChatFolderUpdate, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) result = await db.execute( select(AIChatFolder) .where(AIChatFolder.id == uuid.UUID(folder_id)) .where(AIChatFolder.tenant_id == tenant_id) .where(AIChatFolder.user_id == user_id) ) folder = result.scalar_one_or_none() if not folder: raise HTTPException(status_code=404, detail="Folder not found") update_data = data.model_dump(exclude_unset=True) if "parent_id" in update_data: if update_data["parent_id"]: update_data["parent_id"] = uuid.UUID(update_data["parent_id"]) else: update_data["parent_id"] = None for field, val in update_data.items(): setattr(folder, field, val) await db.flush() await db.refresh(folder) await db.commit() return folder_to_response(folder) @router.delete("/folders/{folder_id}", dependencies=[Depends(require_permission("ai:write"))]) async def delete_folder( folder_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) result = await db.execute( select(AIChatFolder) .where(AIChatFolder.id == uuid.UUID(folder_id)) .where(AIChatFolder.tenant_id == tenant_id) .where(AIChatFolder.user_id == user_id) ) folder = result.scalar_one_or_none() if not folder: raise HTTPException(status_code=404, detail="Folder not found") await db.delete(folder) await db.commit() return {"ok": True} # ─── AI Chat Streaming (via comm_conversations) ─── @router.post("/conversations/{conversation_id}/stream", dependencies=[Depends(require_permission("ai:write"))]) async def chat_stream_comm( conversation_id: str, data: ChatSendRequest, request: Request, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Stream AI chat response for a comm conversation with conversation_type='ai'.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy await check_rate_limit_policy( f"rate:ai:chat:{tenant_id}:{user_id}", RateLimitPolicy.AI, ) await set_tenant_context(db, tenant_id) agent = None if data.agent_id: agent = await get_agent_by_id(db, uuid.UUID(data.agent_id), tenant_id) if not agent: agent = await get_default_agent(db, tenant_id) if not agent: raise HTTPException(status_code=400, detail="No agent available") user_context = { "user_id": current_user["user_id"], "tenant_id": current_user["tenant_id"], "role": current_user.get("role", ""), "permissions": current_user.get("permissions", []), "denied_permissions": current_user.get("denied_permissions", []), "is_system_admin": current_user.get("is_system_admin", False), "field_permissions": current_user.get("field_permissions", {}), } async def event_stream(): from app.core.db import get_session_factory factory = get_session_factory() async with factory() as stream_db: await set_tenant_context(stream_db, tenant_id) async for chunk in stream_chat_comm( stream_db, uuid.UUID(conversation_id), agent, data.content, user_context, tenant_id, user_id ): yield chunk yield "data: [DONE]\n\n" return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @router.get("/conversations/{conversation_id}/messages", dependencies=[Depends(require_permission("ai:read"))]) async def list_comm_messages( conversation_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List messages for an AI comm conversation.""" tenant_id = uuid.UUID(current_user["tenant_id"]) await set_tenant_context(db, tenant_id) messages = await get_comm_messages(db, uuid.UUID(conversation_id), tenant_id) return [{"role": m["role"], "content": m["content"]} for m in messages]