Saltearse al contenido

Proveedores personalizados

Las extensiones pueden registrar proveedores de modelos personalizados mediante pi.registerProvider(). Esto permite:

  • Proxies - Enrutar solicitudes a través de proxies corporativos o puertas de enlace API
  • Endpoints personalizados - Usar despliegues de modelos autoalojados o privados
  • OAuth/SSO - Añadir flujos de autenticación para proveedores empresariales
  • APIs personalizadas - Implementar streaming para APIs LLM no estándar

Consulta estos ejemplos completos de proveedores:

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

La fábrica de extensiones también puede ser async. Para el descubrimiento dinámico de modelos, obtén y registra los modelos en la fábrica en lugar de en session_start. pi espera a la fábrica antes de continuar el arranque, por lo que el proveedor está disponible durante el inicio interactivo y para pi --list-models.

El caso de uso más simple: redirigir un proveedor existente a través de un 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
}
});

Cuando solo se proporcionan baseUrl y/o headers (sin models), se conservan todos los modelos existentes de ese proveedor con el nuevo endpoint.

Para añadir un proveedor completamente nuevo, especifica models junto con la configuración requerida.

Si la lista de modelos proviene de un endpoint remoto, usa una fábrica de extensiones 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,
})),
});
}

Esto registra los modelos obtenidos antes de que finalice el arranque.

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

Cuando se proporciona models, reemplaza todos los modelos existentes de ese proveedor.

apiKey y los valores de encabezados personalizados usan la misma sintaxis de valores de configuración que models.json: !command al inicio ejecuta un comando para el valor completo, $ENV_VAR y ${ENV_VAR} interpolan variables de entorno, $$ emite un $ literal y $! emite un ! literal.

Usa pi.unregisterProvider(name) para eliminar un proveedor registrado previamente mediante 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");

Anular el registro elimina los modelos dinámicos de ese proveedor, el fallback de clave API, el registro del proveedor OAuth y los registros de manejadores de stream personalizados. Se restauran los modelos integrados o el comportamiento del proveedor que se hubieran sobrescrito.

Las llamadas realizadas después de la fase inicial de carga de extensiones se aplican de inmediato, por lo que no se requiere /reload.

El campo api determina qué implementación de streaming se usa:

APIUsar para
anthropic-messagesAPI Anthropic Claude y compatibles
openai-completionsAPI OpenAI Chat Completions y compatibles
openai-responsesAPI OpenAI Responses
azure-openai-responsesAPI Azure OpenAI Responses
openai-codex-responsesAPI OpenAI Codex Responses
mistral-conversationsStreaming Mistral SDK Conversations/Chat
google-generative-aiAPI Google Generative AI
google-vertexAPI Google Vertex AI
bedrock-converse-streamAPI Amazon Bedrock Converse

La mayoría de proveedores compatibles con OpenAI funcionan con openai-completions. Usa thinkingLevelMap a nivel de modelo para niveles de pensamiento específicos del modelo, y compat para peculiaridades del proveedor:

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

Usa openrouter para controles estilo OpenRouter reasoning: { effort }. Usa together para controles estilo Together reasoning: { enabled }; con supportsReasoningEffort, también envía reasoning_effort. Usa qwen-chat-template en su lugar para servidores locales compatibles con Qwen que leen chat_template_kwargs.enable_thinking. Usa cacheControlFormat: "anthropic" para proveedores compatibles con OpenAI que exponen caché de prompts estilo Anthropic mediante cache_control en el prompt del sistema, la última definición de herramienta y el último contenido de texto user/assistant.

Para proveedores compatibles con Anthropic que usan api: "anthropic-messages", establece compat.forceAdaptiveThinking: true en modelos o proveedores cuyo modelo upstream requiere pensamiento adaptativo (thinking.type: "adaptive" más output_config.effort). Los modelos Claude adaptativos integrados lo configuran automáticamente. Establece compat.allowEmptySignature: true solo para proveedores que emiten firmas de pensamiento vacías y esperan signature: "" en la reproducción.

Nota de migración: Mistral pasó de openai-completions a mistral-conversations. Usa mistral-conversations para modelos Mistral nativos. Si enrutas intencionalmente endpoints compatibles con Mistral/personalizados mediante openai-completions, configura las banderas compat explícitamente según sea necesario.

Si tu proveedor espera Authorization: Bearer <key> pero no usa una API estándar, establece 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: [...]
});

Añade autenticación OAuth/SSO que se integra con /login:

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

Tras el registro, los usuarios pueden autenticarse mediante /login corporate-ai.

El objeto callbacks ofrece tres formas de autenticar:

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

Las credenciales se persisten en ~/.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
}

Para proveedores con APIs no estándar, implementa streamSimple. Estudia las implementaciones de proveedores existentes antes de escribir la tuya:

Implementaciones de referencia:

Todos los proveedores siguen el mismo patrón:

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

Envía eventos mediante stream.push() en este orden:

  1. { type: "start", partial: output } - Stream iniciado

  2. Eventos de contenido (repetibles, rastrea contentIndex para cada bloque):

    • { type: "text_start", contentIndex, partial } - Bloque de texto iniciado
    • { type: "text_delta", contentIndex, delta, partial } - Fragmento de texto
    • { type: "text_end", contentIndex, content, partial } - Bloque de texto finalizado
    • { type: "thinking_start", contentIndex, partial } - Pensamiento iniciado
    • { type: "thinking_delta", contentIndex, delta, partial } - Fragmento de pensamiento
    • { type: "thinking_end", contentIndex, content, partial } - Pensamiento finalizado
    • { type: "toolcall_start", contentIndex, partial } - Llamada a herramienta iniciada
    • { type: "toolcall_delta", contentIndex, delta, partial } - Fragmento JSON de llamada a herramienta
    • { type: "toolcall_end", contentIndex, toolCall, partial } - Llamada a herramienta finalizada
  3. { type: "done", reason, message } o { type: "error", reason, error } - Stream finalizado

El campo partial en cada evento contiene el estado actual de AssistantMessage. Actualiza output.content a medida que recibes datos, luego incluye output como partial.

Añade bloques de contenido a output.content a medida que llegan:

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

Las llamadas a herramientas requieren acumular JSON y parsearlo:

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

Actualiza el uso desde la respuesta de la API y calcula el coste:

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

Cuando una solicitud supera la ventana de contexto del modelo, pi puede recuperarse automáticamente compactando la conversación y reintentando. Esta recuperación solo se activa si pi reconoce el fallo como un desbordamiento.

La detección se ejecuta en el mensaje assistant finalizado:

Si tu proveedor devuelve errores de desbordamiento con un mensaje que pi no reconoce, normaliza el error desde la misma extensión que registra el proveedor. Usa un manejador message_end para reescribir el mensaje assistant de modo que su errorMessage comience con una frase que pi reconozca. El fallback genérico context_length_exceeded es la opción más segura.

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 se ejecuta antes de que pi rastree el mensaje assistant para la compactación automática, por lo que pi comprueba el errorMessage reescrito. Con esto en su lugar, pi:

  1. Detecta el desbordamiento desde errorMessage.
  2. Elimina el mensaje assistant fallido del contexto activo.
  3. Ejecuta la compactación.
  4. Reintenta la solicitud una vez.

Protege la reescritura con cuidado:

  • Limítala a tu proveedor (message.provider y ctx.model?.provider) para no tocar errores no relacionados de otros proveedores.
  • Coincide con un patrón específico del proveedor, no con los patrones genéricos de desbordamiento de pi. Reescribir errores de límite de tasa o throttling (rate limit, too many requests) activaría falsamente la compactación en lugar de la ruta normal de reintento con backoff de pi.
  • Omite cuando errorMessage ya incluye context_length_exceeded para que el manejador sea idempotente.

Registra tu función de stream:

pi.registerProvider("my-provider", {
baseUrl: "https://api.example.com",
apiKey: "$MY_API_KEY",
api: "my-custom-api",
models: [...],
streamSimple: streamMyProvider
});

Prueba tu proveedor contra las mismas suites de pruebas usadas por los proveedores integrados. Copia y adapta estos archivos de prueba desde packages/ai/test/:

PruebaPropósito
stream.test.tsStreaming básico, salida de texto
tokens.test.tsConteo de tokens y uso
abort.test.tsManejo de AbortSignal
empty.test.tsRespuestas vacías/mínimas
context-overflow.test.tsLímites de ventana de contexto
image-limits.test.tsManejo de entrada de imagen
unicode-surrogate.test.tsCasos límite Unicode
tool-call-without-result.test.tsCasos límite de llamadas a herramientas
image-tool-result.test.tsImágenes en resultados de herramientas
total-tokens.test.tsCálculo total de tokens
cross-provider-handoff.test.tsTransferencia de contexto entre proveedores

Ejecuta las pruebas con tus pares proveedor/modelo para verificar la compatibilidad.

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

openrouter envía reasoning: { effort }. deepseek envía thinking: { type: "enabled" | "disabled" } y reasoning_effort cuando está habilitado. together envía reasoning: { enabled } y también reasoning_effort cuando supportsReasoningEffort está habilitado. qwen es para enable_thinking de nivel superior estilo DashScope. Usa qwen-chat-template para servidores locales compatibles con Qwen que leen chat_template_kwargs.enable_thinking. cacheControlFormat: "anthropic" aplica marcadores cache_control estilo Anthropic al prompt del sistema, la última definición de herramienta y el último contenido de texto user/assistant.