コンテンツにスキップ

カスタムプロバイダー

拡張機能は pi.registerProvider() 経由でカスタムモデルプロバイダーを登録できます。これにより次が可能になります:

  • プロキシ - 企業プロキシまたは API ゲートウェイ経由でリクエストをルーティング
  • カスタムエンドポイント - セルフホストまたはプライベートモデルデプロイメントを使用
  • OAuth/SSO - エンタープライズプロバイダー向けの認証フローを追加
  • カスタム API - 非標準 LLM API 向けのストリーミングを実装

これらの完全なプロバイダーサンプルを参照してください:

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 で利用可能です。

最もシンプルなユースケース:既存プロバイダーをプロキシ経由にリダイレクトします。

// All Anthropic requests now go through your proxy
pi.registerProvider("anthropic", {
baseUrl: "https://proxy.example.com"
});
// Add custom headers to OpenAI requests
pi.registerProvider("openai", {
headers: {
"X-Custom-Header": "value"
}
});
// Both baseUrl and headers
pi.registerProvider("google", {
baseUrl: "https://ai-gateway.corp.com/google",
headers: {
"X-Corp-Auth": "$CORP_AUTH_TOKEN" // env var or literal
}
});

baseUrl および/または headers のみが提供された場合(models なし)、そのプロバイダーの既存モデルはすべて保持され、新しいエンドポイントが使用されます。

完全に新しいプロバイダーを追加するには、必要な設定とともに 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} は環境変数を補間し、$$ はリテラル $ を、$! はリテラル ! を出力します。

pi.unregisterProvider(name) を使用して、pi.registerProvider(name, ...) で以前登録したプロバイダーを削除します:

// Register
pi.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 it
pi.unregisterProvider("my-llm");

登録解除すると、そのプロバイダーの動的モデル、API キーフォールバック、OAuth プロバイダー登録、カスタムストリームハンドラー登録が削除されます。上書きされていた組み込みモデルまたはプロバイダー動作は復元されます。

初期拡張読み込みフェーズ後に行われた呼び出しは即座に適用されるため、/reload は不要です。

api フィールドは使用するストリーミング実装を決定します:

API用途
anthropic-messagesAnthropic Claude API および互換実装
openai-completionsOpenAI Chat Completions API および互換実装
openai-responsesOpenAI Responses API
azure-openai-responsesAzure OpenAI Responses API
openai-codex-responsesOpenAI Codex Responses API
mistral-conversationsMistral SDK Conversations/Chat ストリーミング
google-generative-aiGoogle Generative AI API
google-vertexGoogle Vertex AI API
bedrock-converse-streamAmazon 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 フラグを明示的に設定してください。

プロバイダーが 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: [...]
});

/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 経由で認証できます。

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>;
}

認証情報は ~/.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 を持つプロバイダーには streamSimple を実装します。独自実装を書く前に、既存のプロバイダー実装を研究してください:

参考実装:

すべてのプロバイダーは同じパターンに従います:

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;
}

次の順序で stream.push() 経由でイベントをプッシュします:

  1. { type: "start", partial: output } - ストリーム開始

  2. コンテンツイベント(繰り返し可能、各ブロックで 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 } - ツール呼び出し終了
  3. { type: "done", reason, message } または { type: "error", reason, error } - ストリーム終了

各イベントの partial フィールドには現在の AssistantMessage 状態が含まれます。データ受信時に output.content を更新し、outputpartial として含めます。

データ到着時に output.content にコンテンツブロックを追加します:

// Text block
output.content.push({ type: "text", text: "" });
stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
// As text arrives
const block = output.content[contentIndex];
if (block.type === "text") {
block.text += delta;
stream.push({ type: "text_delta", contentIndex, delta, partial: output });
}
// When block completes
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });

ツール呼び出しには JSON の蓄積とパースが必要です:

// Start tool call
output.content.push({
type: "toolCall",
id: toolCallId,
name: toolName,
arguments: {}
});
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
// Accumulate JSON
let partialJson = "";
partialJson += jsonDelta;
try {
block.arguments = JSON.parse(partialJson);
} catch {}
stream.push({ type: "toolcall_delta", contentIndex, delta: jsonDelta, partial: output });
// Complete
stream.push({
type: "toolcall_end",
contentIndex,
toolCall: { type: "toolCall", id, name, arguments: block.arguments },
partial: output
});

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 メッセージで実行されます:

プロバイダーが 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 は次を行います:

  1. errorMessage からオーバーフローを検出。
  2. 失敗した assistant メッセージをライブコンテキストから削除。
  3. コンパクションを実行。
  4. リクエストを 1 回再試行。

書き換えは慎重に保護してください:

  • 自分のプロバイダーに限定(message.providerctx.model?.provider)し、他プロバイダーの無関係なエラーに触れないようにします。
  • pi の汎用オーバーフローパターンではなく、プロバイダー固有のパターンに一致させます。レート制限やスロットリングエラー(rate limittoo 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
});

組み込みプロバイダーと同じテストスイートでプロバイダーをテストします。packages/ai/test/ からこれらのテストファイルをコピーして適応してください:

テスト目的
stream.test.ts基本ストリーミング、テキスト出力
tokens.test.tsトークンカウントと使用量
abort.test.tsAbortSignal 処理
empty.test.ts空/最小レスポンス
context-overflow.test.tsコンテキストウィンドウ制限
image-limits.test.ts画像入力処理
unicode-surrogate.test.tsUnicode エッジケース
tool-call-without-result.test.tsツール呼び出しエッジケース
image-tool-result.test.tsツール結果内の画像
total-tokens.test.ts総トークン計算
cross-provider-handoff.test.tsプロバイダー間のコンテキスト引き継ぎ

プロバイダー/モデルペアでテストを実行し、互換性を検証してください。

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>[];
};
}
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;
};
}

openrouterreasoning: { effort } を送信します。deepseekthinking: { type: "enabled" | "disabled" } を送信し、有効時は reasoning_effort も送信します。togetherreasoning: { enabled } を送信し、supportsReasoningEffort が有効な場合は reasoning_effort も送信します。qwen は DashScope スタイルのトップレベル enable_thinking 用です。chat_template_kwargs.enable_thinking を読み取るローカル Qwen 互換サーバーには qwen-chat-template を使用してください。 cacheControlFormat: "anthropic" は Anthropic スタイルの cache_control マーカーをシステムプロンプト、最後のツール定義、最後の user/assistant テキストコンテンツに適用します。