Wie ich 5 KI-Provider in eine Tauri-Desktop-App integriert habe
Multi-Provider-KI in Tauri: OpenAI, Claude, Gemini, DeepSeek, Ollama. Drei Integrationsmuster und ein Rust-Proxy, der das Localhost-CORS-Problem löst.
Als ich beschloss, die hartkodierte OpenAI-Integration aus Beetroot rauszureißen und durch ein richtiges Multi-Provider-System zu ersetzen, dachte ich, das wäre ein Wochenend-Projekt. Fünf Provider, ein gemeinsames Interface, fertig.
Es hat eine Woche gedauert. Was ich dabei gelernt habe.
TL;DR:
- 3 von 5 Providern teilten sich eine OpenAI-kompatible Funktion (~50 Zeilen)
- Anthropic Claude brauchte eine komplett separate Integration (andere Header, anderes Request-Format, andere Response-Form)
- Lokale Modelle (Ollama, LM Studio) brauchten einen Rust-IPC-Proxy, nicht wegen der API, sondern wegen HTTPS → HTTP Mixed-Content-Blocking in der WebView
- Die versteckten Kosten waren nicht der KI-Code, sondern 260 Zeilen Übersetzungen pro Provider plus UI-Verkabelung
Der Stack macht es interessant
Beetroot baut auf Tauri v2 (Rust-Backend) + React 19 (WebView-Frontend) + Vite. Letzteres ist entscheidend: Das Frontend läuft in einer WebView, die im Grunde ein Browser ist. Jeder KI-API-Call geht über fetch() aus einem Browser-Kontext.
Das heißt: Du erbst alle netten Browser-Beschränkungen: CSP, CORS, Mixed-Content-Blocking. Für Cloud-APIs über HTTPS ist das größtenteils okay. Für eine lokale Ollama-Instanz auf http://localhost:11434? Nicht so sehr. Aber dazu kommen wir noch.
Drei Integrationsmuster, nicht fünf
Fünf Provider klingt nach fünf Integrationen. In der Praxis sind es drei Muster:
| Muster | Provider | Komplexität |
|---|---|---|
| OpenAI-kompatibel | OpenAI, Gemini, DeepSeek | Niedrig (~20 Zeilen pro Stück) |
| Anthropic nativ | Claude | Mittel (~60 Zeilen) |
| Rust-IPC-Proxy | Lokales LLM (Ollama, LM Studio) | Hoch (~130 Zeilen Rust + 50 TS) |
Der Grund, dass drei von fünf Providern ein Muster teilen, ist einfach: Das OpenAI-Chat-Completions-Format hat sich zum De-facto-Standard entwickelt. Google bietet einen OpenAI-Kompatibilitäts-Layer für Gemini unter /v1beta/openai/chat/completions. DeepSeek spricht es nativ. Eine geteilte Funktion behandelt also alle drei.
Muster 1: die geteilte Funktion
Der Kern des Multi-Provider-Systems ist callOpenAICompatible(), 49 Zeilen, die Timeouts, Fehler-Parsing und Response-Extraktion für jede OpenAI-kompatible API erledigen:
async function callOpenAICompatible(
url: string,
headers: Record<string, string>,
body: object,
providerName: string,
): Promise<OpenAIResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30_000);
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
let errorMsg = `API error ${response.status}`;
try {
const parsed = JSON.parse(errorBody);
if (parsed?.error?.message) errorMsg = parsed.error.message;
} catch {}
return { error: errorMsg };
}
const data = await response.json();
const msg0 = data?.choices?.[0]?.message;
const content = msg0?.content || msg0?.reasoning_content;
if (typeof content !== "string" || content.trim().length === 0) {
return { error: `Empty response from ${providerName}` };
}
return { text: stripThinkTags(content.trim()) };
}Jeder Provider wird dann zu einem dünnen Wrapper. Hier ist die komplette Gemini-Integration:
return callOpenAICompatible(
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
{ Authorization: `Bearer ${config.geminiKey}` },
{
model: config.geminiModel,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: `Instruction: ${prompt}\n\n${inputText}` },
],
max_tokens: 4096,
},
"Gemini",
);Das war's. Vierzehn Zeilen. URL und Auth-Header sind die einzigen Dinge, die sich zwischen OpenAI, Gemini und DeepSeek ändern.
Die Eigenheiten pro Provider
„OpenAI-kompatibel" heißt nicht „identisch". Jeder Provider hat mindestens eine Sache, die dich überrascht.
OpenAI (GPT-5-Serie) hat drei Parameternamen gegenüber GPT-4 geändert:
{
model: "gpt-5-nano",
messages: [
{ role: "developer", content: SYSTEM_PROMPT }, // nicht "system"
{ role: "user", content: inputText },
],
max_completion_tokens: 4096, // nicht max_tokens
reasoning_effort: "low", // nicht temperature
}Wenn du die alten Parameternamen verwendest, bekommst du einen verwirrenden 400-Fehler über ungültige Felder, ohne Deprecation-Warnung, ohne hilfreiche Meldung.
DeepSeek R1 liefert Reasoning-Traces verpackt in <think>...</think>-Tags vor der eigentlichen Antwort. Wenn du sie nicht entfernst, kommt dein „Grammatik-Fix" mit einem zweiseitigen inneren Monolog über englische Syntaxregeln zurück. Der Fix ist klein, aber leicht zu übersehen:
function stripThinkTags(text: string): string {
const trimmed = text.trim();
if (!trimmed.startsWith("<think>")) return trimmed;
const end = trimmed.indexOf("</think>");
if (end !== -1) return trimmed.slice(end + 8).trim();
return trimmed.slice(7).trim();
}Das läuft auf jedem Provider-Output. Für OpenAI und Gemini ist es ein No-Op. Für DeepSeek R1 spart es den Nutzern, die inneren Gedanken der KI über ihre Tippfehler zu lesen.
Geminis Kompatibilitäts-Layer verdient eine Erwähnung. Googles primäre Gemini-API nutzt ein anderes Format (generateContent mit einem parts-Array). Aber sie bieten auch einen OpenAI-Kompatibilitäts-Layer unter generativelanguage.googleapis.com/v1beta/openai/chat/completions an. Gleiche Modelle, gleicher Free-Tier, Standard-Authorization: Bearer-Header. Es funktioniert einfach und hat mir ein viertes Integrationsmuster erspart.
Muster 2: Anthropic spielt nach eigenen Regeln
Claudes Messages-API ist nicht OpenAI-kompatibel. Sie hat ihr eigenes Request-Format, ihre eigenen Header und ihre eigene Response-Form. Sie bekommt also ihre eigene 62-Zeilen-Funktion.
Hier ist der Fetch-Call:
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.anthropicKey,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({
model: ANTHROPIC_MODEL_IDS[config.anthropicModel] ?? config.anthropicModel,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: inputText }],
max_tokens: 4096,
}),
});Vier Unterschiede zu OpenAI in einem Block:
- Auth-Header:
x-api-keystattAuthorization: Bearer - CORS-Header:
anthropic-dangerous-direct-browser-access: true. Ja, so heißt der Header wirklich. Anthropic verlangt ihn für browserbasierte API-Calls. Der Name ist… deskriptiv. - System-Prompt: ein Top-Level-
system-Feld, keine Message mitrole: "system" - Response-Form:
data.content[0].textstattdata.choices[0].message.content
Es gibt auch ein Model-ID-Mapping, weil Anthropic intern datierte Snapshot-IDs verwendet:
const ANTHROPIC_MODEL_IDS: Record<string, string> = {
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
"claude-sonnet-4-6": "claude-sonnet-4-6-20250514",
};Nutzer sehen „Claude Haiku 4.5" im Dropdown. Die datierte ID geht an die API. Wenn Anthropic einen neuen Snapshot rausbringt, aktualisiere ich ein Mapping-Objekt und sonst nichts.
Lohnt sich die Code-Duplikation? Die Alternative war ein Adapter-Layer, der Anthropics Format intern in das von OpenAI konvertiert. Das hatte ich zuerst probiert, es waren 80 Zeilen statt 62 und schwerer zu debuggen, wenn etwas schiefging. Manchmal gewinnt der direkte Ansatz.
Muster 3: lokale Modelle und die Mixed-Content-Falle
Hier habe ich den Großteil der Woche verbracht.
Das Problem ist trügerisch einfach: Tauris WebView läuft auf einem HTTPS-Origin (https://tauri.localhost). Ollama läuft auf http://localhost:11434. Ein Browser blockiert diesen Fetch, weil es sich um Mixed Content handelt: eine HTTPS-Seite, die einen HTTP-Request macht.
Keine Menge CORS-Header behebt das. Es ist eine Browser-Sicherheitsrichtlinie. Der Request verlässt die WebView nie.

Die Lösung: Lokale KI-Calls über Tauris Rust-Backend mittels IPC-Befehlen routen. Das Frontend ruft invoke("local_ai_chat") auf, Rust macht den HTTP-Request mit ureq und gibt das Ergebnis zurück.
Frontend →
invoke("local_ai_chat")→ Rust →http://localhost:11434→ Rust → Frontend
Hier ist der Rust-Befehl (vereinfacht):
#[tauri::command]
pub async fn local_ai_chat(request: LocalAIRequest) -> LocalAIResponse {
if request.user_message.len() > 100_000 {
return LocalAIResponse {
ok: false, text: String::new(),
error: "Input text too long (max 100K characters)".into(),
};
}
let ep = request.endpoint.trim().trim_end_matches('/').to_string();
if let Err(e) = validate_loopback(&ep) {
return LocalAIResponse {
ok: false, text: String::new(),
error: e.to_string(),
};
}
// Blockierendes HTTP via ureq, läuft auf separatem Thread
tauri::async_runtime::spawn_blocking(move || {
let body = serde_json::json!({
"model": model,
"messages": [
{ "role": "system", "content": request.system_prompt },
{ "role": "user", "content": request.user_message },
],
});
match ureq::post(&url)
.set("Content-Type", "application/json")
.timeout(Duration::from_secs(120))
.send_json(&body)
{
Ok(resp) => { /* parse response */ }
Err(e) => LocalAIResponse {
ok: false, text: String::new(),
error: e.to_string(),
},
}
}).await.unwrap_or_else(|e| LocalAIResponse {
ok: false, text: String::new(),
error: format!("Task failed: {e}"),
})
}Drei zusätzliche Rust-Befehle stützen das UI: test_local_endpoint (prüft Konnektivität), list_local_models (holt installierte Ollama-Modelle für das Dropdown) und die validate_loopback-Funktion, die SSRF verhindert.
Der SSRF-Fix, den niemand bestellt hat
Da der Rust-Proxy HTTP-Requests im Auftrag des Frontends macht, muss die Endpoint-URL validiert werden. Ohne das könnte jemand den „lokalen KI"-Endpoint auf http://169.254.169.254 (Cloud-Metadata-Endpoint) oder irgendeinen internen Service zeigen lassen.
Der Fix ist validate_loopback(). Es lässt nur localhost, 127.0.0.1 und [::1] durch:
fn validate_loopback(endpoint: &str) -> Result<(), AppError> {
let lower = endpoint.to_lowercase();
let host_part = if let Some(rest) = lower.strip_prefix("https://") {
rest
} else if let Some(rest) = lower.strip_prefix("http://") {
rest
} else {
return Err(AppError::Validation(
"Only http/https endpoints allowed".into(),
));
};
let loopback_hosts = ["localhost", "127.0.0.1", "[::1]"];
let is_loopback = loopback_hosts.iter().any(|host| {
if let Some(rest) = host_part.strip_prefix(host) {
rest.is_empty() || rest.starts_with(':') || rest.starts_with('/')
} else {
false
}
});
if is_loopback { Ok(()) }
else { Err(AppError::Validation("Only loopback endpoints allowed".into())) }
}Cloud-Provider brauchen das nicht, ihre Endpoints sind hartkodierte HTTPS-URLs. Nur der lokale Proxy akzeptiert vom Nutzer konfigurierte URLs, und nur Loopback-Adressen kommen durch die Validierung.
Das Prompt-System: eines für alle
Alle fünf Provider teilen sich dieselbe Prompt-Bibliothek: 10 eingebaute Prompts (Grammatik-Fix, Übersetzen, Zusammenfassen usw.), übersetzt in 26 Sprachen, plus bis zu 20 eigene Prompts. Bis zu 5 lassen sich als „Quick Access" pinnen, die tauchen im Rechtsklick-Kontextmenü auf.
Das war eine bewusste Designentscheidung. Der Wechsel von OpenAI zu Gemini oder von Cloud zu lokal sollte deinen Workflow nicht ändern. Gleiche Prompts, gleiches Menü, gleiche Tastenkürzel. Was sich ändert, ist nur, wo der Request hingeht.
Der AIProvider-Typ steuert das gesamte System:
type AIProvider = "openai" | "gemini" | "anthropic" | "deepseek" | "local";Settings, UI-Tabs, Transform-Routing, alles verzweigt auf dieser einen Union. Einen sechsten Provider hinzuzufügen heißt: einen weiteren String zu diesem Typ, eine Transform-Funktion, einen Settings-Tab.
Was es tatsächlich kostet, einen Provider hinzuzufügen
Nachdem alle fünf gebaut waren, ist das Muster klar:
| Was zu ändern ist | OpenAI-kompatibel | Custom-API |
|---|---|---|
| Transform-Funktion | ~20 Zeilen | ~60 Zeilen |
| Settings-Typen + Defaults | ~15 Zeilen | ~15 Zeilen |
| Settings-UI-Tab | ~40 Zeilen | ~40 Zeilen |
| State-Forwarding | ~5 Zeilen | ~5 Zeilen |
| Übersetzungen (26 Sprachen) | ~260 Zeilen | ~260 Zeilen |
| Gesamt | ~340 Zeilen | ~380 Zeilen |
Den Großteil machen die Übersetzungen aus. Der eigentliche Integrationscode für einen OpenAI-kompatiblen Provider liegt bei etwa 80 Zeilen. Für ein komplett eigenes API-Format wie Anthropics bei etwa 120.
Der Rust-Proxy ist eine Einmalkosten-Position. Er funktioniert für jedes lokale Modell, das das OpenAI-Protokoll spricht. Ollama, LM Studio, llama.cpp, vLLM, alle gehen über denselben local_ai_chat-Befehl.
Kernpunkte
OpenAI-kompatibel ist der richtige Default. Drei von fünf Providern nutzen es. Selbst Gemini liefert einen Kompatibilitäts-Endpoint mit. Wenn du ein KI-Feature baust, fang mit OpenAIs Format an und du deckst die meisten Provider gratis ab.
Anthropics Abweichung hat einen Preis. Die separate Implementierung ist nicht komplex, 62 Zeilen, aber 62 Zeilen, die nur existieren, weil ein Anbieter eine andere API-Form gewählt hat. Der anthropic-dangerous-direct-browser-access-Header ist ein Hinweis darauf, dass browserbasierter Zugriff nicht ihr primärer Use-Case war.
Lokale Modelle sind aus dem falschen Grund am schwierigsten. Die API ist der einfache Teil (sie ist OpenAI-kompatibel). Der schwierige Teil ist das Browser-Sicherheitsmodell. Wenn deine App in einer WebView läuft, ist jeder HTTP-Endpoint ohne native Proxy-Schicht unerreichbar. Plane das von Anfang an ein.
BYOK (Bring Your Own Key) in Desktop-Apps ist einfacher als in Web-Apps. Kein Backend-Server, kein Key-Management-Service, kein Proxy, der Keys vor dem Browser verstecken muss. Der API-Key liegt in den lokalen Settings des Nutzers, und Requests gehen direkt von der App an den Provider. Der Tradeoff: Jeder Nutzer braucht seinen eigenen API-Key, aber für ein kostenloses Tool ist das der richtige Tradeoff.
Der harte Teil bei Multi-Provider-KI war nicht das Model-Routing. Es war alles drumherum: Browser-Sicherheitsbeschränkungen, UX-Konsistenz über fünf verschiedene Provider, sicherer lokaler Zugriff und 260 Zeilen Übersetzungen für jeden neuen Tab in den Settings.