cc-switchに学ぶデスクトップRustバックエンド実装パターン Part 2: 堅牢なマルチプロバイダー・プロキシとサーキットブレーカー
1. 概要
『cc-switch』は、Claude CodeやGemini CLIといったAIツールと複数のLLMプロバイダー(Anthropic, OpenAI, Geminiなど)の間を調停する、Tauriベースのインテリジェントなローカルプロキシサーバーです。
前回(Part 1)では、安全なシステム起動、SQLite migrations、Windows FFI、およびログのプライバシー保護といったデスクトップバックエンドの基盤設計を学びました。本パートでは、cc-switchの最もコアとなる機能である「ローカルHTTPプロキシとスマート・ルーター」に焦点を当てます。
複数のLLM APIを単一のインターフェースに統合する際、APIごとのペイロード構造の差異、不安定なストリーミング(SSE: Server-Sent Events)のハンドリング、接続失敗時の自動フォールバックが大きな課題となります。本記事では、Rustの強力な型システムと非同期機能を活かし、これらをいかに堅牢に実装するかを解説します。
Target Commit: 61d7ac01fb9d0a3541f426c41dde7331049230a5
Analysis Date: 2026-06-28
2. アーキテクチャ
cc-switchのプロキシシステムは、ローカルHTTPサーバーがリクエストをインターセプトし、動的ルーティングとデータ変換を経て、対象の外部APIへとリクエストをフォワードするパイプラインで構成されています。
- Local HTTP Server: クライアント(CLIツールなど)からのリクエストを受信。
- Circuit Breaker: 対象プロバイダーの健全性をチェック。失敗率が高い場合は即座にフォールバック(代替)プロバイダーへルーティング。
- Request Forwarder: 認証ヘッダーの付与やエンドポイントの設定を動的に適用。
- Payload Transformer: 統一形式から対象プロバイダー固有のペイロード(システムプロンプトのフォーマット、ストリーミング・プロトコルなど)への相互変換を実行。
3. この記事で学べること
- 動的ペイロードトランスフォーマー: 異なるLLM API仕様の差分を吸収するトレイト設計
- 非同期ストリーム(SSE)のリアルタイム書き換え: 推論(Thinking)プロセスを解析・修復しながらクライアントに中継するテクニック
- サーキットブレーカーとフェイルオーバー: ネットワーク障害に強いローカルプロキシの設計パターン
- ステートフルなルーティング: 接続情報やトークン利用履歴をDB/メモリ上で同期管理する構造
4. 実践的な実装・コード解説
Tip 1: PayloadTransformer トレイトによるAPI抽象化
異なるプロバイダー(Anthropic、OpenAI、Geminiなど)は、リクエストボディやレスポンスのスキーマが異なります。これらをクリーンにハンドリングするため、cc-switchでは抽象的な共通インターフェース(トレイト)を定義し、各プロバイダー専用のトランスフォーマーを実装しています。
以下は、リクエストとストリームレスポンスを変換するための概念設計をシンプルに再現したものです。
use async_trait::async_trait;
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct ProxyRequest {
pub model: String,
pub messages: Vec<Value>,
pub temperature: f32,
}
#[async_trait]
pub trait PayloadTransformer: Send + Sync {
/// 共通リクエストからターゲットAPI(OpenAI / Gemini等)の生リクエストに変換
fn transform_request(&self, req: &ProxyRequest) -> Result<Value, String>;
/// ターゲットAPIからのストリームチャンク(SSE)を標準的なSSEフォーマットにデコード
fn transform_response_chunk(&self, raw_chunk: &str) -> Option<Value>;
}
pub struct AnthropicToOpenAiTransformer;
#[async_trait]
impl PayloadTransformer for AnthropicToOpenAiTransformer {
fn transform_request(&self, req: &ProxyRequest) -> Result<Value, String> {
// Anthropicのメッセージ構造をOpenAI互換にマップ
let openai_req = serde_json::json!({
"model": req.model,
"messages": req.messages,
"temperature": req.temperature,
"stream": true
});
Ok(openai_req)
}
fn transform_response_chunk(&self, raw_chunk: &str) -> Option<Value> {
// SSEの「data: {...}」からデータを抽出し、標準フォーマットへと整形する
if raw_chunk.starts_with("data: ") {
let data = raw_chunk.trim_start_matches("data: ");
serde_json::from_str::<Value>(data).ok()
} else {
None
}
}
}
#[async_trait] を活用することで、ネットワークやデータベースからの動的なマッピング定義(カスタムモデルマップ)の取得処理など、非同期処理を伴うペイロード書き換えもシームレスに表現できます。
Tip 2: Thinking Rectifier によるストリームデータの動的修復
近年のLLM(DeepSeek R1やClaude 3.7 Sonnetなど)は、「思考プロセス(<thinking>タグ)」を返却します。しかし、一部のクライアントは思考プロセスのタグに対応しておらず、画面表示が崩れたりパースエラーを起こしたりします。
cc-switchでは、ストリームの途中でタグの整合性を検証・修復し、閉じタグの不足を自動補完する ThinkingRectifier(思考修復バッファ)が実装されています。
pub struct ThinkingRectifier {
in_thinking_block: bool,
buffer: String,
}
impl ThinkingRectifier {
pub fn new() -> Self {
Self {
in_thinking_block: false,
buffer: String::new(),
}
}
/// 流入するテキストチャンクを解析し、整形された出力を返す
pub fn process_chunk(&mut self, chunk: &str) -> String {
self.buffer.push_str(chunk);
let mut output = String::new();
// 簡易的な状態遷移マシンによるタグの監視
if !self.in_thinking_block && self.buffer.contains("<thinking>") {
self.in_thinking_block = true;
// タグを検知した際の処理
}
if self.in_thinking_block && self.buffer.contains("</thinking>") {
self.in_thinking_block = false;
}
// 必要な修復処理を加えてクライアントに即時フォワードするデータを生成
output.push_str(chunk);
output
}
/// ストリーム終了時に未完の思考ブロックがあれば強制クローズする
pub fn finalize(mut self) -> Option<String> {
if self.in_thinking_block {
log::warn!("Thinking block was not closed by model. Injecting closing tag.");
Some("</thinking>".to_string())
} else {
None
}
}
}
このパターンの利点は、ストリームのリアルタイム性を損なうことなく、壊れたマークダウンやXMLタグをインフライト(通信の途中)で自動修復できる点にあります。
Tip 3: サーキットブレーカーによるフォールバック機構
ローカルプロキシサーバーにとって、外部LLMプロバイダーのAPIダウンやレートリミット(429エラー)は日常茶飯事です。プロキシ内でエラーを監視し、しきい値を超えたら「Open」状態へ遷移して自動的にフォールバック先のAPIへ処理を流すサーキットブレーカーの実装が求められます。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum State {
Closed, // 正常稼働中
Open, // 遮断中(エラー過多によりバイパス中)
HalfOpen, // 復旧テスト中
}
pub struct CircuitBreaker {
state: Arc<RwLock<State>>,
failure_count: AtomicUsize,
failure_threshold: usize,
last_state_change: Arc<RwLock<Instant>>,
cooldown_period: Duration,
}
impl CircuitBreaker {
pub fn new(failure_threshold: usize, cooldown_period: Duration) -> Self {
Self {
state: Arc::new(RwLock::new(State::Closed)),
failure_count: AtomicUsize::new(0),
failure_threshold,
last_state_change: Arc::new(RwLock::new(Instant::now())),
cooldown_period,
}
}
pub async fn can_execute(&self) -> bool {
let mut state = self.state.write().await;
if *state == State::Open {
let last_change = self.last_state_change.read().await;
if last_change.elapsed() > self.cooldown_period {
// クールダウン期間を過ぎたらHalfOpenに移行しテスト再開
*state = State::HalfOpen;
log::info!("Circuit Breaker entered HalfOpen state.");
return true;
}
return false; // 引き続き遮断
}
true
}
pub async fn record_success(&self) {
let mut state = self.state.write().await;
self.failure_count.store(0, Ordering::Relaxed);
if *state == State::HalfOpen {
*state = State::Closed;
log::info!("Circuit Breaker recovered back to Closed state.");
}
}
pub async fn record_failure(&self) {
let failures = self.failure_count.fetch_add(1, Ordering::Relaxed) + 1;
let mut state = self.state.write().await;
if failures >= self.failure_threshold && *state != State::Open {
*state = State::Open;
let mut last_change = self.last_state_change.write().await;
*last_change = Instant::now();
log::error!("Circuit Breaker TRIPPED to Open state!");
}
}
}
これをAxumなどのHTTPハンドラーに噛ませることで、プロキシ自身が「外部サービスが死んでいるから、自動的にバックアップのローカルLLM(Ollama等)にリクエストを転送しよう」といった高度な障害耐性タスクを自律して処理できるようになります。
5. 実務に持ち帰れるTips
- トランスフォーマーの実装には
async_traitとdynを組み合わせる
静的ディスパッチ(ジェネリクス)はパフォーマンスに優れますが、実行時にプロバイダーを動的に決定するプロキシサーバーでは、Box<dyn PayloadTransformer>のような動的ディスパッチ(Trait Object)を割り切って使うことで、ルーターコードの肥大化を防ぎシンプルに維持できます。 - SSE(Server-Sent Events)の改行ルールに留意する
ストリームをパース・再構成する際、プラットフォームやLLMプロバイダーによって、\r\n\r\nもしくは\n\nが区切り文字になります。パーサーを記述する際は、正規化(両端トリムや共通改行コードへの置き換え)を事前に行うと、文字列操作の不具合を予防できます。 - スレッドセーフなアトミック型による簡易カウンターの利用
サーキットブレーカーの失敗回数など、重いロック(Mutex)が不要なシンプルなカウンタにはAtomicUsizeを使い、スレッド間競合のオーバーヘッドを削減しましょう。Stateなどの状態遷移はRwLockで囲むのがベストです。
6. トレードオフと注意点
リアルタイム性と整合性のトレードオフ
ThinkingRectifier(思考プロセスの補正バッファ)のような処理を挟む場合、「不正なタグを完全に防ぎたいが、1文字ずつの極小ストリームでも遅延なしで出力したい」という要件はトレードオフになります。バッファを大きく保持しすぎるとタイピング風の出力アニメーション(TTFT: Time To First Token)がもたつきます。
cc-switchは、バッファリングを「タグが判定される最小文字数(例:< や </ から始まる数文字)」に限定し、マッチしなかった場合は即座にバッファを吐き出す設計をとることで、低遅延と堅牢性を両立させています。
7. まとめ
cc-switchのプロキシ層は、単に「リクエストの宛先を変える」だけではなく、型安全なAPI相互変換、不安定なストリームのリアルタイム整合性修復、および耐障害性のサーキットブレーカーを巧みに組み合わせることで、プロフェッショナルなローカルAIツールとしての堅牢性を発揮しています。
次回(Part 3)では、最終テーマとして「Model Context Protocol (MCP) サーバーのプロセス管理と、マルチデバイス同期(WebDAV/S3)」に踏み込み、Rustバックエンドからの外部プロセス制御パターンを徹底解析します!