# LeoCRM Plugin Development Guide > **Version:** 2.0 > **Date:** 2026-07-23 > **Applies to:** All plugin developers --- ## 1. Overview Plugins are self-contained modules that extend LeoCRM's functionality. Each plugin lives in its own directory under `app/plugins/builtins/` and declares its capabilities via a `PluginManifest`. The plugin system supports: - **API Routes** — Register FastAPI route handlers - **Event Subscriptions** — React to domain events (contact.created, etc.) - **Database Migrations** — Versioned SQL migrations run on install - **RBAC Permissions** — Declare and enforce permissions - **Frontend UI Contributions** — Menu items, page routes, detail tabs, settings pages, dashboard widgets - **AI Agent Capabilities** — Register tools for the AI assistant - **Lifecycle Hooks** — on_install, on_activate, on_deactivate, on_uninstall --- ## 2. Plugin Structure Every plugin lives under `app/plugins/builtins//`: ``` app/plugins/builtins/my_plugin/ ├── __init__.py # Package init (can be empty) ├── plugin.py # Plugin class with manifest (required) ├── routes.py # FastAPI route definitions ├── models.py # SQLAlchemy models (optional) ├── schemas.py # Pydantic schemas (optional) ├── services.py # Business logic (optional) ├── migrations/ # SQL migration files │ └── 0001_initial.sql └── tests/ # Plugin tests (optional) ├── __init__.py └── test_plugin.py ``` --- ## 3. Manifest Format The `PluginManifest` is a Pydantic v2 model that declares all metadata and capabilities of a plugin. It is defined in `app/plugins/manifest.py`. ### 3.1 Core Fields | Field | Type | Required | Description | |---|---|---|---| | `name` | `str` | Yes | Unique plugin identifier (snake_case, max 80 chars, alphanumeric with underscores only) | | `version` | `str` | Yes | Semantic version string (e.g. "1.0.0") | | `display_name` | `str` | Yes | Human-readable plugin name (max 120 chars) | | `description` | `str` | No | Plugin description (max 500 chars) | | `dependencies` | `list[str]` | No | Other plugin names this plugin depends on | | `is_core` | `bool` | No | Whether this is a core plugin that cannot be deactivated (default: `false`) | ### 3.2 Route Definitions ```python from app.plugins.manifest import PluginRouteDef routes=[ PluginRouteDef( path="/api/v1/my-plugin", # URL path prefix module="app.plugins.builtins.my_plugin.routes", # Dotted module path router_attr="router", # Attribute name of the APIRouter in the module ), ] ``` ### 3.3 Event Subscriptions ```python events=[ "contact.created", "contact.updated", "contact.deleted", ] ``` Event naming convention: `.` (e.g. `company.created`, `task.assigned`). ### 3.4 Migrations ```python migrations=[ "0001_initial.sql", "0002_add_indexes.sql", ] ``` Migration files are stored in the plugin's `migrations/` directory and run in order on install. ### 3.5 Permissions ```python permissions=[ "my_plugin:read", "my_plugin:write", "my_plugin:delete", "my_plugin:admin", ] ``` Permission naming convention: `:`. ### 3.6 Field Definitions (Field-Level Permissions) ```python from app.plugins.manifest import FieldDefinition field_definitions=[ FieldDefinition( module="companies", # Module name (e.g. 'companies', 'contacts') field="annual_revenue", # Field name label="Annual Revenue", # Human-readable label sensitivity="sensitive", # normal|sensitive|critical ), ] ``` ### 3.7 Agent Capabilities ```python agent_capabilities=[ "contact_search", # Contact search capability "email_draft", # Email draft generation "calendar_scheduling", # Calendar scheduling ] ``` ### 3.8 Frontend UI Fields (Phase 3) #### FrontendMenuItem — Sidebar Navigation ```python from app.plugins.manifest import FrontendMenuItem menu_items=[ FrontendMenuItem( label_key="nav.myPlugin", # i18n key for the menu label label="My Plugin", # Fallback label if i18n key is missing path="/my-plugin", # Frontend route path icon="Sparkles", # lucide-react icon name group="", # Optional group label_key for tree-style nesting order=100, # Sort order within the sidebar badge_key="", # Optional store key for badge count ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `label_key` | `str` | Yes | — | i18n key for the menu label | | `label` | `str` | No | `""` | Fallback label if i18n key is missing | | `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` | | `icon` | `str` | No | `"FileText"` | lucide-react icon name | | `group` | `str` | No | `""` | Optional group label_key for tree-style nesting | | `order` | `int` | No | `100` | Sort order within the sidebar | | `badge_key` | `str` | No | `""` | Optional store key for badge count | #### FrontendPageRoute — Page Routes ```python from app.plugins.manifest import FrontendPageRoute page_routes=[ FrontendPageRoute( path="/my-plugin", # Frontend route path component="@/pages/MyPlugin", # Dotted path to the React component parent="", # Parent route path for nested routes protected=True, # Whether the route requires authentication order=100, # Sort order ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` or `/mail/settings` | | `component` | `str` | Yes | — | Dotted path to the React component, e.g. `@/pages/Mail` | | `parent` | `str` | No | `""` | Parent route path for nested routes (e.g. `/settings` for a settings sub-page) | | `protected` | `bool` | No | `True` | Whether the route requires authentication | | `order` | `int` | No | `100` | Sort order | #### FrontendDetailTab — Entity Detail Tabs ```python from app.plugins.manifest import FrontendDetailTab detail_tabs=[ FrontendDetailTab( entity_type="contact", # Entity type this tab applies to label_key="tabs.myPlugin", # i18n key for the tab label label="My Tab", # Fallback label component="@/components/MyTab", # Dotted path to the React component icon="FileText", # lucide-react icon name order=50, # Sort order within the detail view permission="my_plugin:read", # Optional permission required to see this tab ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `entity_type` | `str` | Yes | — | Entity type this tab applies to, e.g. `'contact'` | | `label_key` | `str` | Yes | — | i18n key for the tab label | | `label` | `str` | No | `""` | Fallback label | | `component` | `str` | Yes | — | Dotted path to the React component | | `icon` | `str` | No | `"FileText"` | lucide-react icon name | | `order` | `int` | No | `100` | Sort order within the detail view | | `permission` | `str` | No | `""` | Optional permission required to see this tab | #### FrontendSettingsPage — Settings Sub-Pages ```python from app.plugins.manifest import FrontendSettingsPage settings_pages=[ FrontendSettingsPage( path="my-plugin", # Settings sub-route path label_key="settings.myPlugin", # i18n key for the settings nav label label="My Plugin", # Fallback label component="@/pages/MyPluginSettings", # Dotted path to the React component icon="Sparkles", # lucide-react icon name order=100, # Sort order within settings nav permission="my_plugin:admin", # Optional permission required ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `path` | `str` | Yes | — | Settings sub-route path, e.g. `mail` or `notifications` | | `label_key` | `str` | Yes | — | i18n key for the settings nav label | | `label` | `str` | No | `""` | Fallback label | | `component` | `str` | Yes | — | Dotted path to the React component | | `icon` | `str` | No | `"Settings"` | lucide-react icon name | | `order` | `int` | No | `100` | Sort order within settings nav | | `permission` | `str` | No | `""` | Optional permission required | #### FrontendDashboardWidget — Dashboard Widgets ```python from app.plugins.manifest import FrontendDashboardWidget dashboard_widgets=[ FrontendDashboardWidget( id="my_plugin_stats", # Unique widget identifier label_key="widgets.myPlugin", # i18n key for the widget title label="My Plugin Stats", # Fallback label component="@/components/MyWidget", # Dotted path to the React component icon="LayoutDashboard", # lucide-react icon name order=100, # Sort order on the dashboard col_span=1, # Grid column span (1-4) row_span=1, # Grid row span permission="my_plugin:read", # Optional permission required ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `id` | `str` | Yes | — | Unique widget identifier | | `label_key` | `str` | Yes | — | i18n key for the widget title | | `label` | `str` | No | `""` | Fallback label | | `component` | `str` | Yes | — | Dotted path to the React component | | `icon` | `str` | No | `"LayoutDashboard"` | lucide-react icon name | | `order` | `int` | No | `100` | Sort order on the dashboard | | `col_span` | `int` | No | `1` | Grid column span (1-4) | | `row_span` | `int` | No | `1` | Grid row span | | `permission` | `str` | No | `""` | Optional permission required | ### 3.9 Complete Manifest Example ```python from app.plugins.base import BasePlugin from app.plugins.manifest import ( PluginManifest, PluginRouteDef, FieldDefinition, FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage, FrontendDashboardWidget, ) class MyPlugin(BasePlugin): manifest = PluginManifest( name="my_plugin", version="1.0.0", display_name="My Plugin", description="A comprehensive example plugin.", dependencies=["permissions"], is_core=False, routes=[ PluginRouteDef( path="/api/v1/my-plugin", module="app.plugins.builtins.my_plugin.routes", router_attr="router", ), ], events=["contact.created", "contact.updated"], migrations=["0001_initial.sql"], permissions=["my_plugin:read", "my_plugin:write", "my_plugin:admin"], field_definitions=[ FieldDefinition( module="contacts", field="custom_field", label="Custom Field", sensitivity="normal", ), ], agent_capabilities=["my_plugin:search"], menu_items=[ FrontendMenuItem( label_key="nav.myPlugin", label="My Plugin", path="/my-plugin", icon="Sparkles", order=100, ), ], page_routes=[ FrontendPageRoute( path="/my-plugin", component="@/pages/MyPlugin", protected=True, ), ], detail_tabs=[ FrontendDetailTab( entity_type="contact", label_key="tabs.myPlugin", label="My Tab", component="@/components/MyTab", icon="FileText", order=50, permission="my_plugin:read", ), ], settings_pages=[ FrontendSettingsPage( path="my-plugin", label_key="settings.myPlugin", label="My Plugin", component="@/pages/MyPluginSettings", icon="Sparkles", order=100, permission="my_plugin:admin", ), ], dashboard_widgets=[ FrontendDashboardWidget( id="my_plugin_stats", label_key="widgets.myPlugin", label="My Plugin Stats", component="@/components/MyWidget", icon="LayoutDashboard", order=100, col_span=2, row_span=1, permission="my_plugin:read", ), ], ) ``` --- ## 4. Plugin Lifecycle ### 4.1 Installation (`on_install`) Called when the plugin is installed, after migrations are run. Override to perform seed data or initial setup. ```python async def on_install(self, db: AsyncSession, service_container: ServiceContainer) -> None: """Perform initial setup after migrations.""" # Create default settings settings_service = service_container.settings await settings_service.create_defaults(db, plugin_name=self.name) ``` ### 4.2 Activation (`on_activate`) Called when the plugin is activated. Override to register event listeners and prepare runtime state. The default implementation subscribes to events listed in the manifest. ```python async def on_activate( self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus ) -> None: """Register event listeners and prepare runtime state.""" # Default: subscribes to manifest events for event_name in self.manifest.events: handler = self._make_event_handler(event_name) self._event_handlers[event_name] = handler event_bus.subscribe(event_name, handler) self._container = service_container ``` ### 4.3 Deactivation (`on_deactivate`) Called when the plugin is deactivated. Override to clean up runtime state. The default implementation unsubscribes all event listeners. ```python async def on_deactivate( self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus ) -> None: """Clean up runtime state.""" for event_name, handler in self._event_handlers.items(): event_bus.unsubscribe(event_name, handler) self._event_handlers.clear() ``` ### 4.4 Uninstallation (`on_uninstall`) Called when the plugin is uninstalled (before data tables are dropped). Override to clean up external resources. ```python async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None: """Clean up external resources before tables are dropped.""" # Remove external API webhooks, etc. pass ``` --- ## 5. UI Registration Plugins contribute frontend UI elements through their manifest. The frontend `PluginRegistry` component fetches active manifests and populates the `pluginStore` (Zustand), which is then consumed by: ### 5.1 Sidebar Menu Items Menu items from all active plugins are merged and sorted by `order` via `getAllMenuItems()`. The sidebar renders them alongside built-in navigation items. ### 5.2 Page Routes Page routes are registered via `PluginRouteRenderer`, a catch-all route handler that checks the current URL against all plugin `page_routes`. When a match is found, it renders the plugin's page component using `PluginPage` (React.lazy + Suspense + ErrorBoundary). ### 5.3 Detail Tabs Detail tabs are filtered by `entity_type` via `getDetailTabsForEntity(entityType)`. Entity detail views (contacts, companies, etc.) render these tabs alongside built-in tabs. ### 5.4 Settings Pages Settings pages are merged and sorted via `getAllSettingsPages()`. The settings navigation renders them alongside built-in settings pages. ### 5.5 Dashboard Widgets Dashboard widgets are merged and sorted via `getAllDashboardWidgets()`. The dashboard grid renders them with their specified `col_span` and `row_span`. ### 5.6 Frontend Component Resolution Component paths use the `@/` alias (resolved to `src/` by Vite). The `PluginPage` component converts `@/pages/MyPlugin` to `../pages/MyPlugin` for dynamic import: ```typescript // PluginLoader.tsx const importPath = componentPath.replace(/^@\//, '../'); const LazyComp = lazy(() => import(/* @vite-ignore */ importPath).then((m) => ({ default: m.default || m[Object.keys(m)[0]], })) ); ``` --- ## 6. Event Bus ### 6.1 Subscribing to Events Events are declared in the manifest and handled by methods named `on_` with dots replaced by underscores: ```python # Manifest events=["contact.created", "contact.updated"] # Handler methods async def on_contact_created(self, event_data: dict): contact_id = event_data.get("contact_id") # React to new contact async def on_contact_updated(self, event_data: dict): contact_id = event_data.get("contact_id") # React to contact update ``` ### 6.2 Publishing Events Events are published via the EventBus service: ```python from app.core.event_bus import get_event_bus event_bus = get_event_bus() await event_bus.publish("contact.created", {"contact_id": str(contact.id)}) ``` ### 6.3 Event Naming Conventions - Format: `.` - Standard actions: `created`, `updated`, `deleted`, `assigned`, `completed` - Examples: `company.created`, `task.assigned`, `email.sent` - Use past tense for actions --- ## 7. Migration Runner ### 7.1 Writing Migrations SQL migration files are stored in the plugin's `migrations/` directory and referenced in the manifest: ```sql -- migrations/0001_initial.sql CREATE TABLE my_plugin_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id), name VARCHAR(200) NOT NULL, config JSONB DEFAULT '{}', is_active BOOLEAN DEFAULT true, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX idx_my_plugin_items_tenant ON my_plugin_items(tenant_id); ``` ### 7.2 Migration Naming - Files are named with a zero-padded sequence number and a descriptive slug: `0001_initial.sql`, `0002_add_indexes.sql` - Migrations run in alphanumeric order - Each migration runs exactly once per plugin installation ### 7.3 Migration Guidelines - Always include `tenant_id` for multi-tenant tables - Use `UUID` primary keys with `gen_random_uuid()` - Include `created_at` and `updated_at` timestamps - Add appropriate indexes for foreign keys and frequently queried columns - Use `IF NOT EXISTS` / `IF EXISTS` for idempotent operations --- ## 8. Service Container After activation, plugins can access shared services via `self.services`: ```python class MyPlugin(BasePlugin): async def on_activate(self, db, service_container, event_bus): self._container = service_container async def do_something(self): # Access services after activation settings = self.services.settings audit = self.services.audit cache = self.services.cache ``` Available services (defined in `app/core/service_container.py`): | Service | Accessor | Description | |---|---|---| | Settings | `self.services.settings` | Global and per-tenant settings | | Audit | `self.services.audit` | Audit logging | | Cache | `self.services.cache` | Redis/In-memory cache | | EventBus | `self.services.event_bus` | Event publishing/subscribing | | Notifications | `self.services.notifications` | User notifications | --- ## 9. RBAC / Permissions ### 9.1 Declaring Permissions Permissions are declared in the manifest: ```python permissions=[ "my_plugin:read", "my_plugin:write", "my_plugin:delete", "my_plugin:admin", ] ``` ### 9.2 Securing Routes Use the `require_permission` dependency: ```python from app.deps import get_current_user, require_permission from fastapi import Depends @router.get("", dependencies=[Depends(require_permission("my_plugin:read"))]) async def list_items(current_user: dict = Depends(get_current_user)): ... @router.post("", status_code=201, dependencies=[Depends(require_permission("my_plugin:write"))]) async def create_item(data: ItemCreate, current_user: dict = Depends(get_current_user)): ... ``` ### 9.3 Permission Naming Convention - Format: `:` - Standard actions: `read`, `write`, `delete`, `share`, `admin` - Examples: `calendar:read`, `dms:write`, `tags:delete` ### 9.4 Field-Level Permissions Field definitions in the manifest enable field-level access control: ```python field_definitions=[ FieldDefinition( module="companies", field="annual_revenue", label="Annual Revenue", sensitivity="sensitive", # normal|sensitive|critical ), ] ``` --- ## 10. AI Agent Integration ### 10.1 Agent Capabilities Declare AI capabilities in the manifest: ```python agent_capabilities=[ "contact_search", "email_draft", "calendar_scheduling", ] ``` ### 10.2 Tool Registry Plugins can register tools for the AI assistant: ```python from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry registry = get_tool_registry() registry.register( name="search_contacts", description="Search contacts by name, email, or phone number", parameters={ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "limit": {"type": "integer", "description": "Max results", "default": 10}, }, "required": ["query"], }, handler=my_search_handler, plugin_name="my_plugin", required_permission="contacts:read", category="search", ) ``` ### 10.3 Tool Handler ```python async def my_search_handler(arguments: dict, context: dict) -> str: query = arguments.get("query", "") limit = arguments.get("limit", 10) # Perform search... return json.dumps({"results": results}) ``` ### 10.4 Cleanup on Deactivation ```python async def on_deactivate(self, db, service_container, event_bus): registry = get_tool_registry() registry.unregister_plugin("my_plugin") ``` --- ## 11. Testing Guide ### 11.1 Backend Tests Tests live in the plugin's `tests/` directory or in the central `tests/` folder: ```python # tests/test_my_plugin.py import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_list_items_requires_permission(client: AsyncClient, auth_headers): response = await client.get("/api/v1/my-plugin", headers=auth_headers) assert response.status_code == 200 @pytest.mark.asyncio async def test_list_items_without_permission_returns_403(client: AsyncClient, no_perm_headers): response = await client.get("/api/v1/my-plugin", headers=no_perm_headers) assert response.status_code == 403 ``` ### 11.2 Frontend Tests Frontend tests use vitest, @testing-library/react, and jsdom: ```typescript // frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from '@testing-library/react'; import { PluginRegistry } from '../PluginRegistry'; import { usePluginStore } from '@/store/pluginStore'; // Mock the API hook vi.mock('@/api/pluginManifests', () => ({ useActivePluginManifests: () => ({ data: { plugins: [/* ... */], total: 1 }, isLoading: false, error: null, }), })); describe('PluginRegistry', () => { beforeEach(() => { usePluginStore.getState().reset(); }); it('populates store with manifests on mount', () => { render(); const manifests = usePluginStore.getState().manifests; expect(manifests).toHaveLength(1); }); }); ``` --- ## 12. Do's and Don'ts ### Do's - **Do** use snake_case for plugin names - **Do** declare all permissions in the manifest - **Do** secure every API route with `require_permission` - **Do** include `tenant_id` in all multi-tenant tables - **Do** use UUID primary keys - **Do** prefix i18n keys with the plugin name - **Do** clean up resources in `on_deactivate` and `on_uninstall` - **Do** write tests for both backend and frontend - **Do** follow the event naming convention `.` - **Do** use semantic versioning for plugin versions ### Don'ts - **Don't** hardcode tenant IDs or user IDs - **Don't** use synchronous database operations - **Don't** store secrets in the database without encryption - **Don't** modify other plugins' data directly (use events instead) - **Don't** create circular dependencies between plugins - **Don't** use `is_core=True` unless the plugin is essential for system operation - **Don't** skip error handling in event handlers (they run asynchronously) - **Don't** register the same route path in multiple plugins --- ## 13. Examples ### 13.1 Minimal Plugin ```python # app/plugins/builtins/minimal_example/plugin.py from app.plugins.base import BasePlugin from app.plugins.manifest import PluginManifest, PluginRouteDef class MinimalExamplePlugin(BasePlugin): manifest = PluginManifest( name="minimal_example", version="1.0.0", display_name="Minimal Example", description="A minimal example plugin.", dependencies=[], routes=[ PluginRouteDef( path="/api/v1/minimal-example", module="app.plugins.builtins.minimal_example.routes", router_attr="router", ), ], events=[], migrations=[], permissions=["minimal_example:read"], ) ``` ```python # app/plugins/builtins/minimal_example/routes.py from fastapi import APIRouter, Depends from app.deps import get_current_user, require_permission router = APIRouter() @router.get("", dependencies=[Depends(require_permission("minimal_example:read"))]) async def list_items(current_user: dict = Depends(get_current_user)): return {"items": []} ``` ### 13.2 Plugin with UI See the complete manifest example in Section 3.9 for a plugin with full UI contributions (menu items, page routes, detail tabs, settings pages, dashboard widgets). ### 13.3 Plugin with Events ```python # app/plugins/builtins/event_example/plugin.py from app.plugins.base import BasePlugin from app.plugins.manifest import PluginManifest class EventExamplePlugin(BasePlugin): manifest = PluginManifest( name="event_example", version="1.0.0", display_name="Event Example", description="Demonstrates event handling.", dependencies=[], events=["contact.created", "contact.updated", "contact.deleted"], migrations=[], permissions=[], ) async def on_contact_created(self, event_data: dict): contact_id = event_data.get("contact_id") print(f"Contact created: {contact_id}") async def on_contact_updated(self, event_data: dict): contact_id = event_data.get("contact_id") print(f"Contact updated: {contact_id}") async def on_contact_deleted(self, event_data: dict): contact_id = event_data.get("contact_id") print(f"Contact deleted: {contact_id}") ``` --- ## 7. LLM Integration LeoCRM stellt einen zentralen LLM-Client bereit über den alle LLM-Calls (Completion und Embedding) laufen. **Keine direkten `litellm.acompletion()` oder `litellm.aembedding()` Aufrufe in Plugin-Code.** ### 7.1 Completion ```python from app.ai.llm_client import llm_complete result = await llm_complete( model="openai/gpt-4o", # oder None für Default-Model messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Summarize this email."}, ], temperature=0.3, max_tokens=1000, # Optional: API-Key/Base aus DB holen db=db, tenant_id=tenant_id, # Optional: JSON-Response erzwingen response_format={"type": "json_object"}, # Optional: Tools für Function-Calling tools=[{"type": "function", "function": {...}}], # Optional: Retry-Konfiguration timeout=30, max_retries=2, ) content = result["content"] # str — LLM-Response-Text usage = result["usage"] # dict — {prompt_tokens, completion_tokens, total_tokens} cost_usd = result["cost_usd"] # float — geschätzte Kosten model = result["model"] # str — verwendetes Modell raw_response = result["raw_response"] # litellm-Response-Objekt für erweiterte Nutzung ``` ### 7.2 Embedding ```python from app.ai.llm_client import llm_embed # Einzelne Embedding embeddings = await llm_embed( texts="Text to embed", model="openai/text-embedding-3-small", # oder None für Default db=db, tenant_id=tenant_id, dimensions=768, # Optional, für text-embedding-3 Modelle ) # → [[0.01, 0.02, ...]] # Batch-Embedding embeddings = await llm_embed( texts=["Text 1", "Text 2", "Text 3"], db=db, tenant_id=tenant_id, ) # → [[...], [...], [...]] ``` ### 7.3 Provider-Auswahl und API-Key-Auflösung Der zentrale Client löst API-Keys automatisch aus der Datenbank (`AIProvider`-Tabelle) oder Environment-Variablen. Priorität: 1. Explizit übergebener `api_key` Parameter 2. DB-Lookup über `get_api_credentials(db, tenant_id)` 3. Environment-Variablen (`AI_API_KEY`, `AI_API_BASE`, `AI_PROVIDER`) 4. Mock-Mode (kein API-Key → Keyword-basierte Fallback-Antworten) ```python from app.ai.llm_client import get_api_credentials, build_model # API-Credentials aus DB holen api_key, api_base, provider_type = await get_api_credentials(db, tenant_id) # Model-String bauen (provider/model) model = build_model("gpt-4o", provider_type) # → "openai/gpt-4o" ``` ### 7.4 Error-Handling Der zentrale Client klassifiziert Errors automatisch: - **Transient** (Timeout, Rate-Limit 429, Service-Unavailable 503) → Retry mit Exponential-Backoff - **Permanent** (Auth 401/403, Validation, Model-Not-Found) → Sofortiger Fehler, kein Retry ```python try: result = await llm_complete(model="openai/gpt-4o", messages=[...]) except Exception as e: # Transient errors wurden bereits retried # Permanent errors kommen hier an logger.error(f"LLM call failed permanently: {e}") ``` ### 7.5 Cost-Tracking `llm_complete()` gibt `cost_usd` zurück — automatisch berechnet aus Token-Usage. Plugins sollen diesen Wert in ihren Cost-Tracking-Mechanismus übernehmen. ```python result = await llm_complete(...) total_cost += result["cost_usd"] ``` ### 7.6 Was NICHT zu tun ist - ❌ `import litellm` und direkte `litellm.acompletion()` / `litellm.aembedding()` Aufrufe - ❌ Eigene API-Key-Verwaltung — immer über `get_api_credentials()` oder `llm_complete(db=db, tenant_id=tenant_id)` - ❌ Eigene Retry-Logik — `llm_complete()` hat bereits Retry mit Backoff - ❌ Eigene Cost-Tracking-Logik — `llm_complete()` gibt `cost_usd` zurück - ❌ Eigene Provider-Auswahl — `build_model()` und `get_api_credentials()` zentralisieren das --- ## 8. Event-System Rollen LeoCRM hat **4 Event-Systeme** mit klar getrennten Rollen. **Nicht dieselbe Funktion über Hook UND EventBus triggern.** ### Übersicht | System | Rolle | Persistenz | Use Case | |--------|-------|-----------|----------| | **HookRegistry** | Lifecycle-Erweiterungspunkte | In-Memory | `contact.before_create`, `mail.after_send`, `dms.after_delete` — Plugins können Daten anpassen oder reagieren | | **EventBus** | Flüchtige interne Events | In-Memory | `notification.created`, `ui.contact_selected` — asynchrone Notifikationen, UI-Events, Proactive Suggestions | | **Outbox** | Dauerhafte Domain Events | DB (transactional) | `contact.created`, `mail.received`, `task.completed` — reliable Delivery, Retry, DLQ, Worker-Polling | | **WebhookDispatcher** | Externe HTTP-Zustellung | DB + HTTP | Externe Webhooks an registrierte URLs — Retry, Auth, Payload-Signatur | ### 8.1 HookRegistry (`app/core/hooks.py`) **Wann verwenden:** Wenn ein Plugin bei einem Lifecycle-Punkt Daten anpassen oder reagieren will. ```python from app.core.hooks import get_hook_registry reg = get_hook_registry() # Action — kein Return, nur Seiteneffekte reg.register_action("contact.before_create", self._on_contact_create, priority=10) # Filter — Return modifizierten Wert def _format_name(self, name: str) -> str: return name.title() reg.register_filter("contact.format_display_name", self._format_name, priority=10) ``` **Aufruf im Core/Plugin-Service:** ```python from app.core.hooks import do_action, apply_filters await do_action("contact.before_create", contact_data, db=db) display_name = await apply_filters("contact.format_display_name", contact.name) ``` ### 8.2 EventBus (`app/core/event_bus.py`) **Wann verwenden:** Für flüchtige interne Notifikationen, UI-Events, Proactive Suggestions. **Nicht** für Events die reliable Delivery brauchen. ```python from app.core.event_bus import get_event_bus event_bus = get_event_bus() # Subscribe event_bus.subscribe("notification.created", self._on_notification) # Publish (ephemeral — geht verloren bei Crash) await event_bus.publish("notification.created", {"user_id": "...", "message": "..."}) ``` ### 8.3 Outbox (`app/core/outbox.py`) **Wann verwenden:** Für dauerhafte Domain Events die reliable Delivery, Retry und Worker-Verarbeitung brauchen. ```python from app.core.outbox import enqueue_outbox_event # In derselben Transaktion wie die Business-Operation await enqueue_outbox_event(db, tenant_id, "contact.created", { "contact_id": str(contact.id), "tenant_id": str(tenant_id), }) # Transaction commit → Event ist durable → Worker pollt und published an EventBus ``` **Features:** DLQ (`error_message`, `failed_at`), Replay (`replay_failed_event`), Consumer Registry, Stats. ### 8.4 WebhookDispatcher (`app/core/webhook_dispatcher.py`) **Wann verwenden:** Für externe HTTP-Zustellung an registrierte Webhook-URLs. ```python from app.core.webhook_dispatcher import register_webhook_event_handlers # Wird automatisch im Worker registriert — Plugins müssen nur Webhook-Configs erstellen # und Events über die Outbox publishen ``` ### 8.5 Entscheidungsregel ```text Braucht das Event reliable Delivery + Retry? → JA → Outbox → NEIN → Braucht es Daten-Anpassung (Filter)? → JA → HookRegistry (register_filter) → NEIN → Braucht es nur Reaktion (Action)? → JA → HookRegistry (register_action) → NEIN → Ist es eine flüchtige Notifikation / UI-Event? → JA → EventBus → NEIN → Geht es an externe Systeme? → JA → WebhookDispatcher (über Outbox) → NEIN → Braucht kein Event ``` **Verboten:** Dieselbe Funktion über Hook UND EventBus triggern — das führt zu Doppel-Ausführung und Race-Conditions. --- ## 9. Schema Authority LeoCRM hat eine klare Schema-Authority-Hierarchie. **Kein neuer Schema-Mechanismus.** ### Authority-Regeln | Schema-Typ | Authority | Wie | |-----------|----------|-----| | **Core-Tabellen** | Alembic-Migrationen | `alembic revision --autogenerate -m "..."` → `alembic upgrade head` | | **Plugin-Tabellen** | Plugin-Migrationsweg | `plugin/migrations/` → `sync_plugin_schema.py` bei Aktivierung | | **Runtime Auto-Sync** | **Nicht authoritative** | `Base.metadata.create_all` in Tests/dev — nie in Produktion | ### Verbindliche Regeln 1. **Core-Schema-Änderungen** immer über Alembic-Migrationen — nie manuelle SQL-Statements in Produktion 2. **Plugin-Schema-Änderungen** über Plugin-Migrationen — nie Core-Migrationen für Plugin-Tabellen 3. **Runtime Auto-Sync** (`create_all`, `sync_plugin_schema.py`) ist Convenience für Dev/Tests — **nicht** für Produktion authoritative 4. **Migration-Staffelung** beachten: neu → migrieren → umstellen → testen → release → alt entfernen 5. **Keine Schema-Drift** — wenn Core und Plugin dasselbe Modell nutzen, ist Core authoritative ### Plugin-Migrationen ```python # plugin/migrations/001_initial.py from alembic import op def upgrade(): op.create_table("my_plugin_table", ...) def downgrade(): op.drop_table("my_plugin_table") ``` Plugin-Migrationen werden bei Plugin-Aktivierung automatisch ausgeführt (`sync_plugin_schema.py`). Bei Deaktivierung bleiben die Tabellen erhalten (Soft-Deactivate). Bei Uninstall werden sie gedroppt. --- *This document is authoritative for all plugin development at LeoCRM.*