Skill Tracer
Skills › Scientific Agent Skills › pi-agent › custom-provider.md

custom-provider.md

134 lines · skills/pi-agent/references/custom-provider.md at 5f2bc03

Open on GitHub
1# Custom Providers
2
3Source: https://pi.dev/docs/latest/custom-provider
4
5Reviewed against Pi 0.99.2 and the package versions listed in `../SKILL.md` on 2026-09-30.
6
7Extensions register providers with `pi.registerProvider()` for proxies, private deployments, OAuth/SSO, and non-standard streaming APIs. Two forms exist: a complete pi-ai `Provider` (preferred when you need custom authentication, filtering, refresh, or streaming) and the legacy provider-config object. `models.json` overrides compose **above** registered native providers.
8

Custom Providers

Source: https://pi.dev/docs/latest/custom-provider

Reviewed against Pi 0.99.2 and the package versions listed in ../SKILL.md on 2026-09-30.

Extensions register providers with pi.registerProvider() for proxies, private deployments, OAuth/SSO, and non-standard streaming APIs. Two forms exist: a complete pi-ai Provider (preferred when you need custom authentication, filtering, refresh, or streaming) and the legacy provider-config object. models.json overrides compose above registered native providers.

Raw lines 1 to 8
9## Complete Provider Form
11```ts
12import { createProvider } from "@earendil-works/pi-ai";
13import * as openaiCompletionsApi from "@earendil-works/pi-ai/api/openai-completions";
14import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16export default function (pi: ExtensionAPI) {
17 pi.registerProvider(createProvider({
18 id: "native-local",
19 name: "Native Local",
20 baseUrl: "http://localhost:8080/v1",
21 auth: {
22 apiKey: {
23 name: "Local server API key",
24 async login(interaction) {
25 return { type: "api_key", key: await interaction.prompt({ type: "secret", message: "API key" }) };
26 },
27 async resolve({ credential }) {
28 return credential?.key ? { auth: { apiKey: credential.key }, source: "stored API key" } : undefined;
29 },
30 },
31 },
32 models: [],
33 api: openaiCompletionsApi,
34 }));
36```
38The object form accepts a complete `Provider`, including native `auth`, `getModels`, `refreshModels`, `filterModels`, `stream`, and `streamSimple`.

Complete Provider Form

import { createProvider } from "@earendil-works/pi-ai";
import * as openaiCompletionsApi from "@earendil-works/pi-ai/api/openai-completions";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.registerProvider(createProvider({
    id: "native-local",
    name: "Native Local",
    baseUrl: "http://localhost:8080/v1",
    auth: {
      apiKey: {
        name: "Local server API key",
        async login(interaction) {
          return { type: "api_key", key: await interaction.prompt({ type: "secret", message: "API key" }) };
        },
        async resolve({ credential }) {
          return credential?.key ? { auth: { apiKey: credential.key }, source: "stored API key" } : undefined;
        },
      },
    },
    models: [],
    api: openaiCompletionsApi,
  }));
}

The object form accepts a complete Provider, including native auth, getModels, refreshModels, filterModels, stream, and streamSimple.

Raw lines 9 to 39
40## Legacy Config Form
42```ts
43// Override baseUrl and/or headers only — existing models are preserved
44pi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com" });
45pi.registerProvider("openai", { headers: { "X-Custom-Header": "value" } });
47// New provider with models — replaces all existing models for that provider
48pi.registerProvider("my-provider", {
49 name: "My Provider",
50 baseUrl: "https://api.example.com",
51 apiKey: "$MY_API_KEY",
52 api: "openai-completions",
53 models: [{
54 id: "my-model",
55 name: "My Model",
56 reasoning: false,
57 input: ["text", "image"],
58 cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
59 contextWindow: 128000,
60 maxTokens: 4096,
61 }],
62});
63```
65Use an **async extension factory** for dynamic model discovery so models are registered before startup finishes and are visible to `pi --list-models`. Dynamic providers can also implement `refreshModels(context)`. Complete Providers return nothing and call `context.publish({ update })` to install provider-owned state exposed by `getModels()`. Legacy config callbacks return model definitions. Use the generation-checked publish persistence contract, never the removed `context.store` pattern; pass `context.signal` to I/O.
67`pi.unregisterProvider(name)` removes that provider's dynamic models, API key fallback, OAuth registration, and custom stream handlers, restoring overridden built-in behavior. Calls made after the initial load phase take effect immediately — no `/reload`.

Legacy Config Form

// Override baseUrl and/or headers only — existing models are preserved
pi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com" });
pi.registerProvider("openai", { headers: { "X-Custom-Header": "value" } });

// New provider with models — replaces all existing models for that provider
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,
  }],
});

Use an async extension factory for dynamic model discovery so models are registered before startup finishes and are visible to pi --list-models. Dynamic providers can also implement refreshModels(context). Complete Providers return nothing and call context.publish({ update }) to install provider-owned state exposed by getModels(). Legacy config callbacks return model definitions. Use the generation-checked publish persistence contract, never the removed context.store pattern; pass context.signal to I/O.

pi.unregisterProvider(name) removes that provider's dynamic models, API key fallback, OAuth registration, and custom stream handlers, restoring overridden built-in behavior. Calls made after the initial load phase take effect immediately — no /reload.

Raw lines 40 to 68
69## API Types
71`anthropic-messages`, `openai-completions`, `openai-responses`, `azure-openai-responses`, `openai-codex-responses`, `mistral-conversations`, `google-generative-ai`, `google-vertex`, `bedrock-converse-stream`.
73Most OpenAI-compatible providers work with `openai-completions`; use model-level `thinkingLevelMap` for thinking levels and `compat` for quirks (full flag list in `references/models.md`). `xhigh` and `max` are opt-in and require non-null map entries. Mistral moved from `openai-completions` to `mistral-conversations` (native Mistral Chat Completions streaming) — use the latter for native Mistral models.

API Types

anthropic-messages, openai-completions, openai-responses, azure-openai-responses, openai-codex-responses, mistral-conversations, google-generative-ai, google-vertex, bedrock-converse-stream.

Most OpenAI-compatible providers work with openai-completions; use model-level thinkingLevelMap for thinking levels and compat for quirks (full flag list in references/models.md). xhigh and max are opt-in and require non-null map entries. Mistral moved from openai-completions to mistral-conversations (native Mistral Chat Completions streaming) — use the latter for native Mistral models.

Raw lines 69 to 74
75## Auth Header and Secrets
77`authHeader: true` adds `Authorization: Bearer <apiKey>`; the key is resolved per request and an explicit request `Authorization` header wins. `apiKey` and custom header values use the same syntax as `models.json`: leading `!command` executes for the whole value, `$ENV`/`${ENV}` interpolate, `$$` and `$!` escape literals.

Auth Header and Secrets

authHeader: true adds Authorization: Bearer <apiKey>; the key is resolved per request and an explicit request Authorization header wins. apiKey and custom header values use the same syntax as models.json: leading !command executes for the whole value, $ENV/${ENV} interpolate, $$ and $! escape literals.

Raw lines 75 to 78
79## OAuth
81```ts
82oauth: {
83 name: "Corporate AI (SSO)",
84 async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>,
85 async refreshToken(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials>,
86 getApiKey(credentials: OAuthCredentials): string,
88```
90`OAuthLoginCallbacks`: `onAuth({ url })` (open in browser), `onDeviceCode({ userCode, verificationUri, intervalSeconds?, expiresInSeconds? })`, `onProgress?(message)`, `onPrompt({ message }): Promise<string>`, `onSelect({ message, options: { id, label }[] }): Promise<string | undefined>`.
92`OAuthCredentials` is `{ refresh, access, expires }` (expiry in ms), persisted in `~/.pi/agent/auth.json`. Users authenticate with `/login <provider-name>`. `refreshToken` receives an `AbortSignal` — pass it to blocking I/O and call `signal.throwIfAborted()` early.

OAuth

oauth: {
  name: "Corporate AI (SSO)",
  async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>,
  async refreshToken(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials>,
  getApiKey(credentials: OAuthCredentials): string,
}

OAuthLoginCallbacks: onAuth({ url }) (open in browser), onDeviceCode({ userCode, verificationUri, intervalSeconds?, expiresInSeconds? }), onProgress?(message), onPrompt({ message }): Promise<string>, onSelect({ message, options: { id, label }[] }): Promise<string | undefined>.

OAuthCredentials is { refresh, access, expires } (expiry in ms), persisted in ~/.pi/agent/auth.json. Users authenticate with /login <provider-name>. refreshToken receives an AbortSignal — pass it to blocking I/O and call signal.throwIfAborted() early.

Raw lines 79 to 93
94## Custom Streaming
96Implement `streamSimple(model, context, options?)` with a normalized `TranscriptContext`: read prompt/tools with `getCurrentSystemPrompt(context.messages)` and `getCurrentTools(context.messages)`, or use `collapseSystemMessages(context)` for transports that cannot represent mid-conversation updates. Do not read removed `context.systemPrompt`/`context.tools`. Return an `AssistantMessageEventStream` from `createAssistantMessageEventStream()`. Initialize an `AssistantMessage` (`role`, `content: []`, `api`, `provider`, `model`, zeroed `usage`, `stopReason: "pending"`, `timestamp`), then:
981. `stream.push({ type: "start", partial: output })`
992. Content events, tracking `contentIndex` per block: `text_start`, `text_delta`, `text_end`, `thinking_start`, `thinking_delta`, `thinking_end`, `toolcall_start`, `toolcall_delta`, `toolcall_end`
1003. `stream.push({ type: "done", reason, message })` or `{ type: "error", reason, error }`, then `stream.end()`
102`stopReason: "pending"` marks the partial message; set a terminal reason before pushing `done` (throw for `"error"`/`"aborted"`). Content/start events carry `partial` with the current `AssistantMessage` state; terminal events instead carry `message` or `error` — mutate `output.content` as data arrives and pass `output`. Tool calls accumulate JSON deltas, parse into `{ id, name, arguments }`, and finish with `toolcall_end` carrying the full `toolCall`. Update usage from the API response and call `calculateCost(model, output.usage)`. Register with `streamSimple` on the provider config.
104Reference implementations live in `packages/ai/src/api/`, including `anthropic-messages.ts`, `mistral-conversations.ts`, `openai-completions.ts`, `openai-responses.ts`, `google-generative-ai.ts`, and `bedrock-converse-stream.ts`. Honor `options.onPayload`, `options.onResponse`, and awaited `options.onProviderStreamEvent` plus cancellation and provider-scoped environment.

Custom Streaming

Implement streamSimple(model, context, options?) with a normalized TranscriptContext: read prompt/tools with getCurrentSystemPrompt(context.messages) and getCurrentTools(context.messages), or use collapseSystemMessages(context) for transports that cannot represent mid-conversation updates. Do not read removed context.systemPrompt/context.tools. Return an AssistantMessageEventStream from createAssistantMessageEventStream(). Initialize an AssistantMessage (role, content: [], api, provider, model, zeroed usage, stopReason: "pending", timestamp), then:

  1. stream.push({ type: "start", partial: output })
  2. Content events, tracking contentIndex per block: text_start, text_delta, text_end, thinking_start, thinking_delta, thinking_end, toolcall_start, toolcall_delta, toolcall_end
  3. stream.push({ type: "done", reason, message }) or { type: "error", reason, error }, then stream.end()

stopReason: "pending" marks the partial message; set a terminal reason before pushing done (throw for "error"/"aborted"). Content/start events carry partial with the current AssistantMessage state; terminal events instead carry message or error — mutate output.content as data arrives and pass output. Tool calls accumulate JSON deltas, parse into { id, name, arguments }, and finish with toolcall_end carrying the full toolCall. Update usage from the API response and call calculateCost(model, output.usage). Register with streamSimple on the provider config.

Reference implementations live in packages/ai/src/api/, including anthropic-messages.ts, mistral-conversations.ts, openai-completions.ts, openai-responses.ts, google-generative-ai.ts, and bedrock-converse-stream.ts. Honor options.onPayload, options.onResponse, and awaited options.onProviderStreamEvent plus cancellation and provider-scoped environment.

Raw lines 94 to 105
106## Context Overflow Errors
108Pi auto-recovers from context overflow by compacting and retrying once, but only when it recognizes the failure: `stopReason === "error"` and `errorMessage` matching a known pattern (see `packages/ai/src/utils/overflow.ts`). If your provider's message is unrecognized, normalize it from the same extension with a `message_end` handler so `errorMessage` starts with a recognized phrase — `context_length_exceeded` is the safest:
110```ts
111pi.on("message_end", (event, ctx) => {
112 const message = event.message;
113 if (message.role !== "assistant" || message.stopReason !== "error") return;
114 if (message.provider !== "my-provider" && ctx.model?.provider !== "my-provider") return;
115 const errorMessage = message.errorMessage ?? "";
116 if (errorMessage.includes("context_length_exceeded")) return;
117 if (!MY_PROVIDER_OVERFLOW_PATTERN.test(errorMessage)) return;
118 return { message: { ...message, errorMessage: `context_length_exceeded: ${errorMessage}` } };
119});
120```
122`message_end` runs before Pi tracks the message for auto-compaction, so the rewrite is what Pi checks. Scope it to your provider, match a provider-specific pattern (never Pi's generic ones — rewriting rate-limit errors would trigger compaction instead of retry-with-backoff), and skip when the phrase is already present so the handler is idempotent.

Context Overflow Errors

Pi auto-recovers from context overflow by compacting and retrying once, but only when it recognizes the failure: stopReason === "error" and errorMessage matching a known pattern (see packages/ai/src/utils/overflow.ts). If your provider's message is unrecognized, normalize it from the same extension with a message_end handler so errorMessage starts with a recognized phrase — context_length_exceeded is the safest:

pi.on("message_end", (event, ctx) => {
  const message = event.message;
  if (message.role !== "assistant" || 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 runs before Pi tracks the message for auto-compaction, so the rewrite is what Pi checks. Scope it to your provider, match a provider-specific pattern (never Pi's generic ones — rewriting rate-limit errors would trigger compaction instead of retry-with-backoff), and skip when the phrase is already present so the handler is idempotent.

Raw lines 106 to 123
124## Testing
126Adapt the suites in `packages/ai/test/` for your provider/model pairs: `stream.test.ts`, `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `image-limits.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. Verify registration with `pi --list-models`, then exercise auth, tools, thinking levels, image input, cache behavior, retries, and context overflow.

Testing

Adapt the suites in packages/ai/test/ for your provider/model pairs: stream.test.ts, tokens.test.ts, abort.test.ts, empty.test.ts, context-overflow.test.ts, image-limits.test.ts, unicode-surrogate.test.ts, tool-call-without-result.test.ts, image-tool-result.test.ts, total-tokens.test.ts, cross-provider-handoff.test.ts. Verify registration with pi --list-models, then exercise auth, tools, thinking levels, image input, cache behavior, retries, and context overflow.

Raw lines 124 to 127
128## Config Reference
130`ProviderConfig`: `name?`, `baseUrl?`, `apiKey?`, `api?`, `streamSimple?`, `headers?`, `authHeader?`, `models?`, `refreshModels?`, `oauth?`.
132`ProviderModelConfig`: `id`, `name`, `api?`, `baseUrl?` (per-model endpoint override), `reasoning`, `thinkingLevelMap?`, `input`, `cost` (`input`, `output`, `cacheRead`, `cacheWrite` per million tokens), `contextWindow`, `maxTokens`, `headers?`, `compat?`.
134Example extensions: `examples/extensions/custom-provider-anthropic/` and `examples/extensions/custom-provider-gitlab-duo/`.

Config Reference

ProviderConfig: name?, baseUrl?, apiKey?, api?, streamSimple?, headers?, authHeader?, models?, refreshModels?, oauth?.

ProviderModelConfig: id, name, api?, baseUrl? (per-model endpoint override), reasoning, thinkingLevelMap?, input, cost (input, output, cacheRead, cacheWrite per million tokens), contextWindow, maxTokens, headers?, compat?.

Example extensions: examples/extensions/custom-provider-anthropic/ and examples/extensions/custom-provider-gitlab-duo/.

Raw lines 128 to 134