Integrating Multiple LLM Providers in the pi-ai Abstraction: A Complete Guide
The pi-ai abstraction integrates multiple LLM providers through a four-step pattern: implement dual streaming functions, export them with the StreamFunction type, register via registerApiProvider, and consume through generic high-level helpers that automatically resolve the correct implementation.
The badlogic/pi-mono repository provides a unified streaming interface for Large Language Models through its pi-ai package. When integrating multiple LLM providers within the pi-ai abstraction, developers follow a consistent pattern that treats every service as a streaming API conforming to common interfaces. This design enables seamless swapping between OpenAI, Anthropic, Google, or custom providers without changing consumer code.
The Four-Step Pattern for Integrating LLM Providers
Every provider integration follows an identical structural pattern that keeps provider-specific details isolated while sharing runtime plumbing for payload handling, abort signals, and usage tracking.
Step 1: Implement the Streaming Functions
Create two stream functions in a new provider file under packages/ai/src/providers/. The full-featured function handles complex options, while the simplified version derives from SimpleStreamOptions.
// packages/ai/src/providers/my-provider.ts
import type {
Api,
AssistantMessage,
Context,
Model,
StreamFunction,
StreamOptions,
SimpleStreamOptions,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
export interface MyProviderOptions extends StreamOptions {
customField?: string;
}
export const streamMyProvider: StreamFunction<"my-api", MyProviderOptions> = (
model: Model<"my-api">,
context: Context,
options?: MyProviderOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
// Build request, handle chunks, push events
return stream;
};
Step 2: Export with the StreamFunction Type
Export both implementations using the StreamFunction type to enable compile-time checking of provider-specific options.
export const streamSimpleMyProvider: StreamFunction<"my-api", SimpleStreamOptions> = (
model,
context,
options,
) => {
const base = buildBaseOptions(model, options, options?.apiKey ?? "");
return streamMyProvider(model, context, { ...base } satisfies MyProviderOptions);
};
Step 3: Register with the Global Registry
Import the functions into packages/ai/src/providers/register-builtins.ts and call registerApiProvider to map the API identifier to your implementation.
// packages/ai/src/providers/register-builtins.ts
import { registerApiProvider } from "../api-registry.js";
import { streamMyProvider, streamSimpleMyProvider } from "./my-provider.js";
export function registerBuiltInApiProviders(): void {
registerApiProvider({
api: "my-api",
stream: streamMyProvider,
streamSimple: streamSimpleMyProvider,
});
// ... other providers
}
// Auto-execute on module load
registerBuiltInApiProviders();
Step 4: Consume Through High-Level Helpers
Application code consumes the provider through generic functions that automatically resolve the correct implementation based on the Model object's api field.
import { streamSimple, Model, Context } from "@mariozechner/pi-ai";
const model: Model<"my-api"> = {
id: "my-model-1",
name: "My-Model",
api: "my-api",
provider: "my-provider",
baseUrl: "https://api.myprovider.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
headers: { "X-Custom": "value" },
};
const ctx: Context = {
systemPrompt: "You are an assistant.",
messages: [{ role: "user", content: "Hello!", timestamp: Date.now() }],
};
const stream = streamSimple(model, ctx, { temperature: 0.5 });
stream.on("text_delta", ({ delta }) => console.log("Δ:", delta));
stream.on("done", ({ message }) => console.log("Finished:", message));
Core Types and Registry Architecture
The abstraction relies on three core files that define the contracts and wiring mechanisms.
| File | Role |
|---|---|
packages/ai/src/types.ts |
Defines Api, Provider, StreamOptions, SimpleStreamOptions, StreamFunction, and AssistantMessageEventStream. |
packages/ai/src/api-registry.ts |
Holds the Map<Api, ApiProvider> registry and exports registerApiProvider for validation and wrapping. |
packages/ai/src/stream.ts |
Public entry points (stream, streamSimple, complete, completeSimple) that resolve providers from the registry. |
The registration helper in packages/ai/src/api-registry.ts validates mappings and wraps stream functions to enforce the requested API:
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
sourceId?: string,
): void {
apiProviderRegistry.set(provider.api, {
provider: {
api: provider.api,
stream: wrapStream(provider.api, provider.stream),
streamSimple: wrapStreamSimple(provider.api, provider.streamSimple),
},
sourceId,
});
}
Built-in Provider Registration
All first-party providers are wired in packages/ai/src/providers/register-builtins.ts. This centralizes provider discovery and executes automatically on module load:
export function registerBuiltInApiProviders(): void {
registerApiProvider({ api: "anthropic-messages", stream: streamAnthropic, streamSimple: streamSimpleAnthropic });
registerApiProvider({ api: "openai-completions", stream: streamOpenAICompletions, streamSimple: streamSimpleOpenAICompletions });
registerApiProvider({ api: "openai-responses", stream: streamOpenAIResponses, streamSimple: streamSimpleOpenAIResponses });
registerApiProvider({ api: "google", stream: streamGoogle, streamSimple: streamSimpleGoogle });
registerApiProvider({ api: "ollama", stream: streamOllama, streamSimple: streamSimpleOllama });
registerApiProvider({ api: "openrouter", stream: streamOpenRouter, streamSimple: streamSimpleOpenRouter });
}
registerBuiltInApiProviders();
Summary
- Implement dual functions: Every provider needs a full-featured
stream…and a simplifiedstreamSimple…function that returnAssistantMessageEventStream. - Type safety: Export functions using the
StreamFunction<Api, Options>type to enable compile-time checking of provider-specific options. - Registration: Call
registerApiProviderinpackages/ai/src/providers/register-builtins.tsto map the API identifier to your implementation. - Consumption: Use generic helpers like
streamSimpleorcompletewhich automatically resolve the correct provider from the registry based on theModel.apifield. - Isolation: Provider-specific logic (request building, authentication, response parsing) remains isolated in dedicated files while sharing common runtime plumbing for abort signals and usage tracking.
Frequently Asked Questions
How does the registry know which provider implementation to use?
The registry in packages/ai/src/api-registry.ts maintains a Map that associates each Api identifier (like "openai-completions" or "anthropic-messages") with its corresponding ApiProvider object. When you call streamSimple(model, context, options), the function reads model.api, looks up the registered provider, and invokes its streamSimple implementation. This resolution happens at runtime, allowing you to switch providers by simply changing the Model configuration object.
What is the difference between the stream and streamSimple functions?
The stream function accepts full StreamOptions including advanced parameters like tools, toolChoice, responseFormat, and provider-specific extensions, giving you complete control over the LLM request. The streamSimple function accepts SimpleStreamOptions (typically just temperature, maxTokens, and apiKey) and internally constructs the full options object before delegating to the main stream function. This dual-layer design lets consumers choose between maximum flexibility and minimal configuration overhead.
Do I need to modify core library files to add a new LLM provider?
You only need to modify one core file: packages/ai/src/providers/register-builtins.ts. Create your provider implementation in a new file under packages/ai/src/providers/, then import and register it using registerApiProvider in the registration function. You do not need to touch packages/ai/src/stream.ts, packages/ai/src/api-registry.ts, or packages/ai/src/types.ts unless you are adding new global options or changing the core abstraction contracts.
How does error handling work when integrating multiple LLM providers?
Each provider implementation handles provider-specific errors (HTTP status codes, rate limits, malformed responses) internally within its stream function, converting them into standard AssistantMessageEvent error events or throwing exceptions that propagate through the AssistantMessageEventStream. The high-level helpers in packages/ai/src/stream.ts remain agnostic to these details, treating all providers uniformly. Consumers attach error listeners to the returned event stream to handle failures regardless of which underlying provider generated the error.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →