Add declarative custom providers through native Pi AI
This commit is contained in:
@@ -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(); }
|
||||
@@ -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);
|
||||
|
||||
@@ -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() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user