ApiKeyPoolService アルゴリズム詳解
ApiKeyPoolService は複数の API キー向けのロードバランシングソリューションを実装します。加重ラウンドロビンによるキー選択、Prompt Cache を維持するためのセッションレベルの親和性、レート制限に対する指数バックオフのクールダウン、認証失敗時の永続的な無効化を扱います。
ファイルの場所
| ファイル | パス |
|---|---|
| ApiKeyPoolService | packages/desktop/app/main/services/capabilities/llm/completion/ApiKeyPoolService.ts |
| API キー DB 操作 | packages/desktop/app/main/workers/db/apiKeys.ts |
| DB Worker 登録 | packages/desktop/app/main/workers/db/index.ts |
| DB Worker 型 | packages/desktop/app/main/workers/types.ts |
| IPC Router | packages/desktop/app/main/services/routers/llm/ApiKeyRouter.ts |
| フロントエンド UI | packages/renderer/src/features/settings/components/provider-settings/llm/ApiKeyPoolSection.tsx |
アーキテクチャ上の位置づけ
graph TB
subgraph CompletionService
Resolve[resolveApiKeyForRequest]
Retry[retry on 429/529]
Success[reportSuccess]
end
subgraph ApiKeyPoolService
direction TB
GetKey[getKeyForSession]
GetKeyNoSession[getKey]
Report[reportError]
ReportOk[reportSuccess]
Select[selectWeightedRoundRobin]
Available[getAvailableKeys]
Cooldown[applyCooldown]
AuthFail[handleAuthFailure]
Cleanup[cleanupExpiredCooldowns<br/>every 30 seconds]
end
subgraph InMemoryStructures ["In-Memory Data Structures"]
SB["sessionBindings<br/>Map<sessionId, SessionBinding>"]
RRI["rrIndex<br/>Map<providerId, number>"]
KC["keyCache<br/>Map<providerId, ApiKeyEntry[]>"]
CD["cooldowns<br/>Map<keyId, KeyCooldown>"]
end
subgraph ExternalDeps ["External Dependencies"]
DB[(SQLite<br/>llm_provider_api_keys)]
Loader["loadKeys(providerId)"]
Disabler["disableKey(keyId)"]
end
Resolve --> GetKey
Retry --> Report
Success --> ReportOk
GetKey --> SB
GetKey --> Available
Available --> KC
Available --> CD
KC --> Loader
Loader --> DB
Select --> RRI
Report --> Cooldown
Report --> AuthFail
AuthFail --> Disabler
Disabler --> DB
Cleanup --> CD
データ構造
コア型
// Session binding: locks a session to a specific key
interface SessionBinding {
keyId: string; // Bound key ID
providerId: string; // Owning provider ID
}
// Key cooldown state
interface KeyCooldown {
until: number; // Cooldown expiry timestamp (Date.now() + cooldownMs)
errors: number; // Consecutive error count (used for exponential backoff)
}
// API key entry (from database)
interface ApiKeyEntry {
id: string; // UUID
providerId: string; // Owning provider
label?: string; // Display label (e.g. "Production Key #1")
apiKey: string; // Actual key value (may start with $ for env var)
enabled: boolean; // Whether enabled
weight: number; // Weight (1-100)
}
// Key loader function signature
type ApiKeysLoader = (providerId: string) => Promise<ApiKeyEntry[]>;
// Key disabler function signature
type ApiKeyDisabler = (keyId: string) => Promise<boolean>;
// Key resolver (handles $ env variable prefix)
type ApiKeyResolver = (rawKey: string) => string;
インメモリデータ構造の概要
| 構造 | 型 | 目的 | ライフサイクル |
|---|---|---|---|
sessionBindings | Map<sessionId, SessionBinding> | セッションをバインド済みキーに対応付ける | セッション終了時に releaseSession() でクリア |
rrIndex | Map<providerId, number> | プロバイダーごとのラウンドロビンインデックス | アプリケーションのライフタイム中は保持 |
keyCache | Map<providerId, ApiKeyEntry[]> | キー一覧キャッシュ(リクエストごとの DB クエリを回避) | CRUD 操作後に invalidateCache() でクリア |
cooldowns | Map<keyId, KeyCooldown> | キーのクールダウン状態 | 期限切れエントリを 30 秒ごとにクリーンアップ |
アルゴリズムとロジック
加重ラウンドロビンアルゴリズム
各キーの weight は、ラウンドロビンサイクル内でそのキーが占有する「スロット」数を決定します。
手順:
selectWeightedRoundRobin(providerId, keys):
1. If only 1 key → return directly
2. Calculate totalWeight = sum(keys[i].weight)
3. Advance round-robin index: idx = (rrIndex[providerId] + 1) % totalWeight
4. Save new index: rrIndex[providerId] = idx
5. Accumulate and iterate:
accum = 0
for each key in keys:
accum += key.weight
if idx < accum:
return key
6. Fallback: return keys[0]
例:
3 つのキー: A(weight=3), B(weight=1), C(weight=2), totalWeight=6
| ラウンドロビンインデックス (idx) | 累積値 | 選択されるキー |
|---|---|---|
| 0 | A: 3 | A (0 < 3) |
| 1 | A: 3 | A (1 < 3) |
| 2 | A: 3 | A (2 < 3) |
| 3 | A: 3, B: 4 | B (3 < 4) |
| 4 | A: 3, B: 4, C: 6 | C (4 < 6) |
| 5 | A: 3, B: 4, C: 6 | C (5 < 6) |
| 0 | (cycle repeats) | A |
重みの意味: weight=3 のキーは 1 サイクルで 3 回選択され、weight=1 のキーは 1 回選択されます。
セッション親和性(セッションバインディング)
flowchart TD
Start[getKeyForSession] --> CheckBinding{Session has binding?}
CheckBinding -->|Yes| CheckProvider{providerId matches?}
CheckProvider -->|Yes| CheckAvailable{Bound key available?}
CheckAvailable -->|Yes| Return[Return bound key]
CheckAvailable -->|No| ReBind[Re-bind]
CheckProvider -->|No| ReBind
CheckBinding -->|No| ReBind
ReBind --> GetKeys[getAvailableKeys]
GetKeys --> Empty{Key list empty?}
Empty -->|Yes| ReturnEmpty[Return empty string]
Empty -->|No| WRR[selectWeightedRoundRobin]
WRR --> Bind[sessionBindings.set]
Bind --> ReturnNew[Return new key]
セッション親和性が必要な理由:
- Anthropic などのプロバイダーは Prompt Cache を実装している
- 同じ API Key でリクエストを送信するとキャッシュにヒットし、コストと時間を節約できる
- キーを切り替えるとキャッシュミスが発生する
- そのため、単一セッション内では可能な限り同じキーを使用するべきである
利用できないキーの処理:
getKeyForSession(providerId, sessionId):
binding = sessionBindings.get(sessionId)
if binding && binding.providerId === providerId:
keys = getAvailableKeys(providerId) // filter: enabled + not in cooldown
boundKey = keys.find(k.id === binding.keyId)
if boundKey:
return resolveKey(boundKey.apiKey) // hit: return
// Key disabled/deleted/in cooldown → re-bind
log.info("Session key no longer available, re-binding")
// Select a new key and bind
keys = getAvailableKeys(providerId)
if keys.length === 0: return ''
selected = selectWeightedRoundRobin(providerId, keys)
sessionBindings.set(sessionId, { keyId: selected.id, providerId })
return resolveKey(selected.apiKey)
クールダウン / バックオフ機構
指数バックオフの式
cooldownMs = min(DEFAULT_COOLDOWN_MS * 2^(errors - 1), MAX_COOLDOWN_MS)
| 連続エラー数 | 計算 | クールダウン時間 |
|---|---|---|
| 1 | 60,000 * 2^0 | 60 秒(1 分) |
| 2 | 60,000 * 2^1 | 120 秒(2 分) |
| 3 | 60,000 * 2^2 | 240 秒(4 分) |
| 4 | 60,000 * 2^3 | 480 秒(8 分) |
| 5+ | 60,000 * 2^4 | 900 秒(15 分上限) |
定数設定:
| 定数 | 値 | 説明 |
|---|---|---|
DEFAULT_COOLDOWN_MS | 60,000 (60 s) | 基本クールダウン時間 |
MAX_COOLDOWN_MS | 900,000 (15 min) | 最大クールダウン時間 |
COOLDOWN_MULTIPLIER | 2 | 指数の底 |
| クリーンアップ間隔 | 30,000 (30 s) | cleanupExpiredCooldowns() の実行間隔 |
クールダウン適用フロー
applyCooldown(keyId, providerId, statusCode):
current = cooldowns.get(keyId)
errors = (current?.errors ?? 0) + 1
cooldownMs = min(60_000 * 2^(errors-1), 900_000)
cooldowns.set(keyId, {
until: Date.now() + cooldownMs,
errors: errors
})
クールダウンのリセット
- リクエストが成功したら
reportSuccess(sessionId)を呼び出す - バインド済みキーにクールダウンエントリがある場合は、ただちに削除する
認証失敗の処理
HTTP 401 および 403 エラーの場合、そのキーは永続的に無効とみなされます:
flowchart TD
Error[reportError] --> Check{HTTP status code}
Check -->|429/529| Cooldown[applyCooldown<br/>exponential backoff]
Check -->|401/403| AuthFail[handleAuthFailure]
Check -->|other| Ignore[Ignore]
AuthFail --> Disable[disableKey<br/>disable in database]
Disable --> InvalidateCache[keyCache.delete<br/>clear cache]
Cooldown --> Rebind[Re-select key]
InvalidateCache --> Rebind
Rebind --> HasMore{More keys available?}
HasMore -->|Yes| Return[Return new key]
HasMore -->|No| ReturnNull[Return null]
HTTP ステータスコードの分類:
| ステータスコード | クラス | 処理 |
|---|---|---|
| 401 | AUTH_FAILURE | キーを永続的に無効化 |
| 403 | AUTH_FAILURE | キーを永続的に無効化 |
| 429 | RATE_LIMIT | 指数バックオフクールダウン |
| 529 | RATE_LIMIT | 指数バックオフクールダウン(Anthropic overload) |
| Other | — | 何もせず、null を返す |
キーの利用可能性フィルタリング
getAvailableKeys(providerId):
all = getAllKeys(providerId) // load from cache or database
now = Date.now()
return all.filter(key =>
key.enabled === true // must be enabled
&& !(cooldowns[key.id]?.until > now) // not in cooldown
)
クリーンアップ間隔
cleanupExpiredCooldowns() は 30 秒ごとに実行されます:
cleanupExpiredCooldowns():
now = Date.now()
for each [keyId, cd] in cooldowns:
if cd.until <= now:
cooldowns.delete(keyId)
IPC 統合表
| IPC チャンネル | 方向 | パラメーター | Zod Schema | 説明 |
|---|---|---|---|---|
llmConfig:getApiKeys | R → M | providerId: string | None | プロバイダーのすべてのキーを取得 |
llmConfig:addApiKey | R → M | { providerId, label?, apiKey, enabled?, weight? } | AddApiKeySchema | キーを追加(デフォルト weight=1、enabled=true) |
llmConfig:updateApiKey | R → M | { id, label?, apiKey?, enabled?, weight? } | UpdateApiKeySchema | キー情報を更新 |
llmConfig:deleteApiKey | R → M | { id: string } | None | キーを削除 |
llmConfig:toggleApiKey | R → M | { id, enabled } | ToggleApiKeySchema | キーを有効化または無効化 |
Zod バリデーションルール:
// weight range constraint
weight: z.number().int().min(1).max(100)
// apiKey non-empty
apiKey: z.string().min(1)
// providerId non-empty
providerId: z.string().min(1)
キャッシュの無効化: すべての書き込み操作(add/update/delete/toggle)は完了時に apiKeyPool.invalidateCache(providerId) を呼び出し、次のリクエストでデータベースから再読み込みされるようにします。
拡張ポイント
クールダウン戦略の調整
ApiKeyPoolService の定数を変更します:
// More aggressive cooldown (for low-QPS scenarios)
private readonly DEFAULT_COOLDOWN_MS = 30_000; // 30 s
private readonly MAX_COOLDOWN_MS = 5 * 60_000; // 5 minutes
// More lenient cooldown (for high-QPS scenarios)
private readonly DEFAULT_COOLDOWN_MS = 120_000; // 2 minutes
private readonly MAX_COOLDOWN_MS = 30 * 60_000; // 30 minutes
カスタムキー選択戦略
現在の戦略は加重ラウンドロビンです。別の戦略(例: 最少接続数、ランダム加重)を使うには、selectWeightedRoundRobin() メソッドを置き換えます。
環境変数キー
$ で始まるキー値は、自動的に環境変数へ展開されます:
$OPENAI_API_KEY → process.env.OPENAI_API_KEY
これは ApiKeyResolver 関数によって処理され、CompletionService.resolveApiKey() に実装されています。
関連ファイル
| ファイル | 関係 |
|---|---|
capabilities/llm/completion/CompletionService.ts | 利用側: resolveApiKeyForRequest() 経由でプールを呼び出す |
workers/db/apiKeys.ts | データソース: loadKeys と CRUD 操作を提供 |
workers/types.ts | DB Worker 型定義 |
routers/llm/ApiKeyRouter.ts | IPC 層: フロントエンドのキー管理操作 |
renderer/.../ApiKeyPoolSection.tsx | フロントエンド UI: キー一覧の CRUD |
shared/llm-config.ts | ApiKeyEntry 型定義 |