- Add resetApiProviders() to clear and re-register built-in providers - Add createAssistantMessageEventStream() factory for extensions - Add streamSimple support in ProviderConfig for custom API implementations - Call resetApiProviders() on /reload to clean up extension providers - Add custom-provider.md documentation - Add custom-provider.ts example with full Anthropic implementation - Update extensions.md with streamSimple config option
15 KiB
Custom Providers
Extensions can register custom model providers via pi.registerProvider(). This enables:
- Proxies - Route requests through corporate proxies or API gateways
- Custom endpoints - Use self-hosted or private model deployments
- OAuth/SSO - Add authentication flows for enterprise providers
- Custom APIs - Implement streaming for non-standard LLM APIs
Table of Contents
- Quick Reference
- Override Existing Provider
- Register New Provider
- OAuth Support
- Custom Streaming API
- Config Reference
- Model Definition Reference
Quick Reference
import type { ExtensionAPI } from "@mariozechner/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", {
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
}
]
});
}
Override Existing Provider
The simplest use case: redirect an existing provider through a proxy.
// 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
}
});
When only baseUrl and/or headers are provided (no models), all existing models for that provider are preserved with the new endpoint.
Register New Provider
To add a completely new provider, specify models along with the required configuration.
pi.registerProvider("my-llm", {
baseUrl: "https://api.my-llm.com/v1",
apiKey: "MY_LLM_API_KEY", // env var name or literal value
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
},
{
id: "my-llm-small",
name: "My LLM Small",
reasoning: false,
input: ["text"],
cost: { input: 0.25, output: 1.25, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192
}
]
});
When models is provided, it replaces all existing models for that provider.
API Types
The api field determines which streaming implementation is used:
| API | Use for |
|---|---|
anthropic-messages |
Anthropic Claude API and compatibles |
openai-completions |
OpenAI Chat Completions API and compatibles |
openai-responses |
OpenAI Responses API |
azure-openai-responses |
Azure OpenAI Responses API |
openai-codex-responses |
OpenAI Codex Responses API |
google-generative-ai |
Google Generative AI API |
google-gemini-cli |
Google Cloud Code Assist API |
google-vertex |
Google Vertex AI API |
bedrock-converse-stream |
Amazon Bedrock Converse API |
Most OpenAI-compatible providers work with openai-completions. Use compat for quirks:
models: [{
id: "custom-model",
// ...
compat: {
supportsDeveloperRole: false, // use "system" instead of "developer"
supportsReasoningEffort: false, // disable reasoning_effort param
maxTokensField: "max_tokens", // instead of "max_completion_tokens"
requiresToolResultName: true, // tool results need name field
requiresMistralToolIds: true // tool IDs must be 9 alphanumeric chars
}
}]
Auth Header
If your provider expects Authorization: Bearer <key> but doesn't use a standard API, set 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 Support
Add OAuth/SSO authentication that integrates with /login:
import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
pi.registerProvider("corporate-ai", {
baseUrl: "https://ai.corp.com/v1",
api: "openai-responses",
models: [
{
id: "corp-claude",
name: "Corporate Claude",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 16384
}
],
oauth: {
name: "Corporate AI (SSO)",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
// Option 1: Browser-based OAuth
callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." });
// Option 2: Device code flow
callbacks.onDeviceCode({
userCode: "ABCD-1234",
verificationUri: "https://sso.corp.com/device"
});
// Option 3: Prompt for token/code
const 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) {
// e.g., update baseUrl based on user's region
const region = decodeRegionFromToken(credentials.access);
return models.map(m => ({
...m,
baseUrl: `https://${region}.ai.corp.com/v1`
}));
}
}
});
After registration, users can authenticate via /login corporate-ai.
OAuthLoginCallbacks
The callbacks object provides three ways to authenticate:
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 }): void;
// Prompt user for input (for manual token entry)
onPrompt(params: { message: string }): Promise<string>;
}
OAuthCredentials
Credentials are persisted in ~/.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
}
Custom Streaming API
For providers with non-standard APIs, implement streamSimple:
import type {
AssistantMessageEventStream,
Context,
Model,
SimpleStreamOptions,
Api
} from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
pi.registerProvider("custom-llm", {
baseUrl: "https://api.custom-llm.com",
apiKey: "CUSTOM_LLM_KEY",
api: "custom-llm-api", // your custom API identifier
models: [
{
id: "custom-model",
name: "Custom Model",
reasoning: false,
input: ["text"],
cost: { input: 1.0, output: 2.0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
maxTokens: 4096
}
],
streamSimple(
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions
): AssistantMessageEventStream {
return createAssistantMessageEventStream(async function* (signal) {
// Convert context to your API format
const messages = context.messages.map(m => ({
role: m.role,
content: typeof m.content === "string"
? m.content
: m.content.filter(c => c.type === "text").map(c => c.text).join("")
}));
// Make streaming request
const response = await fetch(`${model.baseUrl}/chat`, {
method: "POST",
headers: {
"Authorization": `Bearer ${options?.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: model.id,
messages,
stream: true
}),
signal
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
// Yield start event
yield { type: "start" };
// Parse SSE stream
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
let contentIndex = 0;
let textStarted = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
if (data === "[DONE]") continue;
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
if (!textStarted) {
yield { type: "text_start", contentIndex };
textStarted = true;
}
yield { type: "text_delta", contentIndex, delta };
}
}
}
if (textStarted) {
yield { type: "text_end", contentIndex };
}
// Yield usage if available
yield {
type: "usage",
usage: {
input: 0, // fill from response if available
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
}
};
// Yield done
yield { type: "done", reason: "stop" };
});
}
});
Event Types
Your generator must yield events in this order:
{ type: "start" }- Stream started- Content events (repeatable, in order):
{ type: "text_start", contentIndex }- Text block started{ type: "text_delta", contentIndex, delta }- Text chunk{ type: "text_end", contentIndex }- Text block ended{ type: "thinking_start", contentIndex }- Thinking block started{ type: "thinking_delta", contentIndex, delta }- Thinking chunk{ type: "thinking_end", contentIndex }- Thinking block ended{ type: "toolcall_start", contentIndex }- Tool call started{ type: "toolcall_delta", contentIndex, delta }- Tool call JSON chunk{ type: "toolcall_end", contentIndex, toolCall }- Tool call ended
{ type: "usage", usage }- Token usage (optional but recommended){ type: "done", reason }or{ type: "error", error }- Stream ended
Reasoning Support
For models with extended thinking, yield thinking events:
if (chunk.thinking) {
if (!thinkingStarted) {
yield { type: "thinking_start", contentIndex: thinkingIndex };
thinkingStarted = true;
}
yield { type: "thinking_delta", contentIndex: thinkingIndex, delta: chunk.thinking };
}
Tool Calls
For function calling support, yield tool call events:
if (chunk.tool_calls) {
for (const tc of chunk.tool_calls) {
if (tc.index !== currentToolIndex) {
if (currentToolIndex >= 0) {
yield {
type: "toolcall_end",
contentIndex: currentToolIndex,
toolCall: {
type: "toolCall",
id: currentToolId,
name: currentToolName,
arguments: JSON.parse(currentToolArgs)
}
};
}
currentToolIndex = tc.index;
currentToolId = tc.id;
currentToolName = tc.function.name;
currentToolArgs = "";
yield { type: "toolcall_start", contentIndex: tc.index };
}
if (tc.function.arguments) {
currentToolArgs += tc.function.arguments;
yield { type: "toolcall_delta", contentIndex: tc.index, delta: tc.function.arguments };
}
}
}
Config Reference
interface ProviderConfig {
/** API endpoint URL. Required when defining models. */
baseUrl?: string;
/** API key or environment variable name. 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 can be env var names. */
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>[];
};
}
Model Definition Reference
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;
/** Whether the model supports extended thinking. */
reasoning: boolean;
/** 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>;
/** OpenAI compatibility settings for openai-completions API. */
compat?: {
supportsStore?: boolean;
supportsDeveloperRole?: boolean;
supportsReasoningEffort?: boolean;
supportsUsageInStreaming?: boolean;
maxTokensField?: "max_completion_tokens" | "max_tokens";
requiresToolResultName?: boolean;
requiresAssistantAfterToolResult?: boolean;
requiresThinkingAsText?: boolean;
requiresMistralToolIds?: boolean;
thinkingFormat?: "openai" | "zai";
};
}