Max Nardit
Beetroot

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 のレスポンス抽出を扱います。

typescript
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 統合の全文:

typescript
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 つ変えました。

typescript
{
  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 段落の内省と一緒に戻ってきます。修正は小さいですが見逃しやすい。

typescript
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/completionsOpenAI 互換レイヤー も提供しています。同じモデル、同じ無料枠、標準の Authorization: Bearer ヘッダー。動きますし、おかげで 4 つ目の統合パターンを書かずに済みました。

パターン 2: Anthropic は独自ルール

Claude の Messages API は OpenAI 互換ではありません。リクエスト形式、ヘッダー、レスポンス形状がすべて独自です。だから 62 行の独自関数になります。

fetch 呼び出し:

typescript
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 つ:

  1. 認証ヘッダー: Authorization: Bearer ではなく x-api-key
  2. CORS ヘッダー: anthropic-dangerous-direct-browser-access: true、はい、これが本当のヘッダー名です。Anthropic はブラウザーベースの API 呼び出しにこれを要求します。名前は……記述的です。
  3. システムプロンプト: role: "system" のメッセージではなく、トップレベルの system フィールド
  4. レスポンス形状: data.choices[0].message.content ではなく data.content[0].text

モデル ID マッピングもあります。Anthropic は内部的に日付付きスナップショット ID を使うからです。

typescript
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 を出ません。

Beetroot の AI 設定が、ローカル localhost で Qwen3 モデルを動かしている LM Studio の上に開いている

解決策: ローカル AI 呼び出しを Tauri の Rust バックエンドに IPC 経由でルーティングします。フロントエンドは invoke("local_ai_chat") を呼び、Rust が ureq で HTTP リクエストを行い、結果を返します。

フロントエンドinvoke("local_ai_chat")Rusthttp://localhost:11434Rustフロントエンド

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() です。localhost127.0.0.1[::1] のみ許可します。

rust
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 型がシステム全体を駆動します。

typescript
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 行の翻訳。

ディスカッション

コメント欄はありません。議論は X で行っています。

ほかの記事

Beetroot v1.6.6:Office 修正

Excel と Word のセルが値ではなくスクリーンショットとしてキャプチャされていました。Microsoft Store の自動起動が密かに壊れていました。画像サムネイルがギガバイト単位の RAM を消費していました。v1.6.6 はこの 3 つに加え、大型の 1.6.5 AI Vision リリース後のセキュリティと信頼性の作業を修正します。

Tauri の AI プロバイダーアーキテクチャ:OpenAI、Claude、Gemini、Ollama、DeepSeek