AI Assistant plugin: backend with LiteLLM, agents, tools, streaming chat
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
from app.plugins.builtins.ai_assistant.plugin import AIAssistantPlugin
|
||||||
|
|
||||||
|
__all__ = ["AIAssistantPlugin"]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-- AI Assistant plugin initial migration
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
provider_type VARCHAR(50) NOT NULL,
|
||||||
|
api_key TEXT NOT NULL DEFAULT '',
|
||||||
|
base_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_providers_tenant ON ai_providers(tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_models (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
provider_id UUID NOT NULL REFERENCES ai_providers(id) ON DELETE CASCADE,
|
||||||
|
model_id VARCHAR(200) NOT NULL,
|
||||||
|
display_name VARCHAR(200) NOT NULL,
|
||||||
|
context_window INTEGER NOT NULL DEFAULT 4096,
|
||||||
|
supports_tools BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_streaming BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_models_provider ON ai_models(provider_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_models_tenant ON ai_models(tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_presets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
model_id VARCHAR(200) NOT NULL,
|
||||||
|
provider_id UUID REFERENCES ai_providers(id) ON DELETE SET NULL,
|
||||||
|
temperature REAL NOT NULL DEFAULT 0.7,
|
||||||
|
max_tokens INTEGER NOT NULL DEFAULT 2048,
|
||||||
|
top_p REAL NOT NULL DEFAULT 1.0,
|
||||||
|
system_prompt TEXT NOT NULL DEFAULT '',
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_presets_tenant ON ai_presets(tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_agents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
system_prompt TEXT NOT NULL DEFAULT '',
|
||||||
|
preset_id UUID REFERENCES ai_presets(id) ON DELETE SET NULL,
|
||||||
|
tool_ids JSONB NOT NULL DEFAULT '[]',
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_agents_tenant ON ai_agents(tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_chat_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
agent_id UUID REFERENCES ai_agents(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(255) NOT NULL DEFAULT 'Neuer Chat',
|
||||||
|
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_sidebar BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_sessions_user ON ai_chat_sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_sessions_tenant ON ai_chat_sessions(tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_chat_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
tool_calls JSONB,
|
||||||
|
tool_results JSONB,
|
||||||
|
tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model_used VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_messages_session ON ai_chat_messages(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ai_messages_tenant ON ai_chat_messages(tenant_id);
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""SQLAlchemy models for the AI Assistant plugin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
# --- Providers ---
|
||||||
|
|
||||||
|
class AIProvider(Base, TenantMixin):
|
||||||
|
"""LLM provider configuration (OpenAI, Anthropic, Ollama, etc.)."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_providers"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_providers_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
provider_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
api_key: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
base_url: Mapped[str] = mapped_column(String(500), nullable=False, default="")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Models ---
|
||||||
|
|
||||||
|
class AIModel(Base, TenantMixin):
|
||||||
|
"""Available model per provider."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_models"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_models_provider", "provider_id"),
|
||||||
|
Index("ix_ai_models_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
provider_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("ai_providers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
model_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
context_window: Mapped[int] = mapped_column(Integer, nullable=False, default=4096)
|
||||||
|
supports_tools: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
supports_streaming: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Presets ---
|
||||||
|
|
||||||
|
class AIPreset(Base, TenantMixin):
|
||||||
|
"""Model preset: model + parameters + optional system prompt."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_presets"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_presets_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
model_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
provider_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("ai_providers.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
temperature: Mapped[float] = mapped_column(Float, nullable=False, default=0.7)
|
||||||
|
max_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=2048)
|
||||||
|
top_p: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
|
||||||
|
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Agents ---
|
||||||
|
|
||||||
|
class AIAgent(Base, TenantMixin):
|
||||||
|
"""AI agent with system prompt and assigned tools."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_agents"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_agents_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
preset_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("ai_presets.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
tool_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Chat Sessions ---
|
||||||
|
|
||||||
|
class AIChatSession(Base, TenantMixin):
|
||||||
|
"""Chat session for a user with a specific agent."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_chat_sessions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_sessions_user", "user_id"),
|
||||||
|
Index("ix_ai_sessions_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||||
|
agent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("ai_agents.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Neuer Chat")
|
||||||
|
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
is_sidebar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Chat Messages ---
|
||||||
|
|
||||||
|
class AIChatMessage(Base, TenantMixin):
|
||||||
|
"""Individual message in a chat session."""
|
||||||
|
|
||||||
|
__tablename__ = "ai_chat_messages"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ai_messages_session", "session_id"),
|
||||||
|
Index("ix_ai_messages_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("ai_chat_sessions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
tool_calls: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
tool_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
model_used: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""AI Assistant plugin — multi-provider LLM chat, agents, tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AIAssistantPlugin(BasePlugin):
|
||||||
|
"""AI Assistant plugin: multi-provider LLM chat with agents and tools."""
|
||||||
|
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name="ai_assistant",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="KI Assistent",
|
||||||
|
description=(
|
||||||
|
"AI Assistant with multi-provider LLM support, custom agents, "
|
||||||
|
"plugin tools, and streaming chat."
|
||||||
|
),
|
||||||
|
dependencies=[],
|
||||||
|
routes=[
|
||||||
|
PluginRouteDef(
|
||||||
|
path="/api/v1/ai",
|
||||||
|
module="app.plugins.builtins.ai_assistant.routes",
|
||||||
|
router_attr="router",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
events=[],
|
||||||
|
migrations=["0001_initial.sql"],
|
||||||
|
permissions=[
|
||||||
|
"ai:read",
|
||||||
|
"ai:write",
|
||||||
|
"ai:config",
|
||||||
|
"ai:agents",
|
||||||
|
"ai:tools",
|
||||||
|
],
|
||||||
|
is_core=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_install(self, db, service_container) -> None:
|
||||||
|
"""Seed default provider and agent."""
|
||||||
|
from app.plugins.builtins.ai_assistant.services import seed_defaults
|
||||||
|
|
||||||
|
await seed_defaults(db)
|
||||||
|
|
||||||
|
def get_notification_types(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type_key": "ai_response_error",
|
||||||
|
"category": "ai",
|
||||||
|
"label": "KI Antwort-Fehler",
|
||||||
|
"description": "Fehler bei der KI-Antwortgenerierung",
|
||||||
|
"is_enabled_by_default": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Pydantic schemas for the AI Assistant plugin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Providers ───
|
||||||
|
|
||||||
|
class AIProviderCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
provider_type: str = Field(..., min_length=1, max_length=50)
|
||||||
|
api_key: str = Field(default="", max_length=2000)
|
||||||
|
base_url: str = Field(default="", max_length=500)
|
||||||
|
is_active: bool = True
|
||||||
|
is_default: bool = False
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AIProviderUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
provider_type: str | None = Field(None, min_length=1, max_length=50)
|
||||||
|
api_key: str | None = Field(None, max_length=2000)
|
||||||
|
base_url: str | None = Field(None, max_length=500)
|
||||||
|
is_active: bool | None = None
|
||||||
|
is_default: bool | None = None
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIProviderResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
provider_type: str
|
||||||
|
api_key: str # masked in routes
|
||||||
|
base_url: str
|
||||||
|
is_active: bool
|
||||||
|
is_default: bool
|
||||||
|
config: dict[str, Any]
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Models ───
|
||||||
|
|
||||||
|
class AIModelCreate(BaseModel):
|
||||||
|
provider_id: str
|
||||||
|
model_id: str = Field(..., min_length=1, max_length=200)
|
||||||
|
display_name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
context_window: int = Field(default=4096, ge=1)
|
||||||
|
supports_tools: bool = False
|
||||||
|
supports_streaming: bool = True
|
||||||
|
is_active: bool = True
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelUpdate(BaseModel):
|
||||||
|
model_id: str | None = Field(None, min_length=1, max_length=200)
|
||||||
|
display_name: str | None = Field(None, min_length=1, max_length=200)
|
||||||
|
context_window: int | None = Field(None, ge=1)
|
||||||
|
supports_tools: bool | None = None
|
||||||
|
supports_streaming: bool | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: str
|
||||||
|
provider_id: str
|
||||||
|
model_id: str
|
||||||
|
display_name: str
|
||||||
|
context_window: int
|
||||||
|
supports_tools: bool
|
||||||
|
supports_streaming: bool
|
||||||
|
is_active: bool
|
||||||
|
config: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Presets ───
|
||||||
|
|
||||||
|
class AIPresetCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
model_id: str = Field(..., min_length=1, max_length=200)
|
||||||
|
provider_id: str | None = None
|
||||||
|
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
||||||
|
max_tokens: int = Field(default=2048, ge=1, le=128000)
|
||||||
|
top_p: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||||
|
system_prompt: str = ""
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AIPresetUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
model_id: str | None = Field(None, min_length=1, max_length=200)
|
||||||
|
provider_id: str | None = None
|
||||||
|
temperature: float | None = Field(None, ge=0.0, le=2.0)
|
||||||
|
max_tokens: int | None = Field(None, ge=1, le=128000)
|
||||||
|
top_p: float | None = Field(None, ge=0.0, le=1.0)
|
||||||
|
system_prompt: str | None = None
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIPresetResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
model_id: str
|
||||||
|
provider_id: str | None = None
|
||||||
|
temperature: float
|
||||||
|
max_tokens: int
|
||||||
|
top_p: float
|
||||||
|
system_prompt: str
|
||||||
|
config: dict[str, Any]
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Agents ───
|
||||||
|
|
||||||
|
class AIAgentCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: str = ""
|
||||||
|
system_prompt: str = ""
|
||||||
|
preset_id: str | None = None
|
||||||
|
tool_ids: list[str] = Field(default_factory=list)
|
||||||
|
is_active: bool = True
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AIAgentUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
description: str | None = None
|
||||||
|
system_prompt: str | None = None
|
||||||
|
preset_id: str | None = None
|
||||||
|
tool_ids: list[str] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIAgentResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
system_prompt: str
|
||||||
|
preset_id: str | None = None
|
||||||
|
tool_ids: list[str]
|
||||||
|
is_default: bool
|
||||||
|
is_active: bool
|
||||||
|
config: dict[str, Any]
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Chat Sessions ───
|
||||||
|
|
||||||
|
class ChatSessionCreate(BaseModel):
|
||||||
|
title: str = Field(default="Neuer Chat", max_length=255)
|
||||||
|
agent_id: str | None = None
|
||||||
|
is_sidebar: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionUpdate(BaseModel):
|
||||||
|
title: str | None = Field(None, max_length=255)
|
||||||
|
is_pinned: bool | None = None
|
||||||
|
agent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
agent_id: str | None = None
|
||||||
|
title: str
|
||||||
|
is_pinned: bool
|
||||||
|
is_sidebar: bool
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Chat Messages ───
|
||||||
|
|
||||||
|
class ChatMessageResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: str
|
||||||
|
session_id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
tool_calls: list[dict[str, Any]] | None = None
|
||||||
|
tool_results: list[dict[str, Any]] | None = None
|
||||||
|
tokens: int
|
||||||
|
model_used: str
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSendRequest(BaseModel):
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
agent_id: str | None = None # override session agent
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tools ───
|
||||||
|
|
||||||
|
class AIToolResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: dict[str, Any]
|
||||||
|
plugin_name: str
|
||||||
|
required_permission: str | None = None
|
||||||
|
category: str
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""Business logic for the AI Assistant plugin.
|
||||||
|
|
||||||
|
Uses LiteLLM for multi-provider LLM calls and PydanticAI for the agent loop.
|
||||||
|
Tools from the global ToolRegistry are wrapped as PydanticAI tools with
|
||||||
|
RBAC permission checks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.permissions import check_permission
|
||||||
|
from app.plugins.builtins.ai_assistant.models import (
|
||||||
|
AIAgent,
|
||||||
|
AIChatMessage,
|
||||||
|
AIChatSession,
|
||||||
|
AIModel,
|
||||||
|
AIPreset,
|
||||||
|
AIProvider,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.ai_assistant.tool_registry import (
|
||||||
|
AITool,
|
||||||
|
get_tool_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Suppress litellm verbose logging
|
||||||
|
litellm.suppress_debug_info = True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ───
|
||||||
|
|
||||||
|
def mask_api_key(key: str) -> str:
|
||||||
|
"""Mask API key for display: show first 8 and last 4 chars."""
|
||||||
|
if not key or len(key) <= 12:
|
||||||
|
return "***"
|
||||||
|
return f"{key[:8]}...{key[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def provider_to_response(provider: AIProvider) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(provider.id),
|
||||||
|
"name": provider.name,
|
||||||
|
"provider_type": provider.provider_type,
|
||||||
|
"api_key": mask_api_key(provider.api_key),
|
||||||
|
"base_url": provider.base_url,
|
||||||
|
"is_active": provider.is_active,
|
||||||
|
"is_default": provider.is_default,
|
||||||
|
"config": provider.config or {},
|
||||||
|
"created_at": provider.created_at.isoformat() if provider.created_at else None,
|
||||||
|
"updated_at": provider.updated_at.isoformat() if provider.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def model_to_response(model: AIModel) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(model.id),
|
||||||
|
"provider_id": str(model.provider_id),
|
||||||
|
"model_id": model.model_id,
|
||||||
|
"display_name": model.display_name,
|
||||||
|
"context_window": model.context_window,
|
||||||
|
"supports_tools": model.supports_tools,
|
||||||
|
"supports_streaming": model.supports_streaming,
|
||||||
|
"is_active": model.is_active,
|
||||||
|
"config": model.config or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def preset_to_response(preset: AIPreset) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(preset.id),
|
||||||
|
"name": preset.name,
|
||||||
|
"model_id": preset.model_id,
|
||||||
|
"provider_id": str(preset.provider_id) if preset.provider_id else None,
|
||||||
|
"temperature": preset.temperature,
|
||||||
|
"max_tokens": preset.max_tokens,
|
||||||
|
"top_p": preset.top_p,
|
||||||
|
"system_prompt": preset.system_prompt,
|
||||||
|
"config": preset.config or {},
|
||||||
|
"is_active": preset.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def agent_to_response(agent: AIAgent) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(agent.id),
|
||||||
|
"name": agent.name,
|
||||||
|
"description": agent.description,
|
||||||
|
"system_prompt": agent.system_prompt,
|
||||||
|
"preset_id": str(agent.preset_id) if agent.preset_id else None,
|
||||||
|
"tool_ids": agent.tool_ids or [],
|
||||||
|
"is_default": agent.is_default,
|
||||||
|
"is_active": agent.is_active,
|
||||||
|
"config": agent.config or {},
|
||||||
|
"created_at": agent.created_at.isoformat() if agent.created_at else None,
|
||||||
|
"updated_at": agent.updated_at.isoformat() if agent.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def session_to_response(session: AIChatSession) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(session.id),
|
||||||
|
"user_id": str(session.user_id),
|
||||||
|
"agent_id": str(session.agent_id) if session.agent_id else None,
|
||||||
|
"title": session.title,
|
||||||
|
"is_pinned": session.is_pinned,
|
||||||
|
"is_sidebar": session.is_sidebar,
|
||||||
|
"created_at": session.created_at.isoformat() if session.created_at else None,
|
||||||
|
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def message_to_response(msg: AIChatMessage) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(msg.id),
|
||||||
|
"session_id": str(msg.session_id),
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content,
|
||||||
|
"tool_calls": msg.tool_calls if msg.tool_calls else None,
|
||||||
|
"tool_results": msg.tool_results if msg.tool_results else None,
|
||||||
|
"tokens": msg.tokens,
|
||||||
|
"model_used": msg.model_used,
|
||||||
|
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Provider/Model/Preset/Agent CRUD ───
|
||||||
|
|
||||||
|
async def get_default_provider(db: AsyncSession, tenant_id: uuid.UUID) -> AIProvider | None:
|
||||||
|
"""Get the default provider for a tenant."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIProvider)
|
||||||
|
.where(AIProvider.tenant_id == tenant_id)
|
||||||
|
.where(AIProvider.is_default == True)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_provider_by_id(db: AsyncSession, provider_id: uuid.UUID, tenant_id: uuid.UUID) -> AIProvider | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIProvider)
|
||||||
|
.where(AIProvider.id == provider_id)
|
||||||
|
.where(AIProvider.tenant_id == tenant_id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_preset_by_id(db: AsyncSession, preset_id: uuid.UUID, tenant_id: uuid.UUID) -> AIPreset | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIPreset)
|
||||||
|
.where(AIPreset.id == preset_id)
|
||||||
|
.where(AIPreset.tenant_id == tenant_id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_agent_by_id(db: AsyncSession, agent_id: uuid.UUID, tenant_id: uuid.UUID) -> AIAgent | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIAgent)
|
||||||
|
.where(AIAgent.id == agent_id)
|
||||||
|
.where(AIAgent.tenant_id == tenant_id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_default_agent(db: AsyncSession, tenant_id: uuid.UUID) -> AIAgent | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIAgent)
|
||||||
|
.where(AIAgent.tenant_id == tenant_id)
|
||||||
|
.where(AIAgent.is_default == True)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Session/Message helpers ───
|
||||||
|
|
||||||
|
async def get_session_by_id(
|
||||||
|
db: AsyncSession, session_id: uuid.UUID, user_id: uuid.UUID, tenant_id: uuid.UUID
|
||||||
|
) -> AIChatSession | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIChatSession)
|
||||||
|
.where(AIChatSession.id == session_id)
|
||||||
|
.where(AIChatSession.user_id == user_id)
|
||||||
|
.where(AIChatSession.tenant_id == tenant_id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_messages(
|
||||||
|
db: AsyncSession, session_id: uuid.UUID, tenant_id: uuid.UUID
|
||||||
|
) -> list[AIChatMessage]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIChatMessage)
|
||||||
|
.where(AIChatMessage.session_id == session_id)
|
||||||
|
.where(AIChatMessage.tenant_id == tenant_id)
|
||||||
|
.order_by(AIChatMessage.created_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def save_message(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: uuid.UUID,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
tool_calls: list | None = None,
|
||||||
|
tool_results: list | None = None,
|
||||||
|
tokens: int = 0,
|
||||||
|
model_used: str = "",
|
||||||
|
) -> AIChatMessage:
|
||||||
|
msg = AIChatMessage(
|
||||||
|
session_id=session_id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
tool_results=tool_results,
|
||||||
|
tokens=tokens,
|
||||||
|
model_used=model_used,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
await db.flush()
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
# ─── LLM Chat with Tool Loop ───
|
||||||
|
|
||||||
|
async def build_litellm_params(
|
||||||
|
db: AsyncSession,
|
||||||
|
agent: AIAgent,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build LiteLLM completion parameters from agent + preset."""
|
||||||
|
# Get preset
|
||||||
|
preset: AIPreset | None = None
|
||||||
|
if agent.preset_id:
|
||||||
|
preset = await get_preset_by_id(db, agent.preset_id, tenant_id)
|
||||||
|
|
||||||
|
model_id = "gpt-4o-mini" # fallback
|
||||||
|
temperature = 0.7
|
||||||
|
max_tokens = 2048
|
||||||
|
top_p = 1.0
|
||||||
|
system_prompt = agent.system_prompt or ""
|
||||||
|
|
||||||
|
if preset:
|
||||||
|
model_id = preset.model_id
|
||||||
|
temperature = preset.temperature
|
||||||
|
max_tokens = preset.max_tokens
|
||||||
|
top_p = preset.top_p
|
||||||
|
if preset.system_prompt and not system_prompt:
|
||||||
|
system_prompt = preset.system_prompt
|
||||||
|
|
||||||
|
# Get provider for API key
|
||||||
|
provider: AIProvider | None = None
|
||||||
|
if preset and preset.provider_id:
|
||||||
|
provider = await get_provider_by_id(db, preset.provider_id, tenant_id)
|
||||||
|
if not provider:
|
||||||
|
provider = await get_default_provider(db, tenant_id)
|
||||||
|
|
||||||
|
# Build litellm model string with provider prefix
|
||||||
|
litellm_model = model_id
|
||||||
|
if provider and provider.provider_type != "openai":
|
||||||
|
litellm_model = f"{provider.provider_type}/{model_id}"
|
||||||
|
|
||||||
|
# Build messages with system prompt
|
||||||
|
litellm_messages = []
|
||||||
|
if system_prompt:
|
||||||
|
litellm_messages.append({"role": "system", "content": system_prompt})
|
||||||
|
litellm_messages.extend(messages)
|
||||||
|
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"model": litellm_model,
|
||||||
|
"messages": litellm_messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"top_p": top_p,
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set API key from provider
|
||||||
|
if provider and provider.api_key:
|
||||||
|
if provider.provider_type == "openai":
|
||||||
|
params["api_key"] = provider.api_key
|
||||||
|
elif provider.provider_type == "anthropic":
|
||||||
|
params["api_key"] = provider.api_key
|
||||||
|
else:
|
||||||
|
params["api_key"] = provider.api_key
|
||||||
|
|
||||||
|
if provider and provider.base_url:
|
||||||
|
params["api_base"] = provider.base_url
|
||||||
|
|
||||||
|
return params, model_id
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_tool_call(
|
||||||
|
tool: AITool,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
user_context: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
"""Execute a tool call with RBAC permission check."""
|
||||||
|
# Check RBAC permission if tool requires one
|
||||||
|
if tool.required_permission:
|
||||||
|
if not check_permission(user_context, tool.required_permission):
|
||||||
|
return f"Error: Permission '{tool.required_permission}' required for tool '{tool.name}'"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await tool.handler(arguments=arguments, context=user_context)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Tool '%s' execution error: %s", tool.name, exc)
|
||||||
|
return f"Error executing tool '{tool.name}': {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_chat(
|
||||||
|
db: AsyncSession,
|
||||||
|
session: AIChatSession,
|
||||||
|
agent: AIAgent,
|
||||||
|
user_message: str,
|
||||||
|
user_context: dict[str, Any],
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Stream chat response via SSE with tool-calling loop.
|
||||||
|
|
||||||
|
Yields SSE-formatted strings.
|
||||||
|
"""
|
||||||
|
# Load conversation history
|
||||||
|
history = await get_session_messages(db, session.id, tenant_id)
|
||||||
|
messages: list[dict[str, Any]] = []
|
||||||
|
for msg in history:
|
||||||
|
messages.append({"role": msg.role, "content": msg.content})
|
||||||
|
|
||||||
|
# Add user message
|
||||||
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
await save_message(db, session.id, "user", user_message, tenant_id)
|
||||||
|
|
||||||
|
# Get agent tools
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.get_by_names(agent.tool_ids or [])
|
||||||
|
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None
|
||||||
|
|
||||||
|
# Build LLM params
|
||||||
|
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
||||||
|
|
||||||
|
# Agent loop: LLM → tool calls → execute → feed back → repeat
|
||||||
|
max_iterations = 5
|
||||||
|
for iteration in range(max_iterations):
|
||||||
|
# Add tools to params if available
|
||||||
|
if tool_schemas:
|
||||||
|
params["tools"] = tool_schemas
|
||||||
|
|
||||||
|
# Stream LLM response
|
||||||
|
collected_content = ""
|
||||||
|
collected_tool_calls: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
response = await litellm.acompletion(**params)
|
||||||
|
async for chunk in response:
|
||||||
|
delta = chunk.choices[0].delta
|
||||||
|
if delta.content:
|
||||||
|
collected_content += delta.content
|
||||||
|
yield f"data: {json.dumps({'type': 'token', 'content': delta.content})}\n\n"
|
||||||
|
if delta.tool_calls:
|
||||||
|
for tc in delta.tool_calls:
|
||||||
|
idx = tc.index
|
||||||
|
while len(collected_tool_calls) <= idx:
|
||||||
|
collected_tool_calls.append({"id": "", "function": {"name": "", "arguments": ""}})
|
||||||
|
if tc.id:
|
||||||
|
collected_tool_calls[idx]["id"] = tc.id
|
||||||
|
if tc.function:
|
||||||
|
if tc.function.name:
|
||||||
|
collected_tool_calls[idx]["function"]["name"] += tc.function.name
|
||||||
|
if tc.function.arguments:
|
||||||
|
collected_tool_calls[idx]["function"]["arguments"] += tc.function.arguments
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("LLM streaming error: %s", exc)
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'content': str(exc)})}\n\n"
|
||||||
|
await save_message(db, session.id, "assistant", f"Error: {exc}", tenant_id, model_used=model_id)
|
||||||
|
await db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# If tool calls, execute them and continue loop
|
||||||
|
if collected_tool_calls:
|
||||||
|
# Save assistant message with tool calls
|
||||||
|
await save_message(
|
||||||
|
db, session.id, "assistant", collected_content, tenant_id,
|
||||||
|
tool_calls=collected_tool_calls, model_used=model_id,
|
||||||
|
)
|
||||||
|
yield f"data: {json.dumps({'type': 'tool_calls', 'tools': [tc['function']['name'] for tc in collected_tool_calls]})}\n\n"
|
||||||
|
|
||||||
|
# Execute each tool call
|
||||||
|
for tc in collected_tool_calls:
|
||||||
|
tool_name = tc["function"]["name"]
|
||||||
|
try:
|
||||||
|
tool_args = json.loads(tc["function"]["arguments"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
tool_args = {}
|
||||||
|
|
||||||
|
tool = registry.get(tool_name)
|
||||||
|
if tool is None:
|
||||||
|
result = f"Tool '{tool_name}' not found"
|
||||||
|
else:
|
||||||
|
result = await execute_tool_call(tool, tool_args, user_context)
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result[:500]})}\n\n"
|
||||||
|
|
||||||
|
# Add tool result to messages for next iteration
|
||||||
|
messages.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": collected_content,
|
||||||
|
"tool_calls": collected_tool_calls,
|
||||||
|
})
|
||||||
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc["id"],
|
||||||
|
"name": tool_name,
|
||||||
|
"content": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update params with new messages for next iteration
|
||||||
|
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# No tool calls — final response
|
||||||
|
await save_message(db, session.id, "assistant", collected_content, tenant_id, model_used=model_id)
|
||||||
|
await db.commit()
|
||||||
|
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# Max iterations reached
|
||||||
|
await save_message(db, session.id, "assistant", collected_content, tenant_id, model_used=model_id)
|
||||||
|
await db.commit()
|
||||||
|
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Seed Defaults ───
|
||||||
|
|
||||||
|
async def seed_defaults(db: AsyncSession) -> None:
|
||||||
|
"""Seed default provider, preset, and agent for existing tenants."""
|
||||||
|
from app.models import Tenant
|
||||||
|
|
||||||
|
result = await db.execute(select(Tenant))
|
||||||
|
tenants = list(result.scalars().all())
|
||||||
|
|
||||||
|
for tenant in tenants:
|
||||||
|
# Check if default provider already exists
|
||||||
|
existing = await get_default_provider(db, tenant.id)
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create default OpenAI provider (no key, user must configure)
|
||||||
|
provider = AIProvider(
|
||||||
|
name="OpenAI",
|
||||||
|
provider_type="openai",
|
||||||
|
api_key="",
|
||||||
|
base_url="",
|
||||||
|
is_active=True,
|
||||||
|
is_default=True,
|
||||||
|
config={},
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
db.add(provider)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create default preset
|
||||||
|
preset = AIPreset(
|
||||||
|
name="Standard",
|
||||||
|
model_id="gpt-4o-mini",
|
||||||
|
provider_id=provider.id,
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=2048,
|
||||||
|
top_p=1.0,
|
||||||
|
system_prompt="Du bist ein hilfreicher KI-Assistent für ein CRM-System.",
|
||||||
|
is_active=True,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
db.add(preset)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create default agent
|
||||||
|
agent = AIAgent(
|
||||||
|
name="Standard Assistent",
|
||||||
|
description="Allgemeiner KI-Assistent",
|
||||||
|
system_prompt="Du bist ein hilfreicher KI-Assistent. Antworte präzise und hilfreich.",
|
||||||
|
preset_id=preset.id,
|
||||||
|
tool_ids=[],
|
||||||
|
is_default=True,
|
||||||
|
is_active=True,
|
||||||
|
config={},
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
db.add(agent)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info("AI Assistant: default providers, presets, and agents seeded")
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Global tool registry for AI Assistant plugin tools.
|
||||||
|
|
||||||
|
Plugins can register tools that AI agents can call during chat sessions.
|
||||||
|
Each tool declares a name, description, JSON schema for parameters,
|
||||||
|
and an async handler. Tools can optionally require specific RBAC permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Awaitable, Callable, Protocol
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolHandler(Protocol):
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
context: dict[str, Any],
|
||||||
|
) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AITool:
|
||||||
|
"""Represents a tool that an AI agent can call."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: dict[str, Any] # JSON Schema for parameters
|
||||||
|
handler: ToolHandler
|
||||||
|
plugin_name: str = ""
|
||||||
|
required_permission: str | None = None # e.g. "mail:send"
|
||||||
|
category: str = "general"
|
||||||
|
|
||||||
|
def to_openai_schema(self) -> dict[str, Any]:
|
||||||
|
"""Convert to OpenAI function-calling tool schema."""
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"parameters": self.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ToolRegistry:
|
||||||
|
"""Singleton registry for AI tools."""
|
||||||
|
|
||||||
|
_instance: ToolRegistry | None = None
|
||||||
|
|
||||||
|
def __new__(cls) -> ToolRegistry:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._tools: dict[str, AITool] = {}
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
parameters: dict[str, Any],
|
||||||
|
handler: ToolHandler,
|
||||||
|
plugin_name: str = "",
|
||||||
|
required_permission: str | None = None,
|
||||||
|
category: str = "general",
|
||||||
|
) -> None:
|
||||||
|
"""Register a tool."""
|
||||||
|
tool = AITool(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
parameters=parameters,
|
||||||
|
handler=handler,
|
||||||
|
plugin_name=plugin_name,
|
||||||
|
required_permission=required_permission,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._tools[name] = tool
|
||||||
|
logger.info("AI tool registered: %s (plugin=%s)", name, plugin_name)
|
||||||
|
|
||||||
|
def unregister(self, name: str) -> None:
|
||||||
|
"""Unregister a tool by name."""
|
||||||
|
self._tools.pop(name, None)
|
||||||
|
|
||||||
|
def unregister_plugin(self, plugin_name: str) -> None:
|
||||||
|
"""Unregister all tools from a plugin."""
|
||||||
|
to_remove = [
|
||||||
|
name for name, tool in self._tools.items() if tool.plugin_name == plugin_name
|
||||||
|
]
|
||||||
|
for name in to_remove:
|
||||||
|
self._tools.pop(name, None)
|
||||||
|
|
||||||
|
def get(self, name: str) -> AITool | None:
|
||||||
|
return self._tools.get(name)
|
||||||
|
|
||||||
|
def get_all(self) -> list[AITool]:
|
||||||
|
return list(self._tools.values())
|
||||||
|
|
||||||
|
def get_by_names(self, names: list[str]) -> list[AITool]:
|
||||||
|
return [self._tools[name] for name in names if name in self._tools]
|
||||||
|
|
||||||
|
def list_for_api(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return tool list for API response."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": tool.name,
|
||||||
|
"description": tool.description,
|
||||||
|
"parameters": tool.parameters,
|
||||||
|
"plugin_name": tool.plugin_name,
|
||||||
|
"required_permission": tool.required_permission,
|
||||||
|
"category": tool.category,
|
||||||
|
}
|
||||||
|
for tool in self._tools.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_registry() -> ToolRegistry:
|
||||||
|
"""Get the global tool registry singleton."""
|
||||||
|
return ToolRegistry()
|
||||||
@@ -45,3 +45,4 @@ openpyxl>=3.1
|
|||||||
# Monitoring
|
# Monitoring
|
||||||
prometheus-client>=0.20
|
prometheus-client>=0.20
|
||||||
structlog>=24.0
|
structlog>=24.0
|
||||||
|
litellm
|
||||||
|
|||||||
Reference in New Issue
Block a user