Add declarative custom providers through native Pi AI

This commit is contained in:
amosblomqvist
2026-10-28 04:42:46 +02:00
parent 8f6fd7cd29
commit f84bef1f91
11 changed files with 811 additions and 3 deletions
+68
View File
@@ -0,0 +1,68 @@
/** Native Pi providers assembled from validated configuration. No IO or registry
* here: the server supplies key lookup, and Pi owns model dispatch/streaming. */
import { createProvider, type Provider, type ProviderStreams } from "@earendil-works/pi-ai";
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
import { openAICodexResponsesApi } from "@earendil-works/pi-ai/api/openai-codex-responses.lazy";
import { azureOpenAIResponsesApi } from "@earendil-works/pi-ai/api/azure-openai-responses.lazy";
import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
import { googleVertexApi } from "@earendil-works/pi-ai/api/google-vertex.lazy";
import { bedrockConverseStreamApi } from "@earendil-works/pi-ai/api/bedrock-converse-stream.lazy";
import { mistralConversationsApi } from "@earendil-works/pi-ai/api/mistral-conversations.lazy";
import { piMessagesApi } from "@earendil-works/pi-ai/api/pi-messages.lazy";
import { resolveCustomValue, type CustomProviderConfig } from "../core/custom-providers.ts";
const apis: Record<string, ProviderStreams> = {
"anthropic-messages": anthropicMessagesApi(),
"openai-completions": openAICompletionsApi(),
"openai-responses": openAIResponsesApi(),
"openai-codex-responses": openAICodexResponsesApi(),
"azure-openai-responses": azureOpenAIResponsesApi(),
"google-generative-ai": googleGenerativeAIApi(),
"google-vertex": googleVertexApi(),
"bedrock-converse-stream": bedrockConverseStreamApi(),
"mistral-conversations": mistralConversationsApi(),
"pi-messages": piMessagesApi(),
};
export const customProviderApis: ReadonlySet<string> = new Set(Object.keys(apis));
function resolveHeaders(headers: Record<string, string> | undefined, lookup: (name: string) => string | undefined): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const [name, value] of Object.entries(headers ?? {})) {
const resolved = resolveCustomValue(value, lookup);
if (resolved === undefined) return undefined;
result[name] = resolved;
}
return result;
}
export function customProviderAuth(config: CustomProviderConfig, lookup: (name: string) => string | undefined) {
const key = config.definition.apiKey === undefined ? undefined : resolveCustomValue(config.definition.apiKey, lookup);
const headers = resolveHeaders(config.definition.headers, lookup);
if (!key || !headers) return undefined;
// A model header is part of that model's request configuration. Unresolved
// references must not silently disappear from a request.
for (const model of config.models) if (!resolveHeaders(model.headers, lookup)) return undefined;
if (config.definition.authHeader) {
for (const name of Object.keys(headers)) if (name.toLowerCase() === "authorization") delete headers[name];
headers.Authorization = `Bearer ${key}`;
}
return { apiKey: key, headers };
}
export function createCustomProvider(config: CustomProviderConfig, lookup: (name: string) => string | undefined): Provider {
const auth = customProviderAuth(config, lookup);
return createProvider({
id: config.id,
name: config.definition.name ?? config.id,
baseUrl: config.definition.baseUrl,
models: config.models.map((model) => ({ ...model, headers: resolveHeaders(model.headers, lookup) })),
auth: { apiKey: {
name: "API key",
check: async () => auth ? { type: "api_key", source: "custom provider configuration" } : undefined,
resolve: async () => auth ? { auth, source: "custom provider configuration" } : undefined,
} },
// Direct native implementations: no copied stream or request conversion.
api: apis,
});
}
+35 -1
View File
@@ -5,9 +5,11 @@ import {
type Api,
type Model,
type Models,
type MutableModels,
type StreamFunction,
} from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import { lazyStream } from "@earendil-works/pi-ai/api/lazy";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type ModelRole } from "../../config.ts";
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
@@ -49,6 +51,23 @@ export interface PiEngine {
export type { ThinkingLevel };
let collection: ReturnType<typeof createModels> | undefined;
interface ProviderSource {
sync(models: MutableModels): void;
isCustom(id: string): boolean;
}
let providerSource: ProviderSource | undefined;
/** Configuration IO is injected by the server; the agent never imports stores. */
export function configureProviderSource(source: ProviderSource): void {
// Server module re-evaluation can replace the source while this collection
// survives. Retire only the previous source's routes, not built-ins.
if (collection && providerSource) {
for (const provider of collection.getProviders()) {
if (providerSource.isCustom(provider.id)) collection.deleteProvider(provider.id);
}
}
providerSource = source;
}
/** The process-wide pi-ai Models collection — EVERY builtin provider's
* catalog (model slots wave, 2026-09-20): engines and the picker resolve
@@ -63,6 +82,7 @@ export function piModels() {
collection = createModels({ identity: APP_IDENTITY });
for (const provider of builtinProviders()) collection.setProvider(provider);
}
providerSource?.sync(collection);
return collection;
}
@@ -111,6 +131,9 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
const level = spec.thinkingLevel
? clampThinkingLevel(model, spec.thinkingLevel)
: defaultThinkingLevel(model);
// A lesson's agent loop can stay alive through many tool calls. Remember
// only that this is a custom route, never its configuration for that run.
const custom = providerSource?.isCustom(spec.provider) ?? false;
return {
model,
level,
@@ -118,9 +141,20 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
// the pin honest even when config or settings drift off the map.
streamFn: (requestModel, context, options) => {
const requestOptions = { ...options, reasoning: level === "off" ? undefined : level };
if (custom) {
// Revalidate before EVERY upstream model operation, including later
// tool-loop steps. Pi resolves auth and clamps the requested thinking
// against the current native model. Its in-flight operation retains
// its own setup; a subsequent operation uses the reloaded provider.
const current = piModels();
const currentModel = current.getModel(spec.provider, spec.id);
if (!currentModel) return lazyStream(requestModel, async () => { throw new Error("The selected custom model is unavailable."); });
return current.streamSimple(currentModel, context, requestOptions);
}
const purpose = typeof background === "function" ? background : () => background;
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
return stream(requestModel, context, requestOptions);
},
};
}
+4 -2
View File
@@ -73,13 +73,15 @@ export interface ModelCatalogResponse {
* are simply absent (the Keys page is the discovery surface, same
* discipline as the search engines). */
providers: CatalogProvider[];
/** Sanitized configuration diagnostic; built-in catalogs remain available. */
customProvidersError?: string;
}
/** One row of the providers surface. */
export interface CatalogProvider {
id: string;
/** The env var whose presence federates this provider in. */
/** The gating env var; empty for subscription and file-configured routes. */
envVar: string;
/** Whether that var is currently present (env or auth file). */
/** Whether credentials are present (and custom references resolve). */
keyPresent: boolean;
}
+146
View File
@@ -0,0 +1,146 @@
/** Declarative custom-provider configuration. The envelope/normalization follows
* Pi coding-agent 1.4.0's model-config/provider-composer (MIT, Mario Zechner).
* Pi AI owns the shared model/compatibility schemas and the runtime models.
* Deliberate differences: new provider IDs only, explicit limits/prices,
* and no OAuth, extensions, overrides, or executable credential commands.
*
* Adapted portions: Copyright (c) Mario Zechner. Permission is hereby granted,
* free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify,
* merge, publish, distribute, sublicense, and/or sell copies of the Software,
* and to permit persons to whom the Software is furnished to do so, subject to
* the following conditions: The above copyright notice and this permission
* notice shall be included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. */
import { Type, type Static } from "typebox";
import { Compile } from "typebox/compile";
import type { Api, Model } from "@earendil-works/pi-ai";
import { ProviderCompatSchema } from "@earendil-works/pi-ai/providers/compat-schema";
import { ModelCostSchema, ModelInputLimitsSchema, ModelInputModalitySchema, ModelPromptCacheSchema, ThinkingLevelMapSchema } from "@earendil-works/pi-ai/providers/model-schema";
const Headers = Type.Record(Type.String(), Type.String());
const Sampling = Type.Record(Type.String(), Type.Unknown());
const ModelSchema = Type.Object({
id: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),
api: Type.Optional(Type.String({ minLength: 1 })),
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
reasoning: Type.Optional(Type.Boolean()),
thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema),
input: Type.Optional(Type.Array(ModelInputModalitySchema)),
inputLimits: Type.Optional(ModelInputLimitsSchema),
cost: ModelCostSchema,
promptCache: Type.Optional(ModelPromptCacheSchema),
contextWindow: Type.Number({ exclusiveMinimum: 0 }),
maxTokens: Type.Number({ exclusiveMinimum: 0 }),
samplingParams: Type.Optional(Sampling),
samplingParamsByThinkingLevel: Type.Optional(Type.Object(Object.fromEntries(
["off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => [level, Type.Optional(Sampling)]),
))),
headers: Type.Optional(Headers),
compat: Type.Optional(ProviderCompatSchema),
}, { additionalProperties: false });
const ProviderSchema = Type.Object({
name: Type.Optional(Type.String({ minLength: 1 })),
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
api: Type.Optional(Type.String({ minLength: 1 })),
apiKey: Type.Optional(Type.String({ minLength: 1 })),
headers: Type.Optional(Headers),
compat: Type.Optional(ProviderCompatSchema),
authHeader: Type.Optional(Type.Boolean()),
models: Type.Array(ModelSchema),
}, { additionalProperties: false });
const ConfigSchema = Type.Object({
$schema: Type.Optional(Type.String()),
providers: Type.Record(Type.String(), ProviderSchema),
}, { additionalProperties: false });
const validator = Compile(ConfigSchema);
export type CustomProviderDefinition = Static<typeof ProviderSchema>;
export interface CustomProviderConfig {
id: string;
definition: CustomProviderDefinition;
models: Model<Api>[];
}
/** Error messages describe fields only: never include file contents or values. */
export class CustomProviderConfigError extends Error {}
function invalid(message: string): never { throw new CustomProviderConfigError(message); }
/** Pi-style values, but never execute commands. Escapes are processed once;
* a literal/variable's resolved value beginning with ! is still just data. */
export function resolveCustomValue(value: string, lookup: (name: string) => string | undefined): string | undefined {
if (value.startsWith("!")) invalid("Credential commands are not supported in models.json.");
let missing = false;
const resolved = value.replace(/\$\$|\$!|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced: string | undefined, plain: string | undefined) => {
if (match === "$$") return "$";
if (match === "$!") return "!";
const found = lookup(braced ?? plain!);
if (found === undefined) missing = true;
return found ?? "";
});
return missing ? undefined : resolved;
}
function endpoint(value: string | undefined): string {
if (!value) invalid("Each custom model needs an api and baseUrl, at provider or model level.");
try {
const url = new URL(value);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) throw new Error();
} catch { invalid("Custom baseUrl must be an HTTP(S) URL without embedded credentials or a fragment."); }
return value;
}
function compat(base: Model<Api>["compat"], override: Model<Api>["compat"]): Model<Api>["compat"] {
if (!base && !override) return undefined;
const merged = { ...base, ...override } as Record<string, unknown>;
for (const name of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs", "chatTemplateArgs"]) {
const a = (base as Record<string, unknown> | undefined)?.[name];
const b = (override as Record<string, unknown> | undefined)?.[name];
if (a || b) merged[name] = { ...(a as object | undefined), ...(b as object | undefined) };
}
return merged as Model<Api>["compat"];
}
export function parseCustomProviders(text: string, reserved: ReadonlySet<string>, supportedApis: ReadonlySet<string>): CustomProviderConfig[] {
let raw: unknown;
// Match Pi's line comments/trailing commas without touching JSON strings.
const json = text.replace(/^\uFEFF/, "")
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (match) => match[0] === '"' ? match : "")
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (match, tail: string | undefined) => tail ?? (match[0] === '"' ? match : ""));
try { raw = JSON.parse(json); }
catch { return invalid("models.json is not valid JSON."); }
if (!validator.Check(raw)) invalid("Invalid models.json structure: check provider/model fields and explicit contextWindow, maxTokens and cost.");
return Object.entries(raw.providers).map(([id, definition]) => {
if (!id.trim() || id.includes(":")) invalid("Custom provider IDs must be nonempty and must not contain a colon.");
if (reserved.has(id)) invalid("Custom provider IDs must not replace built-in or subscription providers.");
for (const value of [definition.apiKey, ...Object.values(definition.headers ?? {}), ...definition.models.flatMap((m) => Object.values(m.headers ?? {}))]) {
if (value !== undefined) resolveCustomValue(value, () => "validation");
}
const ids = new Set<string>();
const models = definition.models.map((model): Model<Api> => {
if (ids.has(model.id)) invalid("Duplicate model IDs are not supported within a custom provider.");
ids.add(model.id);
const api = model.api ?? definition.api;
if (!api || !supportedApis.has(api)) invalid("A custom model specifies a missing or unsupported Pi API.");
const rates = [model.cost, ...(model.cost.tiers ?? [])];
if (![model.contextWindow, model.maxTokens].every(Number.isFinite) || rates.some((rate) => Object.values(rate).some((n) => typeof n === "number" && (!Number.isFinite(n) || n < 0)))) invalid("Custom model limits and costs must be finite; limits must be positive and costs nonnegative.");
return {
...model,
provider: id,
api,
baseUrl: endpoint(model.baseUrl ?? definition.baseUrl),
name: model.name ?? model.id,
reasoning: model.reasoning ?? false,
input: model.input ?? ["text"],
compat: compat(definition.compat, model.compat),
};
});
return { id, definition, models };
});
}
+90
View File
@@ -0,0 +1,90 @@
/** Configuration composition only. Pi's Models collection is the one runtime
* registry. A failed reload removes custom routes, never alters built-ins. */
import type { MutableModels } from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import { createCustomProvider, customProviderApis, customProviderAuth } from "../agent/custom-providers.ts";
import { configureProviderSource } from "../agent/models.ts";
import { parseCustomProviders, resolveCustomValue, CustomProviderConfigError, type CustomProviderConfig } from "../core/custom-providers.ts";
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
import { readCustomProvidersFile } from "../store/custom-providers.ts";
const reserved = new Set([...builtinProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]);
/** Instance-local so every Next server module graph registers into its own Pi
* collection. No process-global registry or personal Pi config is consulted. */
export class CustomProviderSource {
private text?: string;
private fingerprint?: string;
private configs: CustomProviderConfig[] = [];
private registered = new Set<string>();
private ready = new Map<string, boolean>();
private error?: string;
constructor(
private readonly read: typeof readCustomProvidersFile,
private readonly key: (name: string) => string | undefined,
) {}
sync(models: MutableModels): void {
try {
const record = this.read();
if (record.error) throw new CustomProviderConfigError(record.error);
if (record.text !== this.text || this.error) {
this.configs = parseCustomProviders(record.text!, reserved, customProviderApis);
this.text = record.text;
this.fingerprint = undefined;
}
// Resolve only referenced variables through the app's existing key seam.
// Changes in credentials/headers trigger a new immutable provider snapshot
// even when the configuration file itself has not changed.
const values = new Map<string, string | undefined>();
for (const config of this.configs) {
for (const template of [config.definition.apiKey, ...Object.values(config.definition.headers ?? {}), ...config.models.flatMap((m) => Object.values(m.headers ?? {}))]) {
if (template !== undefined) resolveCustomValue(template, (name) => {
const value = this.key(name);
values.set(name, value);
return value;
});
}
}
const fingerprint = JSON.stringify([...values]);
if (this.fingerprint === fingerprint) return;
const lookup = (name: string) => values.get(name);
const providers = this.configs.map((config) => createCustomProvider(config, lookup));
const ready = new Map(this.configs.map((config) => [config.id, customProviderAuth(config, lookup) !== undefined]));
// Everything validates/builds before the synchronous publication begins.
for (const id of this.registered) models.deleteProvider(id);
this.registered = new Set(providers.map((p) => p.id));
for (const provider of providers) models.setProvider(provider);
this.ready = ready;
this.fingerprint = fingerprint;
this.error = undefined;
} catch (error) {
for (const id of this.registered) models.deleteProvider(id);
this.registered.clear();
this.ready.clear();
this.fingerprint = undefined;
this.error = error instanceof CustomProviderConfigError ? error.message : "Unable to configure custom providers from models.json.";
}
}
configured(provider: string): boolean | undefined { return this.ready.get(provider); }
providers(): { id: string; envVar: string; keyPresent: boolean }[] {
return [...this.ready].map(([id, keyPresent]) => ({ id, envVar: "", keyPresent }));
}
getError(): string | undefined { return this.error; }
}
let source: CustomProviderSource | undefined;
export function installCustomProviderSource(key: (name: string) => string | undefined): void {
const next = new CustomProviderSource(readCustomProvidersFile, key);
configureProviderSource({
sync: (models) => next.sync(models),
isCustom: (id) => next.configured(id) !== undefined,
});
source = next;
}
/** Call piModels() before these reads to synchronize the snapshot. */
export function customProviderRows() { return source?.providers() ?? []; }
export function customProviderConfigured(provider: string): boolean | undefined { return source?.configured(provider); }
export function customProvidersError(): string | undefined { return source?.getError(); }
+6
View File
@@ -53,6 +53,8 @@ import {
} from "../core/auth-keys.ts";
import { authStore } from "../store/auth.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
import { piModels } from "../agent/models.ts";
import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.ts";
// The once-per-process boot record — globalThis anchor (HMR re-imports the
// module; the snapshot must not re-run and misread fill-gapped vars as real
@@ -201,6 +203,9 @@ export function keyValue(id: string): string | undefined {
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
* no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean {
piModels(); // Synchronize hand-edited custom configuration before admission.
const custom = customProviderConfigured(provider);
if (custom !== undefined) return custom;
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
@@ -244,3 +249,4 @@ export function deleteKey(id: string): AuthKeyStatus {
// Reflect the file into the env at first import (idempotent; the module is
// imported by every route/handler path that touches keys).
initAuthEnv();
installCustomProviderSource(keyValue);
+4
View File
@@ -20,6 +20,7 @@
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { piModels } from "../agent/models.ts";
import { customProviderRows, customProvidersError } from "./custom-providers.ts";
import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.ts";
@@ -135,6 +136,7 @@ export function catalogSlots(): SlotPins {
* with keyPresent read env-wins through the keys seam, fresh per call —
* saving a key immediately federates the provider into the picker. */
export function catalogProviders(): CatalogProvider[] {
piModels();
return AUTH_PROVIDER_KEYS.map((p) => ({
id: p.id,
envVar: p.envVar,
@@ -142,6 +144,7 @@ export function catalogProviders(): CatalogProvider[] {
})).concat([
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
...customProviderRows(),
]).sort((a, b) => a.id.localeCompare(b.id));
}
@@ -211,6 +214,7 @@ export function modelCatalog(): ModelCatalogResponse {
rolePins: catalogRolePins(),
slots: catalogSlots(),
providers: catalogProviders(),
...(customProvidersError() ? { customProvidersError: customProvidersError() } : {}),
};
}
+12
View File
@@ -0,0 +1,12 @@
/** Read-only adapter. Configuration editing/UI is deliberately a later phase. */
import fs from "node:fs";
import { CUSTOM_MODELS_FILE } from "../../config.ts";
export function readCustomProvidersFile(): { text?: string; error?: string } {
try { return { text: fs.readFileSync(CUSTOM_MODELS_FILE, "utf8") }; }
catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT"
? { text: '{"providers":{}}' }
: { error: "Unable to read models.json." };
}
}