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