Tauri デスクトップアプリに AI プロバイダーを 5 つ追加した方法
Tauri におけるマルチプロバイダー AI: OpenAI、Claude、Gemini、DeepSeek、Ollama。3 つの統合パターンと、localhost CORS を解決する Rust プロキシ。
Beetroot からハードコードされた OpenAI 統合を引き剥がし、本格的なマルチプロバイダー方式に置き換えると決めたとき、週末で終わると考えていました。プロバイダー 5 つ、共通インターフェース 1 つ、完了。
1 週間かかりました。学んだことを書きます。
TL;DR:
- 5 つのうち 3 つのプロバイダーは 1 つの OpenAI 互換関数 (約 50 行) を共有
- Anthropic Claude は完全に別の統合が必要 (ヘッダー、リクエスト形式、レスポンス形状すべてが異なる)
- ローカルモデル (Ollama, LM Studio) には Rust IPC プロキシが必要、API のためではなく WebView の HTTPS → HTTP Mixed Content ブロックのため
- 隠れたコストは AI コードではなく、プロバイダーごとに 260 行の翻訳と UI まわりの配線
スタックが面白くする
Beetroot は Tauri v2 (Rust バックエンド) + React 19 (WebView フロントエンド) + Vite で作られています。最後のポイントが鍵で、フロントエンドは WebView の中で動いており、これは本質的にブラウザーです。すべての AI API 呼び出しはブラウザーコンテキストの fetch() 経由で起きます。
つまり、ブラウザーの愉快な制約を全部継承します。CSP、CORS、Mixed Content ブロック。HTTPS のクラウド API ならほぼ問題ありません。http://localhost:11434 のローカル Ollama インスタンスは? そうでもありません。それは後で扱います。
3 つの統合パターン、5 つではなく
5 プロバイダーは 5 つの統合のように聞こえますが、実際には 3 パターンです。
| パターン | プロバイダー | 複雑さ |
|---|---|---|
| OpenAI 互換 | OpenAI, Gemini, DeepSeek | 低 (各 ~20 行) |
| Anthropic ネイティブ | Claude | 中 (~60 行) |
| Rust IPC プロキシ | ローカル LLM (Ollama, LM Studio) | 高 (Rust ~130 行 + TS 50 行) |
5 つのうち 3 つがパターンを共有する理由は単純です。OpenAI の Chat Completions 形式が事実上の標準になりました。Google は Gemini 用に OpenAI 互換レイヤー を /v1beta/openai/chat/completions で提供しています。DeepSeek はネイティブで話します。だから 1 つの共通関数で 3 つを扱えます。
パターン 1: 共通関数
マルチプロバイダーシステムの中心は callOpenAICompatible() です。49 行で、タイムアウト、エラー解析、任意の OpenAI 互換 API のレスポンス抽出を扱います。
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()) };
}各プロバイダーは薄いラッパーになります。Gemini 統合の全文:
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",
);これだけです。14 行。OpenAI、Gemini、DeepSeek の間で変わるのは URL と認証ヘッダーだけです。
プロバイダーごとの癖
「OpenAI 互換」は「同一」を意味しません。各プロバイダーには少なくとも 1 つ、不意打ちを食わせるものがあります。
OpenAI (GPT-5 シリーズ) は GPT-4 から比べてパラメーター名を 3 つ変えました。
{
model: "gpt-5-nano",
messages: [
{ role: "developer", content: SYSTEM_PROMPT }, // "system" ではない
{ role: "user", content: inputText },
],
max_completion_tokens: 4096, // max_tokens ではない
reasoning_effort: "low", // temperature ではない
}古いパラメーター名を使うと、無効なフィールドに関する紛らわしい 400 エラーが返ってきます。Deprecation 警告も役立つメッセージもありません。
DeepSeek R1 は本当の答えの前に推論トレースを <think>...</think> タグで包んで返します。剥がさないと、「文法修正」が英語シンタックスについての 2 段落の内省と一緒に戻ってきます。修正は小さいですが見逃しやすい。
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();
}これはすべてのプロバイダーの出力で走ります。OpenAI と Gemini では何もしません。DeepSeek R1 では、ユーザーが自分のタイポについての AI の内なる思考を読まずに済みます。
Gemini の互換レイヤー は触れる価値があります。Google の主要な Gemini API は別の形式 (parts 配列付きの generateContent) を使います。しかし generativelanguage.googleapis.com/v1beta/openai/chat/completions で OpenAI 互換レイヤー も提供しています。同じモデル、同じ無料枠、標準の Authorization: Bearer ヘッダー。動きますし、おかげで 4 つ目の統合パターンを書かずに済みました。
パターン 2: Anthropic は独自ルール
Claude の Messages API は OpenAI 互換ではありません。リクエスト形式、ヘッダー、レスポンス形状がすべて独自です。だから 62 行の独自関数になります。
fetch 呼び出し:
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,
}),
});OpenAI と異なる点が 1 ブロックに 4 つ:
- 認証ヘッダー:
Authorization: Bearerではなくx-api-key - CORS ヘッダー:
anthropic-dangerous-direct-browser-access: true、はい、これが本当のヘッダー名です。Anthropic はブラウザーベースの API 呼び出しにこれを要求します。名前は……記述的です。 - システムプロンプト:
role: "system"のメッセージではなく、トップレベルのsystemフィールド - レスポンス形状:
data.choices[0].message.contentではなくdata.content[0].text
モデル ID マッピングもあります。Anthropic は内部的に日付付きスナップショット ID を使うからです。
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",
};ユーザーはドロップダウンで「Claude Haiku 4.5」を見ます。日付付きの ID が API に行きます。Anthropic が新しいスナップショットをリリースしたら、マッピングオブジェクトを 1 つ更新するだけで他は何も変わりません。
コードの重複は割に合うのか? 代替は Anthropic 形式を内部的に OpenAI 形式に変換するアダプターレイヤーでした。先に試しましたが、62 行ではなく 80 行になり、何かが起きたときデバッグが難しかったのです。素直なアプローチが勝つことがあります。
パターン 3: ローカルモデルと Mixed Content の罠
ここで週の大半を費やしました。
問題は欺かれるほど単純です。Tauri の WebView は HTTPS オリジン (https://tauri.localhost) で動きます。Ollama は http://localhost:11434 で動きます。ブラウザーはこの fetch を Mixed Content としてブロックします。HTTPS ページが HTTP リクエストを行うからです。
CORS ヘッダーをどれだけ盛っても直りません。ブラウザーのセキュリティポリシーです。リクエストは WebView を出ません。

解決策: ローカル AI 呼び出しを Tauri の Rust バックエンドに IPC 経由でルーティングします。フロントエンドは invoke("local_ai_chat") を呼び、Rust が ureq で HTTP リクエストを行い、結果を返します。
フロントエンド →
invoke("local_ai_chat")→ Rust →http://localhost:11434→ Rust → フロントエンド
Rust コマンド (簡略):
#[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(),
};
}
// ureq によるブロッキング HTTP、別スレッドで実行
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}"),
})
}UI を支える追加の Rust コマンドが 3 つあります。test_local_endpoint (接続性チェック)、list_local_models (ドロップダウン用にインストール済み Ollama モデルを取得)、SSRF を防ぐ validate_loopback 関数です。
誰も頼んでいない SSRF 修正
Rust プロキシがフロントエンドの代わりに HTTP リクエストを行うため、エンドポイント URL の検証が必要です。検証なしだと、「ローカル AI」エンドポイントを http://169.254.169.254 (クラウドメタデータエンドポイント) や任意の内部サービスに向けられてしまいます。
修正は validate_loopback() です。localhost、127.0.0.1、[::1] のみ許可します。
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())) }
}クラウドプロバイダーには不要です。エンドポイントはハードコードされた HTTPS URL だからです。ユーザー設定の URL を受け付けるのはローカルプロキシだけで、ループバックアドレスのみが検証を通ります。
プロンプトシステム: 1 つで全員分
5 プロバイダーすべてが同じプロンプトライブラリを共有します。組み込みプロンプト 10 種 (文法修正、翻訳、要約など) を 26 言語に翻訳、加えてカスタムプロンプトが最大 20 個。最大 5 個を「クイックアクセス」としてピン留めでき、それらは右クリックのコンテキストメニューに現れます。
これは意図的な設計判断でした。OpenAI から Gemini に、あるいはクラウドからローカルに切り替えても、ワークフローは変わるべきではありません。同じプロンプト、同じメニュー、同じキーボードショートカット。変わるのはリクエストの行き先だけです。
AIProvider 型がシステム全体を駆動します。
type AIProvider = "openai" | "gemini" | "anthropic" | "deepseek" | "local";設定、UI タブ、変換ルーティング、すべてがこの 1 つのユニオンで分岐します。6 つ目のプロバイダーを追加するには、この型に文字列を 1 つ追加し、変換関数を 1 つ、設定タブを 1 つ。
プロバイダー追加の実コスト
5 つを作り終えてみると、パターンは明らかです。
| 変更箇所 | OpenAI 互換 | カスタム API |
|---|---|---|
| 変換関数 | ~20 行 | ~60 行 |
| 設定型 + デフォルト | ~15 行 | ~15 行 |
| 設定 UI タブ | ~40 行 | ~40 行 |
| state 転送 | ~5 行 | ~5 行 |
| 翻訳 (26 言語) | ~260 行 | ~260 行 |
| 合計 | ~340 行 | ~380 行 |
大半は翻訳です。OpenAI 互換プロバイダーの実際の統合コードは約 80 行。Anthropic のような完全に独自の API 形式なら約 120 行です。
Rust プロキシは一度きりのコストです。OpenAI プロトコルを話す任意のローカルモデルで動きます。Ollama、LM Studio、llama.cpp、vLLM、すべて同じ local_ai_chat コマンドを通ります。
重要なポイント
OpenAI 互換が正しいデフォルト。 5 つのうち 3 つが使っています。Gemini ですら互換エンドポイントを出荷しています。AI 機能を作るなら OpenAI 形式で始めれば、ほとんどのプロバイダーをタダでカバーできます。
Anthropic の逸脱にはコストがある。 別実装は複雑ではありません、62 行ですが、その 62 行はベンダーの 1 社が違う API 形状を選んだという理由だけで存在します。anthropic-dangerous-direct-browser-access ヘッダーは、ブラウザーベースのアクセスが彼らの主要ユースケースではなかったというヒントです。
ローカルモデルは間違った理由で一番難しい。 API は簡単な部分です (OpenAI 互換ですから)。難しいのはブラウザーセキュリティモデルです。アプリが WebView で動くなら、ネイティブプロキシレイヤー無しで HTTP エンドポイントには届きません。最初から計画してください。
デスクトップアプリの BYOK (Bring Your Own Key) は Web アプリより簡単。 バックエンドサーバー無し、鍵管理サービス無し、ブラウザーで鍵を露出させないためのプロキシ無し。API キーはユーザーのローカル設定ファイルに住み、リクエストはアプリからプロバイダーへ直接行きます。トレードオフはユーザーごとに自分の API キーが必要なことですが、無料ツールには正しいトレードオフです。
マルチプロバイダー AI で難しかったのはモデルルーティングではありませんでした。それを取り囲むすべてです。ブラウザーセキュリティ制約、5 つの異なるプロバイダー間の UX 一貫性、安全なローカルアクセス、設定の新タブごとの 260 行の翻訳。