feat: finish donor-led custom endpoint integration

This commit is contained in:
amosblomqvist
2026-10-29 04:43:53 +02:00
parent 80662d3d3b
commit b48821309b
33 changed files with 1116 additions and 1117 deletions
+43
View File
@@ -0,0 +1,43 @@
/** Custom-only credential composition. File credentials and their authorization
* are read from the same atomic record, never a stale fill-gapped env copy. */
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
import { authEnvRecord } from "../core/auth-env.ts";
import { customBindingMatches, type CustomAuthBinding } from "../core/custom-provider-auth.ts";
import type { CustomProviderSettings } from "../core/settings.ts";
import { readCustomAuth, writeCustomAuth } from "../store/auth.ts";
export interface CustomAuthState {
readable: boolean;
binding?: CustomAuthBinding;
key?: string;
fileKey?: string;
source: "env" | "file" | "none";
}
/** requiredConfig prevents even credential resolution until startup/runtime
* authorization matches. Explicit UI inspection/probe authorization omits it. */
export function customAuthState(inspectKey = false, requiredConfig?: CustomProviderSettings): CustomAuthState {
try {
const snapshot = readCustomAuth();
const result: CustomAuthState = { readable: true, binding: snapshot.binding, source: "none" };
if (!inspectKey || (requiredConfig && !customBindingMatches(requiredConfig, snapshot.binding))) return result;
result.fileKey = snapshot.fileKey();
const record = authEnvRecord();
const envKey = process.env.LH_IGNORE_ENV_KEYS !== "1" && record.realEnv.has(CUSTOM_PROVIDER.envVar) ? process.env[CUSTOM_PROVIDER.envVar] : undefined;
result.source = envKey !== undefined ? "env" : result.fileKey !== undefined ? "file" : "none";
result.key = (envKey ?? result.fileKey)?.trim() || undefined;
return result;
} catch { return { readable: false, source: "none" }; }
}
export function customKeyAuthorized(cp: CustomProviderSettings | undefined, state: CustomAuthState): boolean {
return cp?.authMode === "apiKey" && state.readable && customBindingMatches(cp, state.binding) && Boolean(state.key) && (state.source === "env" || state.binding?.fileKeyBaseUrl === cp?.baseUrl);
}
/** Reflect only after the atomic pair is committed. Runtime file resolution
* does not rely on this compatibility mirror, including after a failed write. */
export function persistCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void {
writeCustomAuth(binding, fileKey);
const record = authEnvRecord();
if (!record.realEnv.has(CUSTOM_PROVIDER.envVar)) {
if (fileKey === undefined) { delete process.env[CUSTOM_PROVIDER.envVar]; record.fillGap.delete(CUSTOM_PROVIDER.envVar); }
else { process.env[CUSTOM_PROVIDER.envVar] = fileKey; record.fillGap.add(CUSTOM_PROVIDER.envVar); }
}
}
+31
View File
@@ -0,0 +1,31 @@
/** Byte-bounded JSON for the custom endpoint's request/discovery surfaces. */
export class CustomBodyLimitError extends Error {}
export async function readBoundedJson(message: Request | Response, limit: number, allowEmptyObject = false): Promise<unknown> {
if (Number(message.headers.get("content-length")) > limit) {
await message.body?.cancel();
throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit");
}
const reader = message.body?.getReader();
if (!reader) {
if (allowEmptyObject) return {};
throw new SyntaxError("Missing JSON body");
}
const decoder = new TextDecoder();
let text = "";
let bytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > limit) throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit");
text += decoder.decode(value, { stream: true });
}
if (bytes === 0 && allowEmptyObject) return {};
return JSON.parse(text + decoder.decode());
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}
+149
View File
@@ -0,0 +1,149 @@
/** PR #62's connect/manual-model/offline-pin workflow, with host request,
* credential-binding and optimistic-write boundaries. No secret reaches GET. */
import { createHmac, randomBytes } from "node:crypto";
import { normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides, type CustomProviderSettings } from "../core/settings.ts";
import { readSettings, writeSettings } from "../store/settings.ts";
import { readCustomAuth } from "../store/auth.ts";
import { customAuthState, customKeyAuthorized, persistCustomAuth } from "./custom-provider-auth.ts";
import type { CustomAuthBinding } from "../core/custom-provider-auth.ts";
import { desktopAPIAuthorized } from "./desktop-access.ts";
import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
import { discoverModels, mergeManualModels, syncCustomProvider } from "./custom-provider.ts";
import { parseCustomModelOverridesBody } from "./settings-body.ts";
import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts";
const revisionKey = Symbol.for("learnharness.custom-provider-revision");
const globals = globalThis as typeof globalThis & { [revisionKey]?: Buffer };
const secret = globals[revisionKey] ??= randomBytes(32);
const failure = (error: string, status = 400) => Response.json({ error }, { status });
const stale = () => failure("Custom configuration or credentials changed. Reload configuration and retry.", 409);
function snapshot(inspectKey = false) {
const record = readSettings() ?? { version: 1 };
const cp = record.customProvider;
// Keyless never looks up a key for authentication or revision generation.
// An explicit API-key inspection is used only after the user selects that mode.
const auth = customAuthState(inspectKey);
const key = auth.key;
// Public revisions contain configuration only, never credential material.
// Effective credentials are compared privately across the awaited probe.
const etag = `"${createHmac("sha256", secret).update(JSON.stringify([cp ?? null, auth.binding ?? null, auth.readable])).digest("base64url")}"`;
return { record, cp, auth, key, etag };
}
function response(body: object) {
return Response.json(body, { headers: { etag: snapshot().etag, "cache-control": "no-store" } });
}
export function customProviderGet(request: Request): Response {
const inspect = new URL(request.url).searchParams.get("authMode") === "apiKey";
const current = snapshot();
const s = snapshot(inspect || current.cp?.authMode === "apiKey");
return response({
baseUrl: s.cp?.baseUrl ?? "", authMode: s.cp?.authMode ?? "keyless",
models: s.cp?.models ?? [], overrides: s.cp?.overrides ?? {}, manualModels: s.cp?.manualModels ?? [],
keyPresent: Boolean(s.key), keySource: s.auth.source,
keyReuseRequired: Boolean(s.key) && !customKeyAuthorized(s.cp, s.auth),
});
}
function authorized(request: Request): boolean {
const desktop = process.env.LH_DESKTOP_SERVER === "1" || Boolean(process.env.LH_DESKTOP_API_SECRET);
if (desktop) return desktopAPIAuthorized(request.headers);
const origin = request.headers.get("origin");
if (!origin || origin === "null") return false;
try {
// Next's standalone Request URL can use its internal hostname. Match
// the actual addressed Host through the app's existing origin seam;
// never trust forwarded-origin headers.
return origin === subscriptionRequestOrigin(request)
&& (!request.headers.has("sec-fetch-site") || request.headers.get("sec-fetch-site") === "same-origin");
} catch { return false; }
}
/** Auth pair FIRST, matching config nonce SECOND. Any interrupted/failed
* cross-file publication is durably unusable, including with a real env key.
* Best-effort rollback restores the entire old pair, never just its secret. */
function commit(cp: CustomProviderSettings | undefined, binding?: CustomAuthBinding, fileKey?: string): void {
const latest = readSettings() ?? { version: 1 };
const oldAuth = binding ? readCustomAuth() : undefined;
let published = false;
try {
if (binding) { persistCustomAuth(binding, fileKey); published = true; }
const { customProvider: _old, ...rest } = latest;
writeSettings(cp ? { ...rest, customProvider: cp } : rest);
} catch {
if (published && oldAuth) {
try { persistCustomAuth(oldAuth.binding, oldAuth.fileKey()); } catch { /* durable nonce mismatch fails closed after restart */ }
}
throw new Error("Unable to save custom configuration. Reload configuration and retry.");
}
syncCustomProvider();
}
export async function customProviderMutation(request: Request): Promise<Response> {
if (!authorized(request)) return failure("Custom configuration requires a trusted same-origin request.", 403);
if (request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") return failure("JSON content-type is required.", 415);
try {
// Next can represent an empty DELETE as a zero-byte stream rather
// than null. Bound it either way; only DELETE permits an empty body.
const body = await readBoundedJson(request, 64 * 1024, request.method === "DELETE");
if (!body || typeof body !== "object" || Array.isArray(body)) return failure("Invalid JSON body");
const raw = body as Record<string, unknown>;
const before = snapshot(raw.authMode === "apiKey");
if (!before.auth.readable) return failure("Custom authorization storage is unavailable. No changes were saved.", 500);
if (request.headers.get("if-match") !== before.etag) return stale();
if (request.method === "DELETE") {
commit(undefined, { version: 1, id: randomBytes(16).toString("hex"), authMode: "disabled" });
return response({ ok: true });
}
if (request.method === "PATCH") {
const parsed = parseCustomModelOverridesBody(raw);
if (!parsed.ok) return failure(parsed.error);
if (!before.cp?.baseUrl) return failure("No custom endpoint configured", 409);
const overrides = sanitizeCustomModelOverrides(parsed.settings, before.cp.models?.map((m) => m.id));
commit({ ...before.cp, overrides });
return response({ ok: true, overrides });
}
if (request.method !== "POST") return failure("Unsupported method", 405);
const base = normalizeBaseUrl(typeof raw.baseUrl === "string" ? raw.baseUrl : "");
if (!base) return failure("Base URL must be http(s), with no credentials, query or fragment.");
if (raw.authMode !== "keyless" && raw.authMode !== "apiKey") return failure("Choose Keyless or API key authentication.");
const authMode = raw.authMode;
if (raw.key !== undefined && typeof raw.key !== "string") return failure("API key must be a string.");
const supplied = typeof raw.key === "string" ? raw.key.trim() : undefined;
if (authMode === "apiKey" && supplied === "") return failure("Choose Keyless to clear authentication.");
const envOwned = before.auth.source === "env";
if (envOwned && supplied) return failure("Environment owns the effective key. Change the environment or explicitly reuse that key.");
// A shadowed file key is not the key that will run. Require consent for
// the actual environment key when authorizing a different endpoint.
const hasNewEffectiveKey = Boolean(supplied) && !envOwned;
const changedTarget = before.cp?.baseUrl !== base;
if (authMode === "apiKey" && (changedTarget || !customKeyAuthorized(before.cp, before.auth)) && !hasNewEffectiveKey && raw.reuseKey !== true) {
return failure("Enter a new key or explicitly allow reuse of the effective existing key for this URL.");
}
const effectiveKey = authMode === "apiKey" ? (envOwned ? before.key : supplied || before.key) : undefined;
if (authMode === "apiKey" && !effectiveKey) return failure("API key mode requires an effective key.");
const manualModels = parseModelIds(raw.manualModels);
if (manualModels.length > 10_000) return failure("Model list exceeds 10000 entries");
const found = await discoverModels(base, effectiveKey);
if (!found.ok && (found.fatal || found.status === 401 || found.status === 403)) return failure(found.error, 502);
if (!found.ok && !manualModels.length) return failure(`${found.error}. If this endpoint has no /models list, add model IDs manually.`, 502);
// Discovery yields; both the custom record and the credential must still
// match. Unrelated settings edits are preserved by commit's fresh merge.
const now = snapshot(authMode === "apiKey");
if (before.etag !== now.etag || JSON.stringify(before.auth) !== JSON.stringify(now.auth)) return stale();
const discovered = found.ok ? found.models : !changedTarget ? before.cp?.models ?? [] : [];
const models = mergeManualModels(discovered, manualModels);
if (models.length > 10_000) return failure("Model list exceeds 10000 entries");
const overrides = sanitizeCustomModelOverrides(!changedTarget ? before.cp?.overrides : undefined, models.map((m) => m.id));
const id = randomBytes(16).toString("hex");
const fileKey = authMode === "keyless" ? undefined : supplied && !envOwned ? supplied : now.auth.fileKey;
const fileKeyBaseUrl = authMode === "apiKey" ? envOwned ? now.auth.binding?.fileKeyBaseUrl : base : undefined;
const binding: CustomAuthBinding = { version: 1, id, baseUrl: base, authMode, ...(fileKey && fileKeyBaseUrl ? { fileKeyBaseUrl } : {}) };
commit({ baseUrl: base, authMode, credentialBindingId: id, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, binding, fileKey);
return response({ ok: true, models });
} catch (error) {
return failure(error instanceof CustomBodyLimitError ? "Configuration body exceeds 64 KiB." : error instanceof SyntaxError ? "Invalid JSON body." : "Unable to save custom configuration. Reload configuration and retry.", error instanceof CustomBodyLimitError ? 413 : error instanceof SyntaxError ? 400 : 500);
}
}
+52 -32
View File
@@ -5,10 +5,14 @@
* local servers work via a placeholder key resolved at request time.
*/
import { createProvider, type ApiKeyAuth, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
import { createProvider, 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 { configureProviderSource, piModels } from "../agent/models.ts";
import { readSettings } from "../store/settings.ts";
import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts";
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
import { customBindingMatches } from "../core/custom-provider-auth.ts";
import { customAuthState, customKeyAuthorized } from "./custom-provider-auth.ts";
import {
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
@@ -16,9 +20,8 @@ import {
normalizeBaseUrl,
type CustomModel,
type CustomModelOverride,
type CustomProviderSettings,
} from "../core/settings.ts";
import { keyValue } from "./keys.ts";
/** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */
const KEYLESS_PLACEHOLDER = "not-needed";
@@ -27,14 +30,32 @@ const KEYLESS_PLACEHOLDER = "not-needed";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** Auth: the reserved key via the keys seam, else the keyless placeholder. */
const customEndpointAuth: ApiKeyAuth = {
name: `${CUSTOM_PROVIDER.label} API key`,
resolve: async () => {
const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim();
return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" };
},
};
/** Install the sole custom source: donor settings + explicit authentication.
* Publication captures a consistent snapshot; an already-started operation
* cannot acquire a later endpoint's key. No file configuration source exists. */
export function installCustomProviderSource(readAuth = customAuthState): void {
let fingerprint: string | undefined;
configureProviderSource({
isCustom: (id) => id === CUSTOM_PROVIDER.id,
sync(models) {
const cp = readSettings()?.customProvider;
// Validate the durable nonce/URL/mode before resolving ANY credential.
// Keyless reads only the nonsecret marker, never a file/env key.
const auth = readAuth(cp?.authMode === "apiKey", cp);
const key = auth.key;
const ready = customProviderUsable(cp) && auth.readable && customBindingMatches(cp, auth.binding) && (cp.authMode === "keyless" || customKeyAuthorized(cp, auth));
const next = JSON.stringify([cp, auth]); // Private in-memory fingerprint, never a public revision.
if (next === fingerprint) return;
if (ready) models.setProvider(buildOpenAiCompatibleProvider({ ...cp, baseUrl: cp.baseUrl, models: cp.models ?? [], apiKey: key }));
else models.deleteProvider(CUSTOM_PROVIDER.id);
fingerprint = next;
},
});
}
export function customProviderAvailable(): boolean {
return piModels().getProvider(CUSTOM_PROVIDER.id) !== undefined;
}
/** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking
* levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27):
@@ -72,13 +93,18 @@ export function buildOpenAiCompatibleProvider(input: {
models: readonly CustomModel[];
/** User pins by model id (see CustomModelOverride) — absent = none. */
overrides?: Record<string, CustomModelOverride>;
/** Already resolved for this publication; absent means explicit keyless. */
apiKey?: string;
}): Provider<Api> {
const overrides = input.overrides ?? {};
return createProvider<Api>({
id: CUSTOM_PROVIDER.id,
name: CUSTOM_PROVIDER.label,
baseUrl: input.baseUrl,
auth: { apiKey: customEndpointAuth },
auth: { apiKey: {
name: `${CUSTOM_PROVIDER.label} API key`,
resolve: async () => ({ auth: { apiKey: input.apiKey ?? KEYLESS_PLACEHOLDER }, source: input.apiKey ? CUSTOM_PROVIDER.envVar : "keyless" }),
} },
models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)),
api: openAICompletionsApi(),
});
@@ -86,19 +112,8 @@ export function buildOpenAiCompatibleProvider(input: {
/** Re-sync the provider into piModels() (idempotent): usable → rebuild;
* not usable → remove. Never throws. */
export function syncCustomProvider(cp: CustomProviderSettings | undefined): void {
const models = piModels();
if (!customProviderUsable(cp)) {
models.deleteProvider(CUSTOM_PROVIDER.id);
return;
}
models.setProvider(
buildOpenAiCompatibleProvider({
baseUrl: normalizeBaseUrl(cp.baseUrl),
models: cp.models ?? [],
overrides: cp.overrides ?? {},
}),
);
export function syncCustomProvider(): void {
piModels();
}
/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status
@@ -106,7 +121,7 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void
* model list. */
export type DiscoverResult =
| { ok: true; models: CustomModel[] }
| { ok: false; error: string; status?: number };
| { ok: false; error: string; status?: number; fatal?: boolean };
/** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https
* only); keyless endpoints answer without a bearer. Non-2xx or unparseable
@@ -114,22 +129,26 @@ export type DiscoverResult =
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 key = apiKey?.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,
redirect: "manual",
});
if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}`, status: res.status };
const body = (await res.json().catch(() => null)) as unknown;
if (res.status >= 300 && res.status < 400) {
await res.body?.cancel();
return { ok: false, error: "Discovery redirects are not allowed", fatal: true };
}
if (!res.ok) { await res.body?.cancel(); return { ok: false, error: `Endpoint returned HTTP ${res.status}`, status: res.status }; }
const body = await readBoundedJson(res, 8 * 1024 * 1024);
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 };
return { ok: false, error: err instanceof CustomBodyLimitError ? err.message : controller.signal.aborted ? "Endpoint timed out" : "Discovery request failed", fatal: err instanceof CustomBodyLimitError };
} finally {
clearTimeout(timer);
}
@@ -210,6 +229,7 @@ const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
function extractModels(body: unknown): CustomModel[] | null {
const rows = modelRows(body);
if (rows === null) return null;
if (rows.length > 10_000) throw new CustomBodyLimitError("Model list exceeds 10000 entries");
const byId = new Map<string, CustomModel>();
for (const row of rows) {
if (typeof row === "string") {
-91
View File
@@ -1,91 +0,0 @@
/** 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 { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
import { readCustomProvidersFile } from "../store/custom-providers.ts";
const reserved = new Set([...builtinProviders().map((p) => p.id), "chatgpt", CLAUDE_CODE_PROVIDER, ...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(); }
+1
View File
@@ -49,3 +49,4 @@ export { claudeCodeInstallationResponse } from "./claude-code.ts";
// The custom endpoint module (registers into piModels() + probes for models).
// No import side effects; the boot sync lives in stores.ts.
export * from "./custom-provider.ts";
export { customProviderGet, customProviderMutation } from "./custom-provider-http.ts";
+16 -16
View File
@@ -54,19 +54,19 @@ 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";
import { customProviderAvailable, installCustomProviderSource } from "./custom-provider.ts";
import { claudeCodeAvailable } from "./claude-code-config.ts";
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
import { CUSTOM_AUTH_BINDING } from "../core/custom-provider-auth.ts";
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
// without importing this store-backed module into the agent layer.
import { authEnvRecord as boot } from "../core/auth-env.ts";
/** Is `id` a WRITABLE key name — curated catalog, model-provider env var, or
* the custom endpoint's reserved key (writable through the same seam). */
/** Ordinary writable key names. Custom auth writes must use the atomic
* endpoint-binding seam, never the generic single-key API. */
export function isWritableAuthKey(id: string): boolean {
return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar;
return id !== CUSTOM_AUTH_BINDING && id !== CUSTOM_PROVIDER.envVar && (isKnownAuthKey(id) || isProviderEnvVar(id));
}
/** The settings page's provider dropdown: every pi-ai provider that takes
@@ -92,7 +92,7 @@ export function envKeysIgnored(): boolean {
*/
export function initAuthEnv(): void {
const record = boot();
const fileVars = authStore.authVars();
const fileVars = Object.fromEntries(Object.entries(authStore.authVars()).filter(([name]) => name !== CUSTOM_AUTH_BINDING));
const managed = new Set<string>([
...Object.keys(fileVars),
...AUTH_KEYS.map((k) => k.id),
@@ -100,6 +100,7 @@ export function initAuthEnv(): void {
// an env-exported OPENAI_API_KEY with a file copy gets honest shadow
// semantics, exactly like the curated vars.
...AUTH_PROVIDER_KEYS.map((p) => p.envVar),
CUSTOM_PROVIDER.envVar,
]);
if (envKeysIgnored()) {
// THE FRESH-USER SWITCH: delete every managed env var (re-run on
@@ -127,7 +128,7 @@ export function initAuthEnv(): void {
/** Is this env-var name one the keys system manages (curated, provider,
* or held in the file)? */
function isManagedName(name: string): boolean {
return isWritableAuthKey(name) || name in authStore.authVars();
return name !== CUSTOM_AUTH_BINDING && (isWritableAuthKey(name) || name in authStore.authVars());
}
/**
@@ -149,6 +150,7 @@ export function resolveKeyStatuses(
}
const rows: AuthKeyStatus[] = [];
for (const id of ids) {
if (id === CUSTOM_AUTH_BINDING) continue;
const envValue = env[id];
const fileValue = fileVars[id];
const isRealEnv = realEnv.has(id);
@@ -170,7 +172,7 @@ export function resolveKeyStatuses(
/** The GET view: every curated key (present or not) + unknown file vars. */
export function keyStatuses(): AuthKeyStatus[] {
return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, AUTH_PROVIDER_ENV_VARS);
return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, new Set([...AUTH_PROVIDER_ENV_VARS, CUSTOM_PROVIDER.envVar]));
}
/** The live value for one var, env-first (the env-wins rule) — the canonical
@@ -178,6 +180,7 @@ export function keyStatuses(): AuthKeyStatus[] {
* STT route was the one cold-boot hole: a first-ever request to it would
* otherwise read env before any fill-gap ran). */
export function keyValue(id: string): string | undefined {
if (id === CUSTOM_AUTH_BINDING) return undefined;
// The fresh-user switch: ONLY the file store counts (belt-and-
// suspenders — the boot deletion already emptied the env; this guards
// any env key reintroduced after boot).
@@ -192,9 +195,7 @@ 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 (provider === CUSTOM_PROVIDER.id) return customProviderAvailable();
// Explicit opt-in + installation only, never authentication readiness.
// Consulted at admission, not inside already-running agent loops.
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable();
@@ -210,10 +211,8 @@ 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) || id === CUSTOM_PROVIDER.envVar
? genericProviderKeyGuard(id, value)
: validateKeyShape(id, value);
if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) return { ok: false, error: "Use the custom endpoint editor" };
const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value);
if (!shape.ok) return { ok: false, error: shape.error };
const trimmed = value.trim();
authStore.setAuthVar(id, trimmed);
@@ -232,6 +231,7 @@ export function saveKey(id: string, value: string): { ok: true; status: AuthKeyS
* wins) and the status shows the key still present from env.
*/
export function deleteKey(id: string): AuthKeyStatus {
if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) throw new Error("Use the custom endpoint editor");
authStore.deleteAuthVar(id);
const record = boot();
if (record.fillGap.has(id) && !record.realEnv.has(id)) {
@@ -244,4 +244,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);
installCustomProviderSource();
+3 -5
View File
@@ -20,7 +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 { customProviderAvailable } from "./custom-provider.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";
@@ -155,11 +155,10 @@ export function catalogProviders(): CatalogProvider[] {
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
...customProviderRows(),
// The custom endpoint: a row only when usable (base URL set). keyPresent
// reflects usability, not key presence (keyless local servers are allowed).
...(customProviderUsable(effectiveSettings().customProvider)
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }]
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: customProviderAvailable() }]
: []),
]).sort((a, b) => a.id.localeCompare(b.id));
}
@@ -180,7 +179,7 @@ export function toModelDto(model: Model<Api>): ModelDto {
reasoning: model.reasoning,
input: [...model.input],
supportedLevels: getSupportedThinkingLevels(model),
cost: model.provider === CLAUDE_CODE_PROVIDER ? null : {
cost: model.provider === CLAUDE_CODE_PROVIDER || model.provider === CUSTOM_PROVIDER.id ? null : {
input: model.cost.input,
output: model.cost.output,
cacheRead: model.cost.cacheRead,
@@ -231,7 +230,6 @@ export function modelCatalog(): ModelCatalogResponse {
rolePins: catalogRolePins(),
slots: catalogSlots(),
providers: catalogProviders(),
...(customProvidersError() ? { customProvidersError: customProvidersError() } : {}),
};
}
+2 -1
View File
@@ -4,6 +4,7 @@
*/
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../agent/models.ts";
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
@@ -12,7 +13,7 @@ function catalogModel(id: string) {
const colon = id.indexOf(":");
// Native CLI routing determines billing. Adapter zero-rate placeholders
// must never become an apparent $0 estimate or subscription classification.
if (colon < 0 || isSubscriptionModel(id) || id.slice(0, colon) === CLAUDE_CODE_PROVIDER) return undefined;
if (colon < 0 || isSubscriptionModel(id) || [CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER.id].includes(id.slice(0, colon))) return undefined;
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
}
+2 -2
View File
@@ -400,7 +400,7 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<Di
* whole desired map, replace-whole like every section. Strict write side — a
* token count must be a whole number at or above the floor (the read side
* clamps, this rejects — the compaction discipline). `reasoning` is the
* tri-state pin: true forces thinking on, false forces it off. */
* tri-state capability pin, not a backend execution toggle. */
export function parseCustomModelOverridesBody(
body: unknown,
): SettingsBodyResult<Record<string, CustomModelOverride>> {
@@ -430,7 +430,7 @@ export function parseCustomModelOverridesBody(
if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` };
pin.reasoning = entry.reasoning;
}
out[id] = pin;
Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
}
return { ok: true, settings: out };
}
+1 -1
View File
@@ -36,4 +36,4 @@ export function effectiveSettings(): EffectiveSettings {
// Boot: register the custom endpoint (when configured) into piModels(); the
// POST /api/custom-provider route re-syncs after every connect.
syncCustomProvider(effectiveSettings().customProvider);
syncCustomProvider();