カスタムプロバイダー
拡張機能は pi.registerProvider() 経由でカスタムモデルプロバイダーを登録できます。これにより次が可能になります:
- プロキシ - 企業プロキシまたは API ゲートウェイ経由でリクエストをルーティング
- カスタムエンドポイント - セルフホストまたはプライベートモデルデプロイメントを使用
- OAuth/SSO - エンタープライズプロバイダー向けの認証フローを追加
- カスタム API - 非標準 LLM API 向けのストリーミングを実装
サンプル拡張機能
Section titled “サンプル拡張機能”これらの完全なプロバイダーサンプルを参照してください:
- サンプル拡張機能
- クイックリファレンス
- 既存プロバイダーの上書き
- 新規プロバイダーの登録
- プロバイダーの登録解除
- OAuth サポート
- カスタムストリーミング API
- コンテキストオーバーフローエラー
- 実装のテスト
- 設定リファレンス
- モデル定義リファレンス
クイックリファレンス
Section titled “クイックリファレンス”import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) { // Override baseUrl for existing provider pi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com" });
// Register new provider with models pi.registerProvider("my-provider", { name: "My Provider", baseUrl: "https://api.example.com", apiKey: "$MY_API_KEY", api: "openai-completions", models: [ { id: "my-model", name: "My Model", reasoning: false, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096 } ] });}拡張ファクトリは async にもできます。動的モデル検出の場合、session_start ではなくファクトリ内でモデルを取得して登録してください。pi は起動続行前にファクトリの完了を待つため、プロバイダーは対話起動時および pi --list-models で利用可能です。
既存プロバイダーの上書き
Section titled “既存プロバイダーの上書き”最もシンプルなユースケース:既存プロバイダーをプロキシ経由にリダイレクトします。
// All Anthropic requests now go through your proxypi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com"});
// Add custom headers to OpenAI requestspi.registerProvider("openai", { headers: { "X-Custom-Header": "value" }});
// Both baseUrl and headerspi.registerProvider("google", { baseUrl: "https://ai-gateway.corp.com/google", headers: { "X-Corp-Auth": "$CORP_AUTH_TOKEN" // env var or literal }});baseUrl および/または headers のみが提供された場合(models なし)、そのプロバイダーの既存モデルはすべて保持され、新しいエンドポイントが使用されます。
新規プロバイダーの登録
Section titled “新規プロバイダーの登録”完全に新しいプロバイダーを追加するには、必要な設定とともに models を指定します。
モデルリストがリモートエンドポイントから取得される場合は、async 拡張ファクトリを使用します:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default async function (pi: ExtensionAPI) { const response = await fetch("http://localhost:1234/v1/models"); const payload = (await response.json()) as { data: Array<{ id: string; name?: string; context_window?: number; max_tokens?: number; }>; };
pi.registerProvider("local-openai", { baseUrl: "http://localhost:1234/v1", apiKey: "$LOCAL_OPENAI_API_KEY", api: "openai-completions", models: payload.data.map((model) => ({ id: model.id, name: model.name ?? model.id, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: model.context_window ?? 128000, maxTokens: model.max_tokens ?? 4096, })), });}これにより、起動完了前に取得したモデルが登録されます。
pi.registerProvider("my-llm", { baseUrl: "https://api.my-llm.com/v1", apiKey: "$MY_LLM_API_KEY", // env var reference api: "openai-completions", // which streaming API to use models: [ { id: "my-llm-large", name: "My LLM Large", reasoning: true, // supports extended thinking input: ["text", "image"], cost: { input: 3.0, // $/million tokens output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 }, contextWindow: 200000, maxTokens: 16384 } ]});models が提供されると、そのプロバイダーの既存モデルはすべて置き換えられます。
apiKey とカスタムヘッダー値は models.json と同じ設定値構文を使用します:先頭の !command は値全体に対してコマンドを実行し、$ENV_VAR と ${ENV_VAR} は環境変数を補間し、$$ はリテラル $ を、$! はリテラル ! を出力します。
プロバイダーの登録解除
Section titled “プロバイダーの登録解除”pi.unregisterProvider(name) を使用して、pi.registerProvider(name, ...) で以前登録したプロバイダーを削除します:
// Registerpi.registerProvider("my-llm", { baseUrl: "https://api.my-llm.com/v1", apiKey: "$MY_LLM_API_KEY", api: "openai-completions", models: [ { id: "my-llm-large", name: "My LLM Large", reasoning: true, input: ["text", "image"], cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 }, contextWindow: 200000, maxTokens: 16384 } ]});
// Later, remove itpi.unregisterProvider("my-llm");登録解除すると、そのプロバイダーの動的モデル、API キーフォールバック、OAuth プロバイダー登録、カスタムストリームハンドラー登録が削除されます。上書きされていた組み込みモデルまたはプロバイダー動作は復元されます。
初期拡張読み込みフェーズ後に行われた呼び出しは即座に適用されるため、/reload は不要です。
API タイプ
Section titled “API タイプ”api フィールドは使用するストリーミング実装を決定します:
| API | 用途 |
|---|---|
anthropic-messages | Anthropic Claude API および互換実装 |
openai-completions | OpenAI Chat Completions API および互換実装 |
openai-responses | OpenAI Responses API |
azure-openai-responses | Azure OpenAI Responses API |
openai-codex-responses | OpenAI Codex Responses API |
mistral-conversations | Mistral SDK Conversations/Chat ストリーミング |
google-generative-ai | Google Generative AI API |
google-vertex | Google Vertex AI API |
bedrock-converse-stream | Amazon Bedrock Converse API |
ほとんどの OpenAI 互換プロバイダーは openai-completions で動作します。モデル固有の思考レベルにはモデルレベルの thinkingLevelMap を、プロバイダー固有の挙動には compat を使用します:
models: [{ id: "custom-model", // ... reasoning: true, thinkingLevelMap: { // map pi levels to provider values; null hides unsupported levels minimal: null, low: null, medium: null, high: "default", xhigh: "max" }, compat: { supportsDeveloperRole: false, // use "system" instead of "developer" supportsReasoningEffort: true, maxTokensField: "max_tokens", // instead of "max_completion_tokens" requiresToolResultName: true, // tool results need name field thinkingFormat: "qwen", // top-level enable_thinking: true cacheControlFormat: "anthropic" // Anthropic-style cache_control markers }}]OpenRouter スタイルの reasoning: { effort } 制御には openrouter を使用します。Together スタイルの reasoning: { enabled } 制御には together を使用します。supportsReasoningEffort と組み合わせると reasoning_effort も送信されます。chat_template_kwargs.enable_thinking を読み取るローカル Qwen 互換サーバーには代わりに qwen-chat-template を使用してください。
システムプロンプト、最後のツール定義、最後の user/assistant テキストコンテンツ経由で Anthropic スタイルのプロンプトキャッシュ(cache_control)を公開する OpenAI 互換プロバイダーには cacheControlFormat: "anthropic" を使用します。
api: "anthropic-messages" を使用する Anthropic 互換プロバイダーで、上流モデルがアダプティブ思考(thinking.type: "adaptive" および output_config.effort)を必要とする場合、モデルまたはプロバイダーに compat.forceAdaptiveThinking: true を設定します。組み込みのアダプティブ Claude モデルはこれを自動設定します。プロバイダーが空の思考署名を出力し、リプレイ時に signature: "" を期待する場合のみ compat.allowEmptySignature: true を設定してください。
移行メモ:Mistral は
openai-completionsからmistral-conversationsに移行しました。 ネイティブ Mistral モデルにはmistral-conversationsを使用してください。 Mistral 互換/カスタムエンドポイントを意図的にopenai-completions経由でルーティングする場合は、必要に応じてcompatフラグを明示的に設定してください。
Auth Header
Section titled “Auth Header”プロバイダーが Authorization: Bearer <key> を期待するが標準 API を使用しない場合、authHeader: true を設定します:
pi.registerProvider("custom-api", { baseUrl: "https://api.example.com", apiKey: "$MY_API_KEY", authHeader: true, // adds Authorization: Bearer header api: "openai-completions", models: [...]});OAuth サポート
Section titled “OAuth サポート”/login と統合する OAuth/SSO 認証を追加します:
import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
pi.registerProvider("corporate-ai", { baseUrl: "https://ai.corp.com/v1", api: "openai-responses", models: [...], oauth: { name: "Corporate AI (SSO)",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> { const method = await callbacks.onSelect({ message: "Select login method:", options: [ { id: "browser", label: "Browser OAuth" }, { id: "device", label: "Device code" } ] }); if (!method) throw new Error("Login cancelled");
let code: string; if (method === "device") { callbacks.onDeviceCode({ userCode: "ABCD-1234", verificationUri: "https://sso.corp.com/device", intervalSeconds: 5, expiresInSeconds: 900 }); code = await pollDeviceCodeUntilComplete(); } else { callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." }); code = await callbacks.onPrompt({ message: "Enter SSO code:" }); }
// Exchange for tokens (your implementation) const tokens = await exchangeCodeForTokens(code);
return { refresh: tokens.refreshToken, access: tokens.accessToken, expires: Date.now() + tokens.expiresIn * 1000 }; },
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> { const tokens = await refreshAccessToken(credentials.refresh); return { refresh: tokens.refreshToken ?? credentials.refresh, access: tokens.accessToken, expires: Date.now() + tokens.expiresIn * 1000 }; },
getApiKey(credentials: OAuthCredentials): string { return credentials.access; },
// Optional: modify models based on user's subscription modifyModels(models, credentials) { const region = decodeRegionFromToken(credentials.access); return models.map(m => ({ ...m, baseUrl: `https://${region}.ai.corp.com/v1` })); } }});登録後、ユーザーは /login corporate-ai 経由で認証できます。
OAuthLoginCallbacks
Section titled “OAuthLoginCallbacks”callbacks オブジェクトは 3 つの認証方法を提供します:
interface OAuthLoginCallbacks { // Open URL in browser (for OAuth redirects) onAuth(params: { url: string }): void;
// Show device code (for device authorization flow) onDeviceCode(params: { userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number; }): void;
// Prompt user for input (for manual token entry) onPrompt(params: { message: string }): Promise<string>;
// Show an interactive selector, e.g. to choose browser OAuth vs device code onSelect(params: { message: string; options: { id: string; label: string }[]; }): Promise<string | undefined>;}OAuthCredentials
Section titled “OAuthCredentials”認証情報は ~/.pi/agent/auth.json に永続化されます:
interface OAuthCredentials { refresh: string; // Refresh token (for refreshToken()) access: string; // Access token (returned by getApiKey()) expires: number; // Expiration timestamp in milliseconds}カスタムストリーミング API
Section titled “カスタムストリーミング API”非標準 API を持つプロバイダーには streamSimple を実装します。独自実装を書く前に、既存のプロバイダー実装を研究してください:
参考実装:
- anthropic.ts - Anthropic Messages API
- mistral.ts - Mistral Conversations API
- openai-completions.ts - OpenAI Chat Completions
- openai-responses.ts - OpenAI Responses API
- google.ts - Google Generative AI
- amazon-bedrock.ts - AWS Bedrock
ストリームパターン
Section titled “ストリームパターン”すべてのプロバイダーは同じパターンに従います:
import { type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, calculateCost, createAssistantMessageEventStream,} from "@earendil-works/pi-ai";
function streamMyProvider( model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream { const stream = createAssistantMessageEventStream();
(async () => { // Initialize output message const output: AssistantMessage = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), };
try { // Push start event stream.push({ type: "start", partial: output });
// Make API request and process response... // Push content events as they arrive...
// Push done event stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output }); stream.end(); } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : String(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })();
return stream;}イベントタイプ
Section titled “イベントタイプ”次の順序で stream.push() 経由でイベントをプッシュします:
-
{ type: "start", partial: output }- ストリーム開始 -
コンテンツイベント(繰り返し可能、各ブロックで
contentIndexを追跡):{ type: "text_start", contentIndex, partial }- テキストブロック開始{ type: "text_delta", contentIndex, delta, partial }- テキストチャンク{ type: "text_end", contentIndex, content, partial }- テキストブロック終了{ type: "thinking_start", contentIndex, partial }- 思考開始{ type: "thinking_delta", contentIndex, delta, partial }- 思考チャンク{ type: "thinking_end", contentIndex, content, partial }- 思考終了{ type: "toolcall_start", contentIndex, partial }- ツール呼び出し開始{ type: "toolcall_delta", contentIndex, delta, partial }- ツール呼び出し JSON チャンク{ type: "toolcall_end", contentIndex, toolCall, partial }- ツール呼び出し終了
-
{ type: "done", reason, message }または{ type: "error", reason, error }- ストリーム終了
各イベントの partial フィールドには現在の AssistantMessage 状態が含まれます。データ受信時に output.content を更新し、output を partial として含めます。
コンテンツブロック
Section titled “コンテンツブロック”データ到着時に output.content にコンテンツブロックを追加します:
// Text blockoutput.content.push({ type: "text", text: "" });stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
// As text arrivesconst block = output.content[contentIndex];if (block.type === "text") { block.text += delta; stream.push({ type: "text_delta", contentIndex, delta, partial: output });}
// When block completesstream.push({ type: "text_end", contentIndex, content: block.text, partial: output });ツール呼び出し
Section titled “ツール呼び出し”ツール呼び出しには JSON の蓄積とパースが必要です:
// Start tool calloutput.content.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: {}});stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
// Accumulate JSONlet partialJson = "";partialJson += jsonDelta;try { block.arguments = JSON.parse(partialJson);} catch {}stream.push({ type: "toolcall_delta", contentIndex, delta: jsonDelta, partial: output });
// Completestream.push({ type: "toolcall_end", contentIndex, toolCall: { type: "toolCall", id, name, arguments: block.arguments }, partial: output});使用量とコスト
Section titled “使用量とコスト”API レスポンスから使用量を更新し、コストを計算します:
output.usage.input = response.usage.input_tokens;output.usage.output = response.usage.output_tokens;output.usage.cacheRead = response.usage.cache_read_tokens ?? 0;output.usage.cacheWrite = response.usage.cache_write_tokens ?? 0;output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;calculateCost(model, output.usage);コンテキストオーバーフローエラー
Section titled “コンテキストオーバーフローエラー”リクエストがモデルのコンテキストウィンドウを超えると、pi は会話をコンパクションして再試行することで自動回復できます。この回復は、pi が失敗をオーバーフローとして認識した場合にのみ発動します。
検出は確定した assistant メッセージで実行されます:
stopReason === "error"errorMessageが pi の既知のオーバーフローパターンのいずれかに一致(packages/ai/src/utils/overflow.tsを参照)
プロバイダーが pi が認識しないメッセージでオーバーフローエラーを返す場合、プロバイダーを登録する同じ拡張機能からエラーを正規化してください。message_end ハンドラーを使用して assistant メッセージを書き換え、errorMessage が pi が認識するフレーズで始まるようにします。汎用フォールバック context_length_exceeded が最も安全な選択です。
const MY_PROVIDER_OVERFLOW_PATTERN = /your provider's overflow phrase/i;
export default function (pi: ExtensionAPI) { pi.registerProvider("my-provider", { /* ... */ });
pi.on("message_end", (event, ctx) => { const message = event.message; if (message.role !== "assistant") return; if (message.stopReason !== "error") return; if ( message.provider !== "my-provider" && ctx.model?.provider !== "my-provider" ) return;
const errorMessage = message.errorMessage ?? ""; if (errorMessage.includes("context_length_exceeded")) return; if (!MY_PROVIDER_OVERFLOW_PATTERN.test(errorMessage)) return;
return { message: { ...message, errorMessage: `context_length_exceeded: ${errorMessage}`, }, }; });}message_end は pi が自動コンパクション用に assistant メッセージを追跡する前に実行されるため、pi がチェックするのは書き換え後の errorMessage です。これにより pi は次を行います:
errorMessageからオーバーフローを検出。- 失敗した assistant メッセージをライブコンテキストから削除。
- コンパクションを実行。
- リクエストを 1 回再試行。
書き換えは慎重に保護してください:
- 自分のプロバイダーに限定(
message.providerとctx.model?.provider)し、他プロバイダーの無関係なエラーに触れないようにします。 - pi の汎用オーバーフローパターンではなく、プロバイダー固有のパターンに一致させます。レート制限やスロットリングエラー(
rate limit、too many requests)を書き換えると、pi の通常のバックオフ付き再試行パスではなく、誤ってコンパクションがトリガーされます。 errorMessageに既にcontext_length_exceededが含まれる場合はスキップし、ハンドラーを冪等にします。
ストリーム関数を登録します:
pi.registerProvider("my-provider", { baseUrl: "https://api.example.com", apiKey: "$MY_API_KEY", api: "my-custom-api", models: [...], streamSimple: streamMyProvider});実装のテスト
Section titled “実装のテスト”組み込みプロバイダーと同じテストスイートでプロバイダーをテストします。packages/ai/test/ からこれらのテストファイルをコピーして適応してください:
| テスト | 目的 |
|---|---|
stream.test.ts | 基本ストリーミング、テキスト出力 |
tokens.test.ts | トークンカウントと使用量 |
abort.test.ts | AbortSignal 処理 |
empty.test.ts | 空/最小レスポンス |
context-overflow.test.ts | コンテキストウィンドウ制限 |
image-limits.test.ts | 画像入力処理 |
unicode-surrogate.test.ts | Unicode エッジケース |
tool-call-without-result.test.ts | ツール呼び出しエッジケース |
image-tool-result.test.ts | ツール結果内の画像 |
total-tokens.test.ts | 総トークン計算 |
cross-provider-handoff.test.ts | プロバイダー間のコンテキスト引き継ぎ |
プロバイダー/モデルペアでテストを実行し、互換性を検証してください。
設定リファレンス
Section titled “設定リファレンス”interface ProviderConfig { /** Display name for the provider in UI such as /login. */ name?: string;
/** API endpoint URL. Required when defining models. */ baseUrl?: string;
/** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */ apiKey?: string;
/** API type for streaming. Required at provider or model level when defining models. */ api?: Api;
/** Custom streaming implementation for non-standard APIs. */ streamSimple?: ( model: Model<Api>, context: Context, options?: SimpleStreamOptions ) => AssistantMessageEventStream;
/** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */ headers?: Record<string, string>;
/** If true, adds Authorization: Bearer header with the resolved API key. */ authHeader?: boolean;
/** Models to register. If provided, replaces all existing models for this provider. */ models?: ProviderModelConfig[];
/** OAuth provider for /login support. */ oauth?: { name: string; login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>; refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>; getApiKey(credentials: OAuthCredentials): string; modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[]; };}モデル定義リファレンス
Section titled “モデル定義リファレンス”interface ProviderModelConfig { /** Model ID (e.g., "claude-sonnet-4-20250514"). */ id: string;
/** Display name (e.g., "Claude 4 Sonnet"). */ name: string;
/** API type override for this specific model. */ api?: Api;
/** API endpoint URL override for this specific model. */ baseUrl?: string;
/** Whether the model supports extended thinking. */ reasoning: boolean;
/** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */ thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string | null>>;
/** Supported input types. */ input: ("text" | "image")[];
/** Cost per million tokens (for usage tracking). */ cost: { input: number; output: number; cacheRead: number; cacheWrite: number; };
/** Maximum context window size in tokens. */ contextWindow: number;
/** Maximum output tokens. */ maxTokens: number;
/** Custom headers for this specific model. */ headers?: Record<string, string>;
/** Compatibility settings for the selected API. */ compat?: { // openai-completions supportsStore?: boolean; supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; supportsUsageInStreaming?: boolean; maxTokensField?: "max_completion_tokens" | "max_tokens"; requiresToolResultName?: boolean; requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; cacheControlFormat?: "anthropic";
// anthropic-messages supportsEagerToolInputStreaming?: boolean; supportsLongCacheRetention?: boolean; sendSessionAffinityHeaders?: boolean; supportsCacheControlOnTools?: boolean; forceAdaptiveThinking?: boolean; allowEmptySignature?: boolean; };}openrouter は reasoning: { effort } を送信します。deepseek は thinking: { type: "enabled" | "disabled" } を送信し、有効時は reasoning_effort も送信します。together は reasoning: { enabled } を送信し、supportsReasoningEffort が有効な場合は reasoning_effort も送信します。qwen は DashScope スタイルのトップレベル enable_thinking 用です。chat_template_kwargs.enable_thinking を読み取るローカル Qwen 互換サーバーには qwen-chat-template を使用してください。
cacheControlFormat: "anthropic" は Anthropic スタイルの cache_control マーカーをシステムプロンプト、最後のツール定義、最後の user/assistant テキストコンテンツに適用します。