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
Ejemplos de extensiones
Sección titulada «Ejemplos de extensiones»Consulta estos ejemplos completos de proveedores:
Tabla de contenidos
Sección titulada «Tabla de contenidos»- Ejemplos de extensiones
- Referencia rápida
- Sobrescribir proveedor existente
- Registrar nuevo proveedor
- Anular registro de proveedor
- Soporte OAuth
- API de streaming personalizada
- Errores de desbordamiento de contexto
- Probar tu implementación
- Referencia de configuración
- Referencia de definición de modelo
Referencia rápida
Sección titulada «Referencia rápida»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.
Sobrescribir proveedor existente
Sección titulada «Sobrescribir proveedor existente»El caso de uso más simple: redirigir un proveedor existente a través de un proxy.
// All Anthropic requests now go through your proxypi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com"});
// Add custom headers to OpenAI requestspi.registerProvider("openai", { headers: { "X-Custom-Header": "value" }});
// Both baseUrl and headerspi.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.
Registrar nuevo proveedor
Sección titulada «Registrar nuevo proveedor»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.
Anular registro de proveedor
Sección titulada «Anular registro de proveedor»Usa pi.unregisterProvider(name) para eliminar un proveedor registrado previamente mediante pi.registerProvider(name, ...):
// Registerpi.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 itpi.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.
Tipos de API
Sección titulada «Tipos de API»El campo api determina qué implementación de streaming se usa:
| API | Usar para |
|---|---|
anthropic-messages | API Anthropic Claude y compatibles |
openai-completions | API OpenAI Chat Completions y compatibles |
openai-responses | API OpenAI Responses |
azure-openai-responses | API Azure OpenAI Responses |
openai-codex-responses | API OpenAI Codex Responses |
mistral-conversations | Streaming Mistral SDK Conversations/Chat |
google-generative-ai | API Google Generative AI |
google-vertex | API Google Vertex AI |
bedrock-converse-stream | API 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-completionsamistral-conversations. Usamistral-conversationspara modelos Mistral nativos. Si enrutas intencionalmente endpoints compatibles con Mistral/personalizados medianteopenai-completions, configura las banderascompatexplícitamente según sea necesario.
Auth Header
Sección titulada «Auth Header»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: [...]});Soporte OAuth
Sección titulada «Soporte OAuth»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.
OAuthLoginCallbacks
Sección titulada «OAuthLoginCallbacks»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>;}OAuthCredentials
Sección titulada «OAuthCredentials»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}API de streaming personalizada
Sección titulada «API de streaming personalizada»Para proveedores con APIs no estándar, implementa streamSimple. Estudia las implementaciones de proveedores existentes antes de escribir la tuya:
Implementaciones de referencia:
- anthropic.ts - API Anthropic Messages
- mistral.ts - API Mistral Conversations
- openai-completions.ts - OpenAI Chat Completions
- openai-responses.ts - API OpenAI Responses
- google.ts - Google Generative AI
- amazon-bedrock.ts - AWS Bedrock
Patrón de stream
Sección titulada «Patrón de stream»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;}Tipos de eventos
Sección titulada «Tipos de eventos»Envía eventos mediante stream.push() en este orden:
-
{ type: "start", partial: output }- Stream iniciado -
Eventos de contenido (repetibles, rastrea
contentIndexpara 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
-
{ 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.
Bloques de contenido
Sección titulada «Bloques de contenido»Añade bloques de contenido a output.content a medida que llegan:
// Text blockoutput.content.push({ type: "text", text: "" });stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
// As text arrivesconst block = output.content[contentIndex];if (block.type === "text") { block.text += delta; stream.push({ type: "text_delta", contentIndex, delta, partial: output });}
// When block completesstream.push({ type: "text_end", contentIndex, content: block.text, partial: output });Llamadas a herramientas
Sección titulada «Llamadas a herramientas»Las llamadas a herramientas requieren acumular JSON y parsearlo:
// Start tool calloutput.content.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: {}});stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
// Accumulate JSONlet partialJson = "";partialJson += jsonDelta;try { block.arguments = JSON.parse(partialJson);} catch {}stream.push({ type: "toolcall_delta", contentIndex, delta: jsonDelta, partial: output });
// Completestream.push({ type: "toolcall_end", contentIndex, toolCall: { type: "toolCall", id, name, arguments: block.arguments }, partial: output});Uso y coste
Sección titulada «Uso y coste»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);Errores de desbordamiento de contexto
Sección titulada «Errores de desbordamiento de contexto»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:
stopReason === "error"errorMessagecoincide con uno de los patrones de desbordamiento conocidos de pi (verpackages/ai/src/utils/overflow.ts)
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:
- Detecta el desbordamiento desde
errorMessage. - Elimina el mensaje assistant fallido del contexto activo.
- Ejecuta la compactación.
- Reintenta la solicitud una vez.
Protege la reescritura con cuidado:
- Limítala a tu proveedor (
message.provideryctx.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
errorMessageya incluyecontext_length_exceededpara que el manejador sea idempotente.
Registro
Sección titulada «Registro»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});Probar tu implementación
Sección titulada «Probar tu implementación»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/:
| Prueba | Propósito |
|---|---|
stream.test.ts | Streaming básico, salida de texto |
tokens.test.ts | Conteo de tokens y uso |
abort.test.ts | Manejo de AbortSignal |
empty.test.ts | Respuestas vacías/mínimas |
context-overflow.test.ts | Límites de ventana de contexto |
image-limits.test.ts | Manejo de entrada de imagen |
unicode-surrogate.test.ts | Casos límite Unicode |
tool-call-without-result.test.ts | Casos límite de llamadas a herramientas |
image-tool-result.test.ts | Imágenes en resultados de herramientas |
total-tokens.test.ts | Cálculo total de tokens |
cross-provider-handoff.test.ts | Transferencia de contexto entre proveedores |
Ejecuta las pruebas con tus pares proveedor/modelo para verificar la compatibilidad.
Referencia de configuración
Sección titulada «Referencia de configuración»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>[]; };}Referencia de definición de modelo
Sección titulada «Referencia de definición de modelo»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.