feat: add Ollama/local LLM provider support

Add Ollama as a third AI provider option alongside Anthropic and OpenAI.
Uses the OpenAI-compatible API endpoint that Ollama exposes, configured
via OLLAMA_URL and OLLAMA_MODEL env vars. Provider selection is now
tenant-aware via DB settings, with env var fallback.

- New provider type 'ollama' in AIProvider union
- Tenant-aware getModelForTenant() reads AI config from tenant settings jsonb
- Admin settings UI on /einstellungen for provider/model selection
- API route GET/PATCH /api/settings/ai for tenant AI config
- Updated all AI call sites (analysis, structured-analysis, contracts)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-09 08:11:24 +00:00
parent bd132315b4
commit ffdab093ff
7 changed files with 271 additions and 23 deletions

View File

@@ -0,0 +1,141 @@
'use client';
import { useEffect, useState } from 'react';
interface AISettings {
provider: string;
model: string;
ollamaUrl: string;
ollamaModel: string;
}
export default function AISettingsForm() {
const [settings, setSettings] = useState<AISettings>({
provider: 'anthropic',
model: '',
ollamaUrl: 'http://localhost:11434',
ollamaModel: 'llama3',
});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/settings/ai')
.then((r) => r.json())
.then((data) => {
setSettings(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
const res = await fetch('/api/settings/ai', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (res.ok) {
setMessage({ type: 'success', text: 'AI-Einstellungen gespeichert.' });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Speichern.' });
}
} catch {
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<p className="text-sm text-muted">Lade AI-Einstellungen...</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">AI-Provider</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-muted mb-1">Provider</label>
<select
value={settings.provider}
onChange={(e) => setSettings({ ...settings, provider: e.target.value })}
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground"
>
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai">OpenAI (GPT)</option>
<option value="ollama">Ollama (Lokal)</option>
</select>
</div>
{settings.provider !== 'ollama' && (
<div>
<label className="block text-sm text-muted mb-1">Modell (optional)</label>
<input
type="text"
value={settings.model}
onChange={(e) => setSettings({ ...settings, model: e.target.value })}
placeholder={settings.provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o'}
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
/>
</div>
)}
{settings.provider === 'ollama' && (
<>
<div>
<label className="block text-sm text-muted mb-1">Ollama URL</label>
<input
type="text"
value={settings.ollamaUrl}
onChange={(e) => setSettings({ ...settings, ollamaUrl: e.target.value })}
placeholder="http://localhost:11434"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">Ollama Modell</label>
<input
type="text"
value={settings.ollamaModel}
onChange={(e) => setSettings({ ...settings, ollamaModel: e.target.value })}
placeholder="llama3"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
/>
</div>
<p className="text-xs text-muted">
Ollama muss lokal oder auf dem Server laufen. Die OpenAI-kompatible API wird verwendet.
</p>
</>
)}
</div>
{message && (
<p className={`mt-3 text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
{message.text}
</p>
)}
<button
type="submit"
disabled={saving}
className="mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</form>
);
}

View File

@@ -4,6 +4,7 @@ import { hasPermission } from '@/lib/auth/rbac';
import { db } from '@/lib/db';
import { tenants, users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import AISettingsForm from './ai-settings';
const ROLE_LABELS: Record<string, string> = {
admin: 'Administrator',
@@ -70,6 +71,8 @@ export default async function EinstellungenPage() {
</div>
</div>
{isAdmin && <AISettingsForm />}
{isAdmin && tenantUsers.length > 0 && (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">Benutzer</h3>

View File

@@ -0,0 +1,66 @@
// GET /api/settings/ai — Read current AI provider settings for tenant
// PATCH /api/settings/ai — Update AI provider settings (admin only)
import { db } from '@/lib/db';
import { tenants } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
import type { AIProvider } from '@/lib/ai/providers';
const VALID_PROVIDERS = new Set<AIProvider>(['anthropic', 'openai', 'ollama']);
export async function GET() {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const [tenant] = await db
.select({ settings: tenants.settings })
.from(tenants)
.where(eq(tenants.id, auth.ctx.tenantId))
.limit(1);
const settings = tenant?.settings ?? {};
return Response.json({
provider: (settings.aiProvider as string) ?? process.env.AI_PROVIDER ?? 'anthropic',
model: (settings.aiModel as string) ?? process.env.AI_MODEL ?? '',
ollamaUrl: (settings.ollamaUrl as string) ?? process.env.OLLAMA_URL ?? 'http://localhost:11434',
ollamaModel: (settings.ollamaModel as string) ?? process.env.OLLAMA_MODEL ?? 'llama3',
});
}
export async function PATCH(request: Request) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const body = await request.json();
const { provider, model, ollamaUrl, ollamaModel } = body;
if (provider && !VALID_PROVIDERS.has(provider)) {
return Response.json(
{ error: `Ungültiger Provider. Erlaubt: ${[...VALID_PROVIDERS].join(', ')}` },
{ status: 400 },
);
}
const [tenant] = await db
.select({ settings: tenants.settings })
.from(tenants)
.where(eq(tenants.id, auth.ctx.tenantId))
.limit(1);
const current = (tenant?.settings ?? {}) as Record<string, unknown>;
const updated = {
...current,
...(provider !== undefined && { aiProvider: provider }),
...(model !== undefined && { aiModel: model }),
...(ollamaUrl !== undefined && { ollamaUrl }),
...(ollamaModel !== undefined && { ollamaModel }),
};
await db
.update(tenants)
.set({ settings: updated, updatedAt: new Date() })
.where(eq(tenants.id, auth.ctx.tenantId));
return Response.json({ ok: true });
}

View File

@@ -1,7 +1,7 @@
// Core analysis service — orchestrates norm/decision lookup, prompt assembly, and AI generation
import { streamText, generateText } from 'ai';
import { getModel, getProvider } from './providers';
import { getModelForTenant } from './providers';
import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts';
import { ANALYSIS_MODES } from './modes';
import { AnalyseMode } from '@/types';
@@ -108,7 +108,7 @@ export async function runAnalysis(input: AnalysisInput) {
const contextBlock = buildContextBlock(normContext, decisionContext);
const model = getModel();
const { model, provider, modelId } = await getModelForTenant(input.tenantId);
// Create the analysis record (status: in_progress)
const [analysis] = await db
@@ -121,8 +121,8 @@ export async function runAnalysis(input: AnalysisInput) {
status: 'in_progress',
title: input.title,
query: input.query,
aiProvider: getProvider(),
aiModel: process.env.AI_MODEL ?? 'default',
aiProvider: provider,
aiModel: modelId,
sources: {
normIds: normContext.map((n) => n.id),
decisionIds: decisionContext.map((d) => d.id),
@@ -182,7 +182,7 @@ export async function runAnalysisSync(input: AnalysisInput) {
? `${contextBlock}\n\n---\n\n## Rechtsfrage\n\n${input.query}`
: input.query;
const model = getModel();
const { model, provider, modelId } = await getModelForTenant(input.tenantId);
const [analysis] = await db
.insert(analyses)
@@ -194,8 +194,8 @@ export async function runAnalysisSync(input: AnalysisInput) {
status: 'in_progress',
title: input.title,
query: input.query,
aiProvider: getProvider(),
aiModel: process.env.AI_MODEL ?? 'default',
aiProvider: provider,
aiModel: modelId,
sources: {
normIds: normContext.map((n) => n.id),
decisionIds: decisionContext.map((d) => d.id),

View File

@@ -1,31 +1,69 @@
// AI Provider abstraction via Vercel AI SDK v6
// Supports: Anthropic, OpenAI — selected via AI_PROVIDER env var
// Supports: Anthropic, OpenAI, Ollama — selected via AI_PROVIDER env var or tenant settings
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import { createOpenAI, openai } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
import { db } from '@/lib/db';
import { tenants } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
export type AIProvider = 'anthropic' | 'openai';
export type AIProvider = 'anthropic' | 'openai' | 'ollama';
const DEFAULT_MODELS: Record<AIProvider, string> = {
anthropic: 'claude-sonnet-4-20250514',
openai: 'gpt-4o',
ollama: 'llama3',
};
export function getProvider(): AIProvider {
const p = process.env.AI_PROVIDER;
if (p === 'openai') return 'openai';
if (p === 'ollama') return 'ollama';
return 'anthropic';
}
function buildModel(provider: AIProvider, modelId: string, ollamaUrl?: string): LanguageModel {
switch (provider) {
case 'anthropic':
return anthropic(modelId);
case 'openai':
return openai(modelId);
case 'ollama': {
const baseURL = ollamaUrl ?? process.env.OLLAMA_URL ?? 'http://localhost:11434';
const ollama = createOpenAI({
baseURL: `${baseURL}/v1`,
apiKey: 'ollama',
});
return ollama(modelId);
}
}
}
export function getModel(provider?: AIProvider, modelId?: string): LanguageModel {
const p = provider ?? getProvider();
const id = modelId ?? process.env.AI_MODEL ?? DEFAULT_MODELS[p];
switch (p) {
case 'anthropic':
return anthropic(id);
case 'openai':
return openai(id);
}
return buildModel(p, id);
}
/** Tenant-aware model getter: reads AI settings from tenant DB, falls back to env vars. */
export async function getModelForTenant(tenantId: string): Promise<{ model: LanguageModel; provider: AIProvider; modelId: string }> {
const [tenant] = await db
.select({ settings: tenants.settings })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
const s = tenant?.settings as Record<string, string> | undefined;
const provider: AIProvider = (['anthropic', 'openai', 'ollama'].includes(s?.aiProvider ?? '') ? s!.aiProvider : getProvider()) as AIProvider;
let modelId: string;
if (provider === 'ollama') {
modelId = s?.ollamaModel ?? process.env.OLLAMA_MODEL ?? DEFAULT_MODELS.ollama;
} else {
modelId = s?.aiModel || process.env.AI_MODEL || DEFAULT_MODELS[provider];
}
const ollamaUrl = s?.ollamaUrl;
return { model: buildModel(provider, modelId, ollamaUrl), provider, modelId };
}

View File

@@ -2,7 +2,7 @@
// Returns structured data for frontend consumption with Quellenrang integration
import { generateText } from 'ai';
import { getModel, getProvider } from './providers';
import { getModelForTenant } from './providers';
import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts';
import { ANALYSIS_MODES } from './modes';
import { STRUCTURED_OUTPUT_INSTRUCTION, type StructuredAnalysisOutput } from './structured-output';
@@ -135,7 +135,7 @@ export async function runStructuredAnalysis(
// Add structured output instruction to system prompt
const systemPrompt = SYSTEM_PROMPTS[systemPromptKey] + STRUCTURED_OUTPUT_INSTRUCTION;
const model = getModel();
const { model, provider, modelId } = await getModelForTenant(input.tenantId);
// Create analysis record
const [analysis] = await db
@@ -148,8 +148,8 @@ export async function runStructuredAnalysis(
status: 'in_progress',
title: input.title,
query: input.query,
aiProvider: getProvider(),
aiModel: process.env.AI_MODEL ?? 'default',
aiProvider: provider,
aiModel: modelId,
sources: {
normIds: normContext.map((n) => n.id),
decisionIds: decisionContext.map((d) => d.id),

View File

@@ -11,7 +11,7 @@ import {
} from '@/lib/db/schema';
import { eq, and, desc } from 'drizzle-orm';
import { generateText } from 'ai';
import { getModel } from '@/lib/ai/providers';
import { getModelForTenant } from '@/lib/ai/providers';
const ALLOWED_MIME_TYPES = new Set([
'application/pdf',
@@ -208,7 +208,7 @@ export async function analyzeContractClauses(tenantId: string, documentId: strin
.join('\n\n')
: 'Keine Standardklauseln verfügbar.';
const model = getModel();
const { model } = await getModelForTenant(tenantId);
const { text: analysisResult } = await generateText({
model,