AI Assistant plugin: backend with LiteLLM, agents, tools, streaming chat
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
"""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
|
||||
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.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.ai_assistant.models import (
|
||||
AIAgent,
|
||||
AIChatSession,
|
||||
AIModel,
|
||||
AIProvider,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.schemas import (
|
||||
AIAgentCreate,
|
||||
AIAgentUpdate,
|
||||
AIModelCreate,
|
||||
AIModelUpdate,
|
||||
AIPresetCreate,
|
||||
AIPresetUpdate,
|
||||
AIProviderCreate,
|
||||
AIProviderUpdate,
|
||||
ChatSendRequest,
|
||||
ChatSessionCreate,
|
||||
ChatSessionUpdate,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.services import (
|
||||
agent_to_response,
|
||||
get_agent_by_id,
|
||||
get_default_agent,
|
||||
get_preset_by_id,
|
||||
get_provider_by_id,
|
||||
get_session_by_id,
|
||||
get_session_messages,
|
||||
message_to_response,
|
||||
model_to_response,
|
||||
preset_to_response,
|
||||
provider_to_response,
|
||||
session_to_response,
|
||||
stream_chat,
|
||||
)
|
||||
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 == 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.commit()
|
||||
await db.refresh(provider)
|
||||
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 == 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.commit()
|
||||
await db.refresh(provider)
|
||||
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.commit()
|
||||
await db.refresh(model)
|
||||
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.commit()
|
||||
await db.refresh(model)
|
||||
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.commit()
|
||||
await db.refresh(preset)
|
||||
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.commit()
|
||||
await db.refresh(preset)
|
||||
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"])
|
||||
result = await db.execute(
|
||||
select(AIAgent).where(AIAgent.tenant_id == tenant_id)
|
||||
)
|
||||
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"])
|
||||
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,
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
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.commit()
|
||||
await db.refresh(agent)
|
||||
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()
|
||||
return registry.list_for_api()
|
||||
|
||||
|
||||
# ─── Chat Sessions ───
|
||||
|
||||
@router.get("/sessions", dependencies=[Depends(require_permission("ai:read"))])
|
||||
async def list_sessions(
|
||||
is_sidebar: bool | None = Query(None),
|
||||
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"])
|
||||
stmt = (
|
||||
select(AIChatSession)
|
||||
.where(AIChatSession.tenant_id == tenant_id)
|
||||
.where(AIChatSession.user_id == user_id)
|
||||
)
|
||||
if is_sidebar is not None:
|
||||
stmt = stmt.where(AIChatSession.is_sidebar == is_sidebar)
|
||||
stmt = stmt.order_by(AIChatSession.updated_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
sessions = list(result.scalars().all())
|
||||
return [session_to_response(s) for s in sessions]
|
||||
|
||||
|
||||
@router.post("/sessions", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def create_session(
|
||||
data: ChatSessionCreate,
|
||||
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_id = None
|
||||
if data.agent_id:
|
||||
agent_id = uuid.UUID(data.agent_id)
|
||||
elif not data.is_sidebar:
|
||||
# Use default agent for non-sidebar sessions
|
||||
default_agent = await get_default_agent(db, tenant_id)
|
||||
if default_agent:
|
||||
agent_id = default_agent.id
|
||||
else:
|
||||
# Sidebar also gets default agent
|
||||
default_agent = await get_default_agent(db, tenant_id)
|
||||
if default_agent:
|
||||
agent_id = default_agent.id
|
||||
|
||||
session = AIChatSession(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
title=data.title,
|
||||
is_sidebar=data.is_sidebar,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session_to_response(session)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def update_session(
|
||||
session_id: str,
|
||||
data: ChatSessionUpdate,
|
||||
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"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "agent_id" in update_data and update_data["agent_id"]:
|
||||
update_data["agent_id"] = uuid.UUID(update_data["agent_id"])
|
||||
for field, val in update_data.items():
|
||||
setattr(session, field, val)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session_to_response(session)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def delete_session(
|
||||
session_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"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ─── Chat Messages ───
|
||||
|
||||
@router.get("/sessions/{session_id}/messages", dependencies=[Depends(require_permission("ai:read"))])
|
||||
async def list_messages(
|
||||
session_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"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
messages = await get_session_messages(db, session.id, tenant_id)
|
||||
return [message_to_response(m) for m in messages]
|
||||
|
||||
|
||||
# ─── Streaming Chat ───
|
||||
|
||||
@router.post("/sessions/{session_id}/stream", dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def chat_stream(
|
||||
session_id: str,
|
||||
data: ChatSendRequest,
|
||||
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"])
|
||||
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
# Get agent
|
||||
agent = None
|
||||
if data.agent_id:
|
||||
agent = await get_agent_by_id(db, uuid.UUID(data.agent_id), tenant_id)
|
||||
elif session.agent_id:
|
||||
agent = await get_agent_by_id(db, session.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")
|
||||
|
||||
# Build user context for RBAC checks in tools
|
||||
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():
|
||||
async for chunk in stream_chat(
|
||||
db, session, agent, data.content, user_context, tenant_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",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user