feat: custom OpenAI-compatible endpoint as a first-class provider (vision + context from /models)

This commit is contained in:
Daniel Buckner
2026-10-27 15:48:16 -07:00
parent 8f6fd7cd29
commit 07b6403d94
15 changed files with 965 additions and 41 deletions
+18
View File
@@ -122,6 +122,24 @@ export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [
{ id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" },
] as const;
/**
* THE SINGLE CUSTOM ENDPOINT's identity (custom-endpoint wave): a
* user-configured OpenAI-compatible server registered into pi-ai at runtime
* (server/custom-provider.ts). Deliberately NOT part of AUTH_PROVIDER_KEYS —
* that table is test-pinned to pi-ai's BUILT-IN providers
* (tests/auth-provider-keys.test.ts) and every row is env-var-keyed; the
* custom endpoint is keyed by a user-supplied BASE URL instead, and its API
* key rides this one reserved env var (OPTIONAL — keyless local servers such
* as Ollama / LM Studio are allowed). The Custom endpoint Settings section is
* its one home; the key is stored in auth.json under `envVar` like any other
* provider key (masked, env-reflected, no restart).
*/
export const CUSTOM_PROVIDER = {
id: "custom",
envVar: "CUSTOM_OPENAI_API_KEY",
label: "Custom endpoint",
} as const;
/** Every distinct env var a model-provider key rides in (the write side's
* writability check — see isProviderEnvVar below; also drives which
* PRESENT provider vars surface as status rows). */
+10
View File
@@ -37,6 +37,7 @@ import {
type GeneralSettings,
type DictationSettings,
type CompactionSettings,
type CustomModel,
type GraphSettings,
type ModelSlot,
type ModelSlotName,
@@ -95,6 +96,11 @@ export interface EffectiveSettings {
/** Compaction (owner round 14): resolved concrete — absent record
* section resolves to the factory 150k. */
compaction: Required<CompactionSettings>;
/** The single custom OpenAI-compatible endpoint (custom-endpoint wave):
* resolved concrete — baseUrl defaults "", models default []. Per-model
* defaults (context window, cost) apply at provider-build time in the
* server (custom-provider.ts), not here. */
customProvider: { baseUrl: string; models: CustomModel[] };
}
/** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own
@@ -221,6 +227,10 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
compaction: {
thresholdTokens: record?.compaction?.thresholdTokens ?? FACTORY_SETTINGS.compaction.thresholdTokens,
},
customProvider: {
baseUrl: record?.customProvider?.baseUrl ?? "",
models: record?.customProvider?.models ?? [],
},
};
}
+120
View File
@@ -206,6 +206,83 @@ export interface ModelsSettings {
vision?: ModelSlot;
}
// ─── Custom OpenAI-compatible endpoint (custom-endpoint wave) ────────────────
// ONE user-configured endpoint speaking the OpenAI /chat/completions API
// (self-hosted vLLM / Ollama / llama.cpp, a corporate proxy). It is NOT a
// special concept: it is one more provider in the Keys list, connected by a
// base URL + (optional) key exactly as the built-ins are connected by a key.
// NON-SECRET config only — base URL + the model ids discovered from it. The
// API key is a SECRET and lives in the auth store under CUSTOM_PROVIDER.envVar
// (core/auth-keys.ts), never in this record, exactly like every other key. The
// endpoint is registered into pi-ai as a normal provider
// (server/custom-provider.ts) so its models fill the same slots/roles.
/** Default facts for a custom model. The endpoint's /models reports ids (and
* sometimes modality); these fill the rest: self-hosted endpoints are free. */
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
/** One discovered custom model. `image` marks it as accepting image input so
* it is selectable for the Vision slot (PDFs, image tasks). Discovery sets it
* from the endpoint's /models metadata when reported; the Keys row exposes a
* checkbox for endpoints that don't report it. `context` / `maxTokens` carry
* the endpoint's reported token limits (max_input_tokens / max_output_tokens).
* Anything the endpoint omits (cost, and any unreported window) lands on the
* CUSTOM_MODEL_DEFAULT_* values at provider-build time. */
export interface CustomModel {
id: string;
/** Accepts image input (Vision-capable). Default false (text-only). */
image?: boolean;
/** Context window in tokens, from the endpoint. Absent → the default. */
context?: number;
/** Max output tokens, from the endpoint. Absent → the default. */
maxTokens?: number;
}
/** The single custom endpoint's stored config (delta section). `models` are
* discovered from the endpoint's /models on connect; the only user-editable
* fact per model is the `image` capability toggle. */
export interface CustomProviderSettings {
/** Base URL of the OpenAI-compatible server, NORMALIZED (see
* normalizeBaseUrl): no trailing slash, absolute http(s). Empty/absent =
* not configured. */
baseUrl?: string;
/** Models discovered from the endpoint (refreshed on connect), each with
* its image-capability flag. */
models?: CustomModel[];
}
/** THE one place the base-URL canonical form is defined (single source — the
* sanitizer stores the normalized value, so no consumer re-derives it and
* the trailing-slash edge case is defined out of existence). Trims; parses
* as an absolute URL (scheme + host lowercased by the parser); drops any
* trailing slashes (the OpenAI SDK appends /chat/completions itself).
* Returns "" for anything that is not a valid absolute http(s) URL — callers
* treat "" as "not configured". */
export function normalizeBaseUrl(raw: string): string {
const value = raw.trim();
if (!value) return "";
let url: URL;
try {
url = new URL(value);
} catch {
return "";
}
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
return url.toString().replace(/\/+$/, "");
}
/** THE single source of "is the custom endpoint usable?" (custom-endpoint
* wave): a non-empty base URL. The API key is OPTIONAL (keyless local
* servers), so key presence is deliberately NOT part of this — the picker
* federation, the sync, and the roster all consult this one predicate so the
* rule can never drift between them. */
export function customProviderUsable(
cp: CustomProviderSettings | undefined,
): cp is CustomProviderSettings & { baseUrl: string } {
return Boolean(cp?.baseUrl);
}
/** First-run gate (model-slots wave, owner 2026-09-20): the app refuses
* to start chats until the user has actively picked all three slots —
* there ARE no defaults for 33 providers; a configured app is one whose
@@ -371,6 +448,9 @@ export interface UserSettings {
agent?: AgentSettings;
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
models?: ModelsSettings;
/** The single custom OpenAI-compatible endpoint (custom-endpoint wave).
* Non-secret config only; the API key lives in the auth store. */
customProvider?: CustomProviderSettings;
appearance?: AppearanceSettings;
graph?: GraphSettings;
compaction?: CompactionSettings;
@@ -497,6 +577,43 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
return out;
}
/** The customProvider section: a normalized base URL (invalid → dropped, i.e.
* treated as unset) and the discovered models (each `{ id, image? }`; ids
* trimmed, empties and duplicate ids dropped, `image` kept only when true;
* a non-array drops the whole list). Clamp-never-reject, like every
* sanitizer here. */
/** A positive integer, or undefined — keeps only meaningful token counts. */
function positiveInt(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings {
const out: CustomProviderSettings = {};
if (typeof raw.baseUrl === "string") {
const base = normalizeBaseUrl(raw.baseUrl);
if (base) out.baseUrl = base;
}
if (Array.isArray(raw.models)) {
const byId = new Map<string, CustomModel>();
for (const m of raw.models) {
if (typeof m !== "object" || m === null) continue;
const rec = m as Record<string, unknown>;
if (typeof rec.id !== "string") continue;
const id = rec.id.trim();
if (!id) continue;
const model: CustomModel = { id };
if (rec.image === true) model.image = true;
const context = positiveInt(rec.context);
if (context) model.context = context;
const maxTokens = positiveInt(rec.maxTokens);
if (maxTokens) model.maxTokens = maxTokens;
byId.set(id, model);
}
out.models = [...byId.values()];
}
return out;
}
function sanitizeBooleanMap(raw: unknown): Record<string, boolean> | undefined {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const out: Record<string, boolean> = {};
@@ -641,6 +758,9 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
const models = sanitizeModels(r.models as Record<string, unknown>);
if (Object.keys(models).length) out.models = models;
}
if (r.customProvider !== null && typeof r.customProvider === "object" && !Array.isArray(r.customProvider)) {
out.customProvider = sanitizeCustomProvider(r.customProvider as Record<string, unknown>);
}
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
+232
View File
@@ -0,0 +1,232 @@
/**
* THE CUSTOM OPENAI-COMPATIBLE ENDPOINT (custom-endpoint wave): the deep
* module that turns the user's non-secret config (base URL + discovered model
* ids, core/settings.ts `customProvider`) into a real pi-ai provider registered
* in the process-wide `piModels()` collection — so its models fill the same
* strong/cheap/vision slots and background roles as any built-in.
*
* WHY A MODULE OWNS THIS (information hiding): the fact "a custom endpoint is
* an OpenAI-completions provider at a user URL with an optional key" would
* otherwise leak into the catalog, the keys seam, and the settings route. It
* lives here instead; those surfaces consult the pure `customProviderUsable`
* predicate (core) and call `syncCustomProvider`. The single-endpoint scope is
* special-purpose, but the builder is written general (`buildOpenAiCompatible
* Provider`) so a future multi-endpoint wave reuses it unchanged.
*
* LIFECYCLE (no restart, mirroring the keys seam): `syncCustomProvider` is
* idempotent and cheap — called at boot (stores.ts, the composition root) and
* after every connect (POST /api/custom-provider). It replaces
* or removes the provider in `piModels()` by id. The base URL is fixed at
* provider construction, so a base-URL change is a rebuild — which is exactly
* what a re-sync does.
*
* AUTH: pi-ai resolves a provider's key at request time. We read the reserved
* env var through the keys seam (`keyValue` — env-wins, file fallback, honors
* the fresh-user switch), and fall back to a placeholder when none is set so
* KEYLESS local servers (Ollama / LM Studio / llama.cpp) work. The placeholder
* is an internal detail of `resolve()` — no caller ever sees it.
*/
import { createProvider, type ApiKeyAuth, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
import { piModels } from "../agent/models.ts";
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
import {
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
customProviderUsable,
normalizeBaseUrl,
type CustomModel,
type CustomProviderSettings,
} from "../core/settings.ts";
import { keyValue } from "./keys.ts";
/** Placeholder bearer sent to keyless endpoints. Many local OpenAI-compatible
* servers ignore the Authorization header entirely; the OpenAI client still
* requires *a* key, so we hand it this. Never shown to the user. */
const KEYLESS_PLACEHOLDER = "not-needed";
/** Narrow an unknown to a plain object (not null, not an array). */
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** The custom endpoint's auth: resolve the reserved key through the keys
* seam, else the keyless placeholder. `resolve` is the only method pi-ai
* needs to treat the provider as configured. */
const customEndpointAuth: ApiKeyAuth = {
name: `${CUSTOM_PROVIDER.label} API key`,
resolve: async () => {
const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim();
// Keyless local servers ignore the header; the OpenAI client still
// needs *a* key, so hand it the placeholder.
return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" };
},
};
/** Map one discovered custom model to a pi-ai chat model entry. The endpoint's
* /models reports ids plus whatever metadata it exposes; `image` sets `input`
* so a Vision-capable model is selectable for the Vision slot (PDFs, images),
* and `context` / `maxTokens` carry the endpoint's reported token limits.
* Anything the endpoint omits falls back to the CUSTOM_MODEL_DEFAULT_* values
* (self-hosted = free, modest window). `baseUrl` is stamped per-model (pi reads
* it from the model, not the provider). */
function toPiModel(model: CustomModel, baseUrl: string): Model<Api> {
return {
id: model.id,
name: model.id,
api: "openai-completions",
provider: CUSTOM_PROVIDER.id,
baseUrl,
reasoning: false,
input: model.image ? ["text", "image"] : ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
maxTokens: model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
type: "chat",
};
}
/** Build a general OpenAI-compatible provider from parts (single-use today,
* general by design). `baseUrl` must already be normalized. */
export function buildOpenAiCompatibleProvider(input: {
baseUrl: string;
models: readonly CustomModel[];
}): Provider<Api> {
return createProvider<Api>({
id: CUSTOM_PROVIDER.id,
name: CUSTOM_PROVIDER.label,
baseUrl: input.baseUrl,
auth: { apiKey: customEndpointAuth },
models: input.models.map((m) => toPiModel(m, input.baseUrl)),
api: openAICompletionsApi(),
});
}
/**
* Re-sync the custom provider into `piModels()` from the resolved section
* (idempotent). Usable → (re)build and `setProvider` (replaces by id, so a
* base-URL/model change lands with no restart). Not usable → `deleteProvider`
* so it stops federating and any slot pinning one of its models falls back to
* the honest models_not_configured refusal. Never throws: a bad config simply
* leaves the provider absent.
*/
export function syncCustomProvider(cp: CustomProviderSettings | undefined): void {
const models = piModels();
if (!customProviderUsable(cp)) {
models.deleteProvider(CUSTOM_PROVIDER.id);
return;
}
// `customProviderUsable` narrowed cp to a defined section with a baseUrl.
models.setProvider(
buildOpenAiCompatibleProvider({ baseUrl: normalizeBaseUrl(cp.baseUrl), models: cp.models ?? [] }),
);
}
/** Outcome of a model-discovery probe. */
export type DiscoverResult = { ok: true; models: CustomModel[] } | { ok: false; error: string };
/**
* Probe an endpoint's `GET {baseUrl}/models` (the OpenAI list-models route)
* and return the model ids. The scheme is validated via normalizeBaseUrl
* (http/https only — the SSRF guard: this is a local app, but a user paste of
* `file://` or a stray string must never reach fetch). The key rides as a
* bearer when one is configured; keyless endpoints answer without it. A
* non-2xx or unparseable body is an honest error, never a silent empty list.
*/
export async function discoverModels(baseUrl: string, apiKey?: string): Promise<DiscoverResult> {
const base = normalizeBaseUrl(baseUrl);
if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" };
const key = apiKey?.trim() || keyValue(CUSTOM_PROVIDER.envVar)?.trim();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
const res = await fetch(`${base}/models`, {
headers: key ? { authorization: `Bearer ${key}` } : {},
signal: controller.signal,
});
if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}` };
const body = (await res.json().catch(() => null)) as unknown;
const list = extractModels(body);
if (list === null) return { ok: false, error: "Endpoint response was not a model list" };
return { ok: true, models: list };
} catch (err) {
const msg = err instanceof Error ? (err.name === "AbortError" ? "Endpoint timed out" : err.message) : "Request failed";
return { ok: false, error: msg };
} finally {
clearTimeout(timer);
}
}
/** The model rows in a list response, or null when the shape is unrecognized.
* Accepts the OpenAI `{data:[…]}`, the Ollama-style `{models:[…]}`, or a
* bare array. */
function modelRows(body: unknown): unknown[] | null {
if (Array.isArray(body)) return body;
if (isRecord(body)) {
if (Array.isArray(body.data)) return body.data;
if (Array.isArray(body.models)) return body.models;
}
return null;
}
/** Does a /models row advertise image input? Endpoints are inconsistent, so
* scan the common modality/capability fields (OpenRouter's
* `architecture.input_modalities`, plus `input_modalities` / `modalities` /
* `supported_modalities` / `capabilities`) for a value mentioning image or
* vision. Absent/unrecognized -> false (text-only); the Keys row exposes a
* manual toggle for endpoints that report nothing. */
function detectImage(row: Record<string, unknown>): boolean {
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
const candidates = [
architecture?.input_modalities,
row.input_modalities,
row.modalities,
row.supported_modalities,
row.capabilities,
];
for (const c of candidates) {
if (Array.isArray(c) && c.some((v) => typeof v === "string" && /image|vision/i.test(v))) return true;
}
return false;
}
/** First positive integer among `keys` on a /models row, else undefined.
* Endpoints name the token limits inconsistently, so the caller passes an
* ordered list of candidate field names. */
function firstPositive(row: Record<string, unknown>, keys: readonly string[]): number | undefined {
for (const key of keys) {
const value = row[key];
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
}
return undefined;
}
// Field names seen in the wild for each limit (first present wins).
const CONTEXT_KEYS = ["max_input_tokens", "context_length", "context_window", "max_context", "n_ctx", "max_seq_len", "max_position_embeddings"] as const;
const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
/** Pull models out of a list response (entries are `{id, ...}` objects or bare
* strings), each carrying its detected image capability and token limits.
* Returns null when the shape is unrecognized - the caller reports an honest
* error rather than a misleading empty "no models". */
function extractModels(body: unknown): CustomModel[] | null {
const rows = modelRows(body);
if (rows === null) return null;
const byId = new Map<string, CustomModel>();
for (const row of rows) {
if (typeof row === "string") {
if (row.trim()) byId.set(row, { id: row });
continue;
}
if (isRecord(row) && typeof row.id === "string" && row.id.trim()) {
const model: CustomModel = { id: row.id };
if (detectImage(row)) model.image = true;
const context = firstPositive(row, CONTEXT_KEYS);
if (context) model.context = context;
const maxTokens = firstPositive(row, MAX_TOKEN_KEYS);
if (maxTokens) model.maxTokens = maxTokens;
byId.set(row.id, model);
}
}
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
}
+4
View File
@@ -45,3 +45,7 @@ export * from "./observability.ts";
export * from "./model-catalog.ts";
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
export { sessionCost } from "./pricing.ts";
// The custom OpenAI-compatible endpoint (custom-endpoint wave): the deep
// module that registers the user's endpoint into piModels() and probes it for
// models. Importing it has no side effects; the boot sync lives in stores.ts.
export * from "./custom-provider.ts";
+10 -4
View File
@@ -43,6 +43,7 @@ import {
AUTH_KEYS,
AUTH_PROVIDER_KEYS,
AUTH_PROVIDER_ENV_VARS,
CUSTOM_PROVIDER,
type AuthKeyInfo,
type AuthKeyStatus,
genericProviderKeyGuard,
@@ -72,10 +73,12 @@ function boot(): AuthBoot {
return g[BOOT_KEY];
}
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
* var (provider-keys interlude: PUT/DELETE accept both). */
/** Is `id` a WRITABLE key name — curated catalog, model-provider env
* var (provider-keys interlude), or the custom endpoint's reserved key
* (custom-endpoint wave: writable through the same seam, generic shape
* guard, but NOT part of the test-pinned AUTH_PROVIDER_KEYS table). */
export function isWritableAuthKey(id: string): boolean {
return isKnownAuthKey(id) || isProviderEnvVar(id);
return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar;
}
/** The settings page's provider dropdown: every pi-ai provider that takes
@@ -213,7 +216,10 @@ export function providerKeyPresent(provider: string): boolean {
* reports shadowedByEnv so the page can explain it).
*/
export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } {
const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value);
const shape =
isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar
? genericProviderKeyGuard(id, value)
: validateKeyShape(id, value);
if (!shape.ok) return { ok: false, error: shape.error };
const trimmed = value.trim();
authStore.setAuthVar(id, trimmed);
+11
View File
@@ -48,6 +48,8 @@ import type {
} from "../core/contracts/models.ts";
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
import { customProviderUsable } from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
@@ -142,6 +144,15 @@ export function catalogProviders(): CatalogProvider[] {
})).concat([
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
// The custom endpoint (custom-endpoint wave): a row only when usable (a
// base URL is set). Its key is OPTIONAL (keyless local servers), so
// keyPresent here reflects USABILITY, not key presence — the picker
// federates exactly the usable providers. `customProviderUsable` is the
// single source of that rule, so this row and the sync in custom-provider.ts
// can never disagree.
...(customProviderUsable(effectiveSettings().customProvider)
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }]
: []),
]).sort((a, b) => a.id.localeCompare(b.id));
}
+3 -1
View File
@@ -387,7 +387,9 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<Di
}
/** The section→parser map for PUT /api/settings/[section] (the route also
* carries the special prompts sub-resource). */
* carries the special prompts sub-resource). The custom endpoint is NOT a
* section here — it is one more provider in the Keys list, connected through
* POST /api/custom-provider (base URL + key + discovery in one action). */
export const SETTINGS_SECTIONS = {
general: parseGeneralSettingsBody,
chat: parseSettingsBody,
+7
View File
@@ -11,6 +11,7 @@ import { graphStore } from "../store/graph.ts";
import { jobStore } from "../store/jobs.ts";
import { settingsStore } from "../store/settings.ts";
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
import { syncCustomProvider } from "./custom-provider.ts";
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
export const stores = {
chatStore,
@@ -32,3 +33,9 @@ export type Stores = typeof stores;
export function effectiveSettings(): EffectiveSettings {
return resolveSettings(settingsStore.readSettings());
}
// Boot: register the custom OpenAI-compatible endpoint (when configured) into
// the process-wide piModels() so its models federate with no restart — the
// exact lifecycle the keys seam runs for keys (initAuthEnv at import). The
// POST /api/custom-provider route re-syncs after every connect.
syncCustomProvider(effectiveSettings().customProvider);