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
+7 -130
View File
@@ -1,132 +1,9 @@
/**
* The custom endpoint's connect surface (one provider in the Keys list).
* GET → non-secret config { baseUrl, models, overrides, manualModels } +
* keyPresent (masked).
* POST → connect: validate + normalize the base URL, probe /models, store
* the key, persist { baseUrl, models, overrides, manualModels },
* re-sync the provider into piModels(). Discovery lives here because
* connecting and discovering are one user action. A failed probe is
* fatal UNLESS the user typed model IDs: an endpoint that serves chat
* but no model list is configured from those ids alone. A failed
* probe for the endpoint already stored keeps its last snapshot, so
* a blip cannot prune the stored pins. A 401/403 always fails: a
* rejected key is not a missing model list. Stored user pins survive
* a reconnect, pruned to the ids the connect ends up offering.
* PATCH → store per-model pins only. No probe: pins must be editable with
* the local server switched off (the case they exist for).
* DELETE → disconnect: drop the stored config + the stored key and unregister
* the provider (the mirror image of POST; idempotent).
*/
import {
deleteKey,
discoverModels,
effectiveSettings,
keyValue,
mergeManualModels,
parseCustomModelOverridesBody,
readSettingsRecord,
saveKey,
syncCustomProvider,
writeSettingsRecord,
} from "@/src/server";
import { CUSTOM_PROVIDER, normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides } from "@/src/core";
/** PR #62's single custom endpoint: discover/manual connect, offline pins,
* and disconnect. Host boundaries and mutations live behind the server barrel. */
import { customProviderGet, customProviderMutation } from "@/src/server";
export const dynamic = "force-dynamic";
export async function GET() {
const cp = effectiveSettings().customProvider;
return Response.json({
baseUrl: cp.baseUrl,
models: cp.models,
overrides: cp.overrides,
manualModels: cp.manualModels,
keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined,
});
}
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown; manualModels?: unknown } | null;
const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : "");
if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 });
// Empty key string clears it, absent leaves it stored; only a real key rides the probe.
const keyProvided = typeof body?.key === "string";
const key = keyProvided ? (body!.key as string).trim() : undefined;
const manualModels = parseModelIds(body?.manualModels);
const record = readSettingsRecord() ?? { version: 1 };
const found = await discoverModels(base, key ? key : undefined);
// A rejected key is not a missing model list: never let typed ids turn an
// auth failure into a stored "connected" endpoint.
if (!found.ok && (found.status === 401 || found.status === 403)) {
return Response.json({ error: found.error }, { status: 502 });
}
if (!found.ok && !manualModels.length) {
return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 });
}
// A failed probe says nothing about what the endpoint offers: keep the last
// snapshot of the SAME endpoint, so a transient failure cannot shrink the
// model list and prune its pins. A different base URL starts clean.
const discovered = found.ok
? found.models
: record.customProvider?.baseUrl === base
? (record.customProvider?.models ?? [])
: [];
const models = mergeManualModels(discovered, manualModels);
if (keyProvided) {
if (key) {
const saved = saveKey(CUSTOM_PROVIDER.envVar, key);
if (!saved.ok) return Response.json({ error: saved.error }, { status: 400 });
} else {
deleteKey(CUSTOM_PROVIDER.envVar);
}
}
// User pins are never rewritten by discovery — only pruned to the ids this
// connect offers (discovered ∪ typed).
const overrides = sanitizeCustomModelOverrides(
record.customProvider?.overrides,
models.map((m) => m.id),
);
const next = writeSettingsRecord({
...record,
customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) },
});
syncCustomProvider(next.customProvider);
return Response.json({ ok: true, models: next.customProvider?.models ?? [] });
}
/** Store the per-model pins (replace-whole). Pruned against the STORED model
* list, never a probe — an offline endpoint keeps its pins editable. */
export async function PATCH(request: Request) {
const parsed = parseCustomModelOverridesBody(await request.json().catch(() => null));
if (!parsed.ok) return Response.json({ error: parsed.error }, { status: 400 });
const cp = effectiveSettings().customProvider;
if (!cp.baseUrl) return Response.json({ error: "No custom endpoint configured" }, { status: 409 });
const overrides = sanitizeCustomModelOverrides(
parsed.settings,
cp.models.map((m) => m.id),
);
const record = readSettingsRecord() ?? { version: 1 };
const next = writeSettingsRecord({
...record,
customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides, ...(cp.manualModels.length ? { manualModels: cp.manualModels } : {}) },
});
syncCustomProvider(next.customProvider);
return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} });
}
/** Disconnect: forget the endpoint AND its key, then unregister the provider.
* Deleting the section (rather than blanking baseUrl) keeps settings.json the
* same shape as a never-connected install. Idempotent by construction. */
export async function DELETE() {
const record = readSettingsRecord() ?? { version: 1 };
const { customProvider: _dropped, ...rest } = record;
const next = writeSettingsRecord(rest);
deleteKey(CUSTOM_PROVIDER.envVar);
syncCustomProvider(next.customProvider);
return Response.json({ ok: true });
}
export const GET = customProviderGet;
export const POST = customProviderMutation;
export const PATCH = customProviderMutation;
export const DELETE = customProviderMutation;
+105 -43
View File
@@ -29,6 +29,7 @@ import {
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
CUSTOM_PROVIDER,
normalizeBaseUrl,
type AuthKeyStatus,
type CustomModel,
type CustomModelOverride,
@@ -56,6 +57,10 @@ interface KeysResponse {
/** The custom endpoint's non-secret status (GET /api/custom-provider). */
interface CustomStatus {
keyReuseRequired: boolean;
revision: string;
authMode: "keyless" | "apiKey";
keySource: "env" | "file" | "none";
baseUrl: string;
models: CustomModel[];
/** User pins by model id (context / max output / thinking). */
@@ -64,6 +69,13 @@ interface CustomStatus {
keyPresent: boolean;
}
async function readCustomStatus(inspectKey = false): Promise<CustomStatus> {
const response = await fetch(`/api/custom-provider${inspectKey ? "?authMode=apiKey" : ""}`);
if (!response.ok) throw new Error("Unable to load custom configuration.");
const cp = await response.json() as Partial<CustomStatus>;
return { keyReuseRequired: Boolean(cp.keyReuseRequired), revision: response.headers.get("etag") ?? "", authMode: cp.authMode ?? "keyless", keySource: cp.keySource ?? "none", baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], overrides: cp.overrides ?? {}, manualModels: cp.manualModels ?? [], keyPresent: Boolean(cp.keyPresent) };
}
/** The curated tools group, pinned by id (the catalog owns the copy;
* these ids are the contract). Derived from the catalog (search wave,
* 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the
@@ -410,14 +422,7 @@ export function KeysSection({
// Best-effort and independent: the custom endpoint's status must never
// blank the provider-key list above if its fetch fails.
try {
const cp = (await fetch("/api/custom-provider").then((r) => r.json())) as Partial<CustomStatus>;
setCustom({
baseUrl: cp.baseUrl ?? "",
models: cp.models ?? [],
overrides: cp.overrides ?? {},
manualModels: cp.manualModels ?? [],
keyPresent: Boolean(cp.keyPresent),
});
setCustom(await readCustomStatus());
} catch {
/* custom status unavailable — leave it unset */
}
@@ -460,11 +465,13 @@ export function KeysSection({
/** Forget the custom endpoint (config + key server-side), same rhythm as
* deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */
async function disconnectCustom(): Promise<void> {
await fetch("/api/custom-provider", { method: "DELETE" }).catch(() => undefined);
async function disconnectCustom(revision: string): Promise<string | null> {
const res = await fetch("/api/custom-provider", { method: "DELETE", headers: { "content-type": "application/json", "if-match": revision }, body: JSON.stringify({}) }).catch(() => null);
if (!res?.ok) return res ? ((await res.json().catch(() => ({}))).error ?? "Disconnect failed.") : "Disconnect failed. Check your connection.";
setAddingCustom(false);
await reload();
notifySettingsSaved("keys");
return null;
}
if (loading || statuses === null) {
@@ -512,6 +519,11 @@ export function KeysSection({
)}
{addingCustom && (
<CustomEndpointEditor
revision0={custom?.revision ?? ""}
authMode0={custom?.authMode ?? "keyless"}
keySource0={custom?.keySource ?? "none"}
keyReuseRequired0={custom?.keyReuseRequired ?? false}
onReload={async () => { await reload(); setAddingCustom(false); }}
baseUrl0={custom?.baseUrl ?? ""}
keyPresent={custom?.keyPresent ?? false}
models={custom?.models ?? []}
@@ -658,6 +670,20 @@ function AddProviderEditor({
);
}
/** One donor editor for first setup and Settings; setup omits offline pin and
* disconnect controls, while authentication/revisions/manual IDs stay identical. */
export function CustomEndpointSetup({ onSaved }: { onSaved: () => void | Promise<void> }) {
const [status, setStatus] = useState<CustomStatus | null>(null);
const [error, setError] = useState(false);
async function reload() {
try { setStatus(await readCustomStatus()); setError(false); }
catch { setError(true); }
}
useEffect(() => { void reload(); }, []);
if (!status || error) return <div className="text-xs">{error ? "Unable to load custom configuration." : "Loading custom configuration…"}{error && <button onClick={() => void reload()}>Retry</button>}</div>;
return <CustomEndpointEditor key={status.revision} setup revision0={status.revision} authMode0={status.authMode} keySource0={status.keySource} keyReuseRequired0={status.keyReuseRequired} baseUrl0={status.baseUrl} keyPresent={status.keyPresent} models={status.models} overrides={status.overrides} manualModels0={status.manualModels} onSaved={onSaved} onPinsSaved={reload} onReload={reload} onCancel={reload} />;
}
/** The custom endpoint's status row (shown once configured); clicking re-opens the editor. */
function CustomEndpointRow({
status,
@@ -669,7 +695,7 @@ function CustomEndpointRow({
onToggle: () => void;
}) {
const count = status.models.length;
const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${status.keyPresent ? "" : " · keyless"}`;
const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"} · ${status.authMode === "keyless" ? "keyless" : status.keyPresent ? "API key" : "API key missing"}`;
return (
<div data-testid="custom-endpoint-row">
<button
@@ -719,7 +745,7 @@ function pinsPayload(drafts: Record<string, PinDraft>): Record<string, CustomMod
if (d.context.trim()) pin.context = Number(d.context);
if (d.maxTokens.trim()) pin.maxTokens = Number(d.maxTokens);
if (d.reasoning !== "") pin.reasoning = d.reasoning === "on";
if (Object.keys(pin).length) out[id] = pin;
if (Object.keys(pin).length) Object.defineProperty(out, id, { value: pin, enumerable: true });
}
return out;
}
@@ -732,8 +758,10 @@ const pinTestid = (prefix: string, id: string) => `${prefix}-${id.replace(/[^a-z
* no probe, so it works with the local server switched off). Image capability is
* detected automatically. */
function CustomEndpointEditor({
setup = false,
revision0, authMode0, keySource0, keyReuseRequired0, onReload,
baseUrl0,
keyPresent,
keyPresent: initialKeyPresent,
models,
overrides,
manualModels0,
@@ -742,6 +770,12 @@ function CustomEndpointEditor({
onCancel,
onDelete,
}: {
setup?: boolean;
revision0: string;
authMode0: "keyless" | "apiKey";
keySource0: "env" | "file" | "none";
keyReuseRequired0: boolean;
onReload: () => void | Promise<void>;
baseUrl0: string;
keyPresent: boolean;
/** The discovered models — one limits row each. */
@@ -753,8 +787,29 @@ function CustomEndpointEditor({
/** Pins saved — the editor stays open (a form, not a connect). */
onPinsSaved: () => void | Promise<void>;
onCancel: () => void;
onDelete?: () => void | Promise<void>;
onDelete?: (revision: string) => Promise<string | null>;
}) {
const [revision, setRevision] = useState(revision0);
const [authMode, setAuthMode] = useState(authMode0);
const [keySource, setKeySource] = useState(keySource0);
const [keyPresent, setKeyPresent] = useState(initialKeyPresent);
const [keyReuseRequired, setKeyReuseRequired] = useState(keyReuseRequired0);
const [authLoading, setAuthLoading] = useState(false);
const [reuseKey, setReuseKey] = useState(false);
useEffect(() => {
if (authMode !== "apiKey") return;
let cancelled = false;
setAuthLoading(true);
readCustomStatus(true).then((status) => {
if (cancelled) return;
setKeySource(status.keySource);
setKeyPresent(status.keyPresent);
setKeyReuseRequired(status.keyReuseRequired);
if (status.keySource === "env") setKey("");
}).catch(() => { if (!cancelled) setError("Unable to inspect effective key. Reload configuration and retry."); })
.finally(() => { if (!cancelled) setAuthLoading(false); });
return () => { cancelled = true; };
}, [authMode]);
const [baseUrl, setBaseUrl] = useState(baseUrl0);
const [key, setKey] = useState("");
const [keyTouched, setKeyTouched] = useState(false);
@@ -767,10 +822,8 @@ function CustomEndpointEditor({
const [baseline, setBaseline] = useState(() => JSON.stringify(pinsPayload(draftsFor(models, overrides))));
const [pinsBusy, setPinsBusy] = useState(false);
const [pinsError, setPinsError] = useState<string | null>(null);
// A key is required to enable chatting; local servers accept any value. A
// stored key is kept when the field is left blank.
const needsKey = !keyPresent;
const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim()));
const changedTarget = normalizeBaseUrl(baseUrl) !== normalizeBaseUrl(baseUrl0);
const canConnect = Boolean(baseUrl.trim()) && (authMode === "keyless" || (!authLoading && ((Boolean(key.trim()) && keySource !== "env") || (keyPresent && (!(changedTarget || keyReuseRequired) || reuseKey)))));
const draftOf = (m: CustomModel): PinDraft => drafts[m.id] ?? pinDraft(overrides[m.id]);
const setDraft = (m: CustomModel, patch: Partial<PinDraft>) =>
@@ -779,44 +832,47 @@ function CustomEndpointEditor({
const dirty = JSON.stringify(pinsPayload(current)) !== baseline;
async function savePins() {
if (pinsBusy || !dirty) return;
if (pinsBusy || busy || !dirty) return;
setPinsBusy(true);
setPinsError(null);
const res = await fetch("/api/custom-provider", {
method: "PATCH",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", "if-match": revision },
body: JSON.stringify({ overrides: pinsPayload(current) }),
}).catch(() => null);
setPinsBusy(false);
if (!res || !res.ok) {
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
setPinsError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection.");
setPinsBusy(false);
return;
}
// The server's stored map is the truth (it floors + prunes) — echo it back.
setRevision(res.headers.get("etag") ?? revision);
// The server's stored map is the truth — echo it back.
const body = (await res.json().catch(() => null)) as { overrides?: Record<string, CustomModelOverride> } | null;
const next = draftsFor(models, body?.overrides ?? pinsPayload(current));
setDrafts(next);
setBaseline(JSON.stringify(pinsPayload(next)));
await onPinsSaved();
setPinsBusy(false);
}
async function save() {
if (busy || !baseUrl.trim()) return;
if (busy || pinsBusy || !canConnect) return;
setBusy(true);
setError(null);
const res = await fetch("/api/custom-provider", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ baseUrl, manualModels, ...(keyTouched ? { key } : {}) }),
headers: { "content-type": "application/json", "if-match": revision },
body: JSON.stringify({ baseUrl, authMode, reuseKey, manualModels, ...(authMode === "apiKey" && keyTouched && key.trim() && keySource !== "env" ? { key } : {}) }),
}).catch(() => null);
setBusy(false);
if (!res || !res.ok) {
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection.");
setBusy(false);
return;
}
await onSaved();
setBusy(false);
}
return (
@@ -845,24 +901,32 @@ function CustomEndpointEditor({
disabled={busy}
className="h-7 min-w-0 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<label className="flex items-center gap-2 text-xs">Authentication
<select aria-label="Custom endpoint authentication" data-testid="custom-auth-mode" value={authMode} disabled={busy || pinsBusy} onChange={(e) => { setAuthMode(e.target.value as "keyless" | "apiKey"); setReuseKey(false); }}>
<option value="keyless">Keyless</option><option value="apiKey">API key</option>
</select>
</label>
{authMode === "apiKey" && keySource === "env" && <p className="text-[11px] text-muted-foreground" data-testid="custom-env-owned">Environment supplies the effective key. Change the environment to replace it.</p>}
{authMode === "apiKey" && keyPresent && (changedTarget || keyReuseRequired) && <label className="text-[11px]"><input type="checkbox" data-testid="custom-reuse-key" checked={reuseKey} onChange={(e) => setReuseKey(e.target.checked)} /> Allow reuse of the effective existing key for this URL</label>}
<div className="flex items-center gap-2">
<input
type="text"
type="password"
value={key}
onChange={(e) => {
setKey(e.target.value);
setKeyTouched(true);
if (!e.target.value.trim()) setAuthMode("keyless");
}}
placeholder={keyPresent ? "Key set — paste to replace, leave empty to keep" : "API key — required (any value works for local servers)"}
placeholder={authMode === "keyless" ? "Keyless — saved/environment keys are not used" : keyPresent ? "Leave blank to keep effective key" : "API key"}
aria-label="Custom endpoint API key"
data-testid="custom-key-input"
disabled={busy}
disabled={busy || pinsBusy || authMode === "keyless" || keySource === "env"}
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={() => void save()}
disabled={busy || !canConnect}
disabled={busy || pinsBusy || !canConnect}
data-testid="custom-save"
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
@@ -883,8 +947,8 @@ function CustomEndpointEditor({
{onDelete && baseUrl0.trim() && (
<button
type="button"
onClick={() => void onDelete()}
disabled={busy}
onClick={async () => { setBusy(true); const err = await onDelete(revision); if (err) setError(err); setBusy(false); }}
disabled={busy || pinsBusy}
data-testid="custom-delete"
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
@@ -892,10 +956,10 @@ function CustomEndpointEditor({
</button>
)}
</div>
{models.length > 0 && (
{!setup && models.length > 0 && (
<div className="flex flex-col gap-1" data-testid="custom-pins">
<span className="text-[11px] text-muted-foreground">
Model limits — blank uses the endpoint’s value, then the default
Budget pins do not resize backend context. Blank uses reported limits, then fallback (8192 context / 4096 output). Custom pricing is unknown.
</span>
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1">
{models.map((m) => {
@@ -904,6 +968,7 @@ function CustomEndpointEditor({
<div key={m.id} className="flex items-center gap-2" data-testid={pinTestid("custom-pin-row", m.id)}>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground" title={m.id}>
{m.id}
<span className="block text-[10px] text-muted-foreground">Context: {d.context.trim() ? "user pin" : m.context ? `reported ${m.context}` : `fallback ${CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW}`} · Output: {d.maxTokens.trim() ? "user pin" : m.maxTokens ? `reported ${m.maxTokens}` : `fallback ${CUSTOM_MODEL_DEFAULT_MAX_TOKENS}`}</span>
</span>
<input
type="text"
@@ -930,14 +995,14 @@ function CustomEndpointEditor({
<select
value={d.reasoning}
onChange={(e) => setDraft(m, { reasoning: e.target.value as PinDraft["reasoning"] })}
aria-label={`${m.id} thinking`}
aria-label={`${m.id} reasoning support`}
data-testid={pinTestid("custom-pin-reasoning", m.id)}
disabled={pinsBusy}
className="h-7 shrink-0 rounded-sm border border-border bg-background px-1 font-ui text-[11px] text-foreground outline-none focus:border-muted-foreground/50"
>
<option value="">think: auto</option>
<option value="on">think: on</option>
<option value="off">think: off</option>
<option value="">Support: reported</option>
<option value="on">Support: yes</option>
<option value="off">Support: no</option>
</select>
</div>
);
@@ -961,11 +1026,8 @@ function CustomEndpointEditor({
</div>
</div>
)}
{needsKey && (
<p className="text-[11px] text-muted-foreground">
A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value.
</p>
)}
<p className="text-[11px] text-muted-foreground">Reasoning support is a capability declaration. The effort picker’s Off may not disable a custom backend’s default thinking.</p>
{(error || pinsError) && <button type="button" data-testid="custom-reload" onClick={() => void onReload()} className="text-left text-xs underline">Reload configuration</button>}
{error && (
<p role="alert" data-testid="custom-error" className="text-xs text-red-500">
{error}
+9 -34
View File
@@ -48,7 +48,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
import { useModelCatalog } from "@/components/model-catalog";
import { savedSlotsConfigured } from "@/components/setup-cta";
import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo, CUSTOM_PROVIDER_INFO } from "@/components/settings/keys-section";
import { type ProviderKeyInfo, CUSTOM_PROVIDER_INFO, CustomEndpointSetup } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu";
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection";
import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections";
@@ -135,8 +135,7 @@ export function SetupKeysBody({
onSave,
menuAnchor,
isCustom,
baseUrl,
onBaseUrl,
onCustomSaved,
}: {
/** The full provider roster (GET /api/keys providers) — undefined
* while it loads; the picker renders an honest empty state then. */
@@ -157,8 +156,7 @@ export function SetupKeysBody({
/** Chosen provider is the custom endpoint: a base URL field joins the key
* field. All three optional so plain-key callers (and tests) stay unchanged. */
isCustom?: boolean;
baseUrl?: string;
onBaseUrl?: (v: string) => void;
onCustomSaved?: () => void;
}) {
return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
@@ -192,20 +190,8 @@ export function SetupKeysBody({
</MenuShell>
)}
</div>
{provider && (
{provider && (isCustom ? <CustomEndpointSetup onSaved={onCustomSaved ?? onSave} /> : (
<div className="flex max-w-96 flex-col gap-2">
{isCustom && (
<input
type="text"
value={baseUrl ?? ""}
onChange={(e) => onBaseUrl?.(e.target.value)}
placeholder="Base URL, e.g. http://localhost:11434/v1"
aria-label="Custom endpoint base URL"
data-testid="setup-custom-baseurl"
disabled={saving}
className="h-7 w-full rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
)}
<div className="flex items-center gap-2">
<input
type="text"
@@ -214,7 +200,7 @@ export function SetupKeysBody({
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave();
}}
placeholder={isCustom ? "API key — required (any value works for local servers)" : "Paste key…"}
placeholder="Paste key…"
aria-label={`${provider.label} key`}
data-testid="setup-key-input"
disabled={saving}
@@ -228,11 +214,11 @@ export function SetupKeysBody({
data-testid="setup-key-save"
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? "Saving…" : isCustom ? "Connect" : "Save key"}
{saving ? "Saving…" : "Save key"}
</button>
</div>
</div>
)}
))}
{error && (
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
{error}
@@ -286,7 +272,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
const [keyProviders, setKeyProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
const [keyProvider, setKeyProvider] = useState<ProviderKeyInfo | null>(null);
const [keyValue, setKeyValue] = useState("");
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [keySaving, setKeySaving] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
@@ -398,18 +383,10 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
* 400s keep their user-facing line inline. */
async function saveKey() {
if (!keyProvider || keySaving) return;
const isCustom = keyProvider.id === CUSTOM_PROVIDER.id;
if (!keyValue.trim()) return;
setKeySaving(true);
setKeyError(null);
// Custom endpoint connects through its own route; every other provider is a plain key PUT.
const res = isCustom
? await fetch("/api/custom-provider", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ baseUrl: customBaseUrl, key: keyValue }),
}).catch(() => null)
: await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value: keyValue }),
@@ -422,7 +399,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
}
setKeyProvider(null);
setKeyValue("");
setCustomBaseUrl("");
notifySettingsSaved("keys");
}
@@ -461,8 +437,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef}
isCustom={keyProvider?.id === CUSTOM_PROVIDER.id}
baseUrl={customBaseUrl}
onBaseUrl={setCustomBaseUrl}
onCustomSaved={() => { setKeyProvider(null); notifySettingsSaved("keys"); }}
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
onValue={setKeyValue}
-3
View File
@@ -111,9 +111,6 @@ export const GRAPH_FORMAT_FILE = path.join(PATHS.graph, "format.json");
*/
export const SETTINGS_FILE = path.join(DATA_ROOT, "settings.json");
/** Explicit custom-provider/model definitions; never read the user's Pi profile. */
export const CUSTOM_MODELS_FILE = path.join(DATA_ROOT, "models.json");
/**
* User prompt-override files (settings-storage wave, 2026-09-19): the
* per-segment system-prompt overrides mirroring the SOURCE layout
+113 -139
View File
@@ -1,159 +1,133 @@
# Custom model providers
# Custom and local models
LearnHarness can register several independently named custom providers using its
existing Pi AI runtime. This is configuration wiring, not another agent runtime:
Pi owns the model protocols, request conversion, streaming, tools, thinking and
usage accounting. There is no additional dependency on Pi coding-agent.
LearnHarness supports **one custom OpenAI-compatible endpoint with multiple models**.
The setup and Settings UI are based on PR #62 by Daniel Buckner and Abhishek Thakur.
Pi owns inference, message conversion, tools, streaming, and reasoning-effort handling.
## Configuration location
## Configure an endpoint
Create `models.json` in the LearnHarness app-data directory (the directory
containing `settings.json`; `LH_DATA_DIR` relocates it). LearnHarness does not read
`~/.pi/agent/models.json`, install a model server, or edit this file for you.
Custom-provider setup UI is not included yet. Once configured, models appear in
the existing model selectors when their credential/header references resolve.
1. Open **Settings → Providers → + Add a provider → Custom endpoint**. The same
editor is available from the first-run provider picker.
2. Enter the server's complete API base URL, for example
`http://localhost:11434/v1`. LearnHarness does **not** append `/v1`.
3. Choose **Keyless** or **API key**:
- **Keyless** never uses a saved or environment custom-provider key for
authentication. Pi's OpenAI client receives an internal dummy credential.
- **API key** uses the effective `CUSTOM_OPENAI_API_KEY`. A real environment
value wins over a file-saved value; the editor identifies environment ownership.
Any nonempty trimmed custom key is accepted—there is no arbitrary length floor.
- Changing the URL requires a new effective key or explicit consent to reuse
the existing effective key. A key is never silently forwarded to a new URL.
Clearing authentication selects Keyless.
4. Enter optional **model IDs**, separated by commas or whitespace. This supports
servers whose chat endpoint works but whose `/models` endpoint is absent.
5. Click **Connect**, then choose models for Strong, Cheap, and Vision.
The file uses Pi's `providers` / `models` structure. For example:
HTTP and HTTPS, including localhost/private-network servers, are supported. URLs
with embedded credentials, query strings, or fragments are rejected. Put a key in
the authentication field, not the URL. Discovery redirects are rejected.
```json
{
"providers": {
"my-local-server": {
"baseUrl": "http://127.0.0.1:11434/v1",
"api": "openai-completions",
"apiKey": "unused-local-key",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"maxTokensField": "max_tokens"
},
"models": [
{
"id": "your-installed-model",
"name": "My local model",
"contextWindow": 8192,
"maxTokens": 1024,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
}
]
},
"my-gateway": {
"baseUrl": "https://your-gateway.example/v1",
"api": "openai-completions",
"apiKey": "$MY_GATEWAY_API_KEY",
"headers": { "X-Tenant": "${MY_GATEWAY_TENANT}" },
"models": [
{
"id": "your-gateway-model-id",
"contextWindow": 32768,
"maxTokens": 4096,
"cost": {
"input": 1,
"output": 3,
"cacheRead": 0,
"cacheWrite": 0
}
}
]
}
}
}
```
Connect requests `GET <base>/models`. Discovery metadata wins over duplicate manually
entered IDs. A failed probe can fall back to entered IDs; a failed probe for the
same URL preserves its previous model snapshot and pins. A different URL starts
with its own models. **401/403 always fails**, even with entered IDs. Response-size,
model-count, and redirect violations also fail explicitly rather than silently
truncating or bypassing a discovery limit.
**Replace all example IDs, limits, prices and compatibility flags with the actual
server/model configuration.** These numbers are examples, not recommendations or
automatically detected capabilities. Rates are USD per million tokens. Explicit
zero rates are allowed; LearnHarness does not assume that custom gateways are
free. Verify the endpoint's billing independently.
Configuration is not a continuous health check. An endpoint may be configured while
offline, and model capability or output quality is not proven by listing its ID.
No backend/model installation, downloads, or OAuth flows are performed.
Use the exact API base URL your server documents. LearnHarness does not append
`/v1`, discover models, infer capabilities from names, or download models. Ollama,
vLLM, LM Studio, 9router and other gateways work only to the extent that their
configured endpoint implements the selected Pi protocol and compatibility flags.
For a keyless OpenAI-compatible local server, supply an explicit nonempty dummy
`apiKey` as above: the native Pi OpenAI client requires a key. Omitting `apiKey`
leaves a provider unavailable rather than implying unauthenticated access.
## Model budgets and reasoning support
## Supported fields
Settings exposes offline-editable pins per model:
Provider fields: `name`, `baseUrl`, `api`, `apiKey`, `headers`, `authHeader`,
`compat`, and the explicit `models` array. Each model requires `id`,
`contextWindow`, `maxTokens`, and `cost` with all four base rates. Unlike Pi
coding-agent's defaults, LearnHarness never invents limits or prices.
- **Context budget**: user pin → reported context → **8192 fallback**.
- **Maximum output**: user pin → reported output limit → **4096 fallback**.
- **Reasoning support**: user declaration → reported support.
Optional Pi model fields are `name`, `api`, `baseUrl`, `reasoning`, `input`,
`thinkingLevelMap`, `inputLimits`, `promptCache`, `samplingParams`,
`samplingParamsByThinkingLevel`, `headers`, and `compat`. Cost tiers and Pi's
exported compatibility schemas are supported. A model's `api` and `baseUrl`
override its provider's values; model headers override provider headers. Names
default to model IDs, `reasoning` defaults to false, and `input` defaults to
`["text"]`. Declare image input only when the server/model supports it. Existing
onboarding and vision-slot requirements are unchanged.
Every positive integer token pin is accepted, including a 512-token output limit.
Blank fields remove that pin. The editor identifies reported, fallback, and user-pin
values. **These are request/compaction budgets; changing them does not resize the
backend's actual context.** Configure the server's active context separately and
enter budgets that match it. Reconnecting prunes pins only to the resulting model
IDs; pin saves do not contact the server.
The native chat transports available in the pinned Pi AI 1.4.0 dependency are:
`openai-completions`, `openai-responses`, `anthropic-messages`,
`google-generative-ai`, `azure-openai-responses`, `google-vertex`,
`bedrock-converse-stream`, `mistral-conversations`, `openai-codex-responses`, and
`pi-messages`. Protocol availability does **not** establish account access,
OAuth support, cloud credentials, subscription permission, or vendor approval.
This feature adds no login flows. In particular, an Anthropic Messages endpoint
is not a Claude Agent SDK or Claude Code runtime.
Reasoning support is a **capability declaration, not an execution toggle**. The
existing Pi-backed effort picker remains available. In particular, **Off may not
disable a custom backend's default thinking**: an absent effort parameter is not a
universal disable command. There is no compatibility/thinking-map editor, Ollama
preset, or model-name inference. Backends requiring additional nonstandard headers
or compatibility configuration are outside this release's custom-endpoint UI.
Use new provider IDs; built-in and subscription-route IDs cannot be replaced.
Provider IDs must be nonempty and cannot contain `:` (the provider/model
coordinate separator). Model IDs must be unique within a provider. Built-in
provider overrides, `modelOverrides`, OAuth definitions, executable plugins, and
automatic discovery are outside this phase.
## Images and Vision
## Credentials and trust
All custom models can be selected for Vision; models that report image support
appear first. The endpoint owner is responsible for choosing an appropriate model.
Built-in model filtering is unchanged.
API keys and header values accept literals, `$NAME`, and `${NAME}`. References
use LearnHarness's existing key lookup: environment first, then the existing
flat `auth.json` file, respecting the fresh-user environment-ignore setting.
There is no auth migration or new secret file format. Prefer references over
putting secrets into `models.json`; protect any file containing literal secrets.
Unknown variable references keep the provider unavailable. All required header
references, including model-specific ones, must resolve before a provider becomes
available. An empty key does not configure a provider.
Missing discovery metadata does not silently remove images: **every custom-engine
image-bearing request attempts delivery**, including teaching `read` results,
rendered PDF pages, and visual-maker screenshots. A request-local model clone permits
Pi's native image encoding; registered/catalog metadata is not promoted to claim
that every model understands images. A text-only backend may reject the request;
that error is surfaced, with no silent text-only fallback. This does not establish
real-model image-understanding quality.
Pi's `$$` and `$!` literal escapes are supported. `!command` credential/header
values are rejected, never executed; resolved environment/file values are data,
not commands. Optional `authHeader: true` adds `Authorization: Bearer <key>` in
addition to the selected native transport's normal authentication behavior.
## Credentials, reloads, and concurrent editing
Only trusted people should edit this file: its endpoints receive model prompts,
attachments, tool results and configured credentials. Only HTTP(S) URLs are
accepted, without embedded URL credentials or fragments. This is not an
arbitrary-endpoint discovery or config-editing API and does not add a network
sandbox, endpoint reachability checks, or vendor policy exemptions.
Nonsecret configuration is stored in the existing `settings.json.customProvider`;
file-saved credentials use the existing `auth.json` key seam. A reserved internal
string entry, `__learnharness_custom_provider_binding`, binds authorization to the
endpoint, mode, and a random nonsecret `credentialBindingId` in custom settings.
The file key and its binding are published in one existing atomic auth-map write.
The internal entry is not an environment variable or an editable/displayed key.
Neither keys nor raw upstream errors are returned by the custom-provider API.
## Reloading and failures
Every startup and upstream operation requires matching binding IDs, URL, and mode
before using credentials. A crash between auth/settings writes, or a failed
settings write whose auth rollback also fails, leaves the custom provider disabled
rather than sending a new key to an old endpoint. Reload and explicitly reconnect
to repair authorization. A shadowed file key retains its own authorized URL: if an
environment key is reused for another URL and later disappears, the old file key
is not silently forwarded there. Keyless also requires its nonsecret binding, but
never resolves a stored/environment key. Disconnect leaves an internal disabled
marker so a latent environment key cannot revive an interrupted disconnect.
Changes take effect on the next catalog, settings-validation, or inference
operation without restarting. Credential/header reference changes are also
picked up through the existing key lookup. Every upstream model operation
revalidates the same saved provider/model ID against the current Pi catalog,
including later requests inside a long-running agent/tool loop. It uses the
current endpoint, model metadata and resolved credentials. A removed model,
invalid configuration or missing credential blocks the next operation, without
falling back. An HTTP operation already in flight retains its own native Pi
setup and is not implicitly cancelled by a file edit.
Use the custom endpoint editor to change custom credentials; generic
`/api/keys` PUT/DELETE cannot bypass binding. No credential-derived fingerprint is
stored in settings or a public revision. These guards cover process crashes and
restart using the existing atomic rename; they are not a power-loss/fsync
transaction system. Missing auth storage can initialize normally; unreadable or
malformed private storage fails closed without overwriting its contents.
Deleting the file means no custom providers. Invalid JSON, invalid metadata,
reserved IDs, unsupported configuration, or an unreadable file disables the
custom routes, **not** built-in providers. The file accepts Pi's line comments
and trailing commas. `GET /api/models` includes an optional sanitized
`customProvidersError` diagnostic; rendering it in setup/settings UI is deferred.
Correcting the file restores valid providers on the next operation.
Each upstream model operation resolves the current custom model, endpoint, effective
credential, limits, and original saved effort preference against Pi's current model
metadata. Already-started requests keep their setup. A removed model or disconnected
provider fails rather than falling back; saved model selections and history are not
rewritten.
Saved selections and thinking preferences are not erased when a provider/model
is removed or becomes unavailable. New work using an unavailable selection fails
rather than falling back to another model. Select another configured model to
repair the slot. Custom-provider removal does not rewrite historical records;
like existing API-key models, historical cost estimates use the current catalog
and become unknown when the relevant model definition is unavailable.
Edits use an opaque nonsecret ETag/If-Match revision. A stale edit returns **409** with
reload/retry guidance. A connect also checks configuration and effective credentials
after discovery, and merges only the custom section into the latest settings record,
preserving intervening unrelated edits. Failed mutations never claim success.
Desktop requests use the existing desktop capability. Browser mutations must be
same-origin JSON requests. Limits are **15 seconds** for discovery, **8 MiB** for its
response, **10,000** listed models, and **64 KiB** for configuration-write bodies.
These are protections for the new endpoint, not a multiuser authentication system.
## Pricing and previous development configuration
Custom model pricing is **unknown/unpriced**, not $0—even for an endpoint that may
be locally hosted. Pi-required internal zero-rate placeholders are not presented as
free inference. There is no rate editor in this release.
The earlier, unshipped app-data `models.json` feature is retired and is **not read**.
No user file is deleted or renamed, and there is no importer, coexistence mode,
profile reset, history rewrite, or silent model remapping. Existing development
profiles need manual endpoint configuration and model reselection. Unbound early
GUI development configurations also require explicit reconnect/new-key entry or
the existing reuse-consent checkbox, even at the same URL; they are never silently
trusted or migrated. This release
supports one OpenAI-compatible custom endpoint, not multiple arbitrary protocols.
+8
View File
@@ -80,6 +80,14 @@ async function probe(standalone) {
// factories, never the checkout's module or a direct/fake Google adapter.
const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js"));
const { createModels } = await import(entry.href);
// Keep every native chat implementation evaluable in the standalone
// payload even though the custom-provider UI intentionally exposes one API.
for (const api of ["anthropic-messages", "openai-completions", "openai-responses", "openai-codex-responses", "azure-openai-responses", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "mistral-conversations", "pi-messages"]) {
const implementation = await import(new URL(`api/${api}.js`, entry).href);
assert.equal(typeof implementation.streamSimple, "function", `${api}: native implementation missing`);
}
noNetwork();
console.log("[smoke-provider-runtime] all 10 native chat implementations evaluated offline");
const models = createModels();
for (const [name, factory, api, label] of [
["google", "googleProvider", "google-generative-ai", "Google"],
-71
View File
@@ -1,71 +0,0 @@
/** 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 { lazyApi } from "@earendil-works/pi-ai/api/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(),
// Pi's Bedrock wrapper intentionally hides its Node-only import from
// bundlers. Give this server's standalone tracer a literal public import;
// Pi still owns both lazy forwarding and the unchanged native transport.
"bedrock-converse-stream": lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")),
"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,
});
}
+16 -5
View File
@@ -9,7 +9,7 @@ import {
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 { lazyApi, 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";
@@ -82,7 +82,14 @@ export function configureProviderSource(source: ProviderSource): void {
export function piModels() {
if (!collection) {
collection = createModels({ identity: APP_IDENTITY });
for (const provider of builtinProviders()) collection.setProvider(provider);
for (const provider of builtinProviders()) {
// Keep the native Bedrock implementation reachable by standalone tracing.
// Pi's default Node-only loader hides this import from bundlers. Only
// replace its lazy forwarding; preserve the native factory's auth/catalog.
collection.setProvider(provider.id === "amazon-bedrock"
? { ...provider, ...lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")) }
: provider);
}
collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
}
providerSource?.sync(collection);
@@ -124,7 +131,12 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
const models: Models = piModels();
const nativeId = nativeSubscriptionId(spec.provider);
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
const registeredModel = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
const custom = providerSource?.isCustom(spec.provider) ?? false;
// Custom endpoint owners authorize image attempts independently of sparse
// discovery metadata. Never promote the registered/catalog capabilities.
const permitImages = (value: Model<Api>): Model<Api> => ({ ...value, input: ["text", "image"] });
const model = custom && registeredModel ? permitImages(registeredModel) : registeredModel;
if (!model) {
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
}
@@ -137,7 +149,6 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
: 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,
@@ -156,7 +167,7 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
const currentLevel = thinkingPreference
? clampThinkingLevel(currentModel, thinkingPreference)
: defaultThinkingLevel(currentModel);
return current.streamSimple(currentModel, context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel });
return current.streamSimple(permitImages(currentModel), context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel });
}
const requestOptions = { ...options, reasoning: level === "off" ? undefined : level };
const purpose = typeof background === "function" ? background : () => background;
-2
View File
@@ -75,8 +75,6 @@ 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. */
+26
View File
@@ -0,0 +1,26 @@
/** Private custom credential authorization; no credential-derived public value. */
import { normalizeBaseUrl, type CustomProviderSettings } from "./settings.ts";
export const CUSTOM_AUTH_BINDING = "__learnharness_custom_provider_binding";
export interface CustomAuthBinding {
version: 1;
id: string;
baseUrl?: string;
authMode: "apiKey" | "keyless" | "disabled";
/** A shadowed file key keeps its own destination when an env key moves. */
fileKeyBaseUrl?: string;
}
export function parseCustomAuthBinding(value: unknown): CustomAuthBinding | undefined {
try {
const b = typeof value === "string" ? JSON.parse(value) : undefined;
if (!b || b.version !== 1 || typeof b.id !== "string" || !/^[a-f0-9]{32}$/.test(b.id)) return;
if (!["apiKey", "keyless", "disabled"].includes(b.authMode)) return;
if (b.authMode !== "disabled" && b.baseUrl === undefined) return;
if (b.baseUrl !== undefined && (typeof b.baseUrl !== "string" || !b.baseUrl || normalizeBaseUrl(b.baseUrl) !== b.baseUrl)) return;
if (b.fileKeyBaseUrl !== undefined && (typeof b.fileKeyBaseUrl !== "string" || !b.fileKeyBaseUrl || normalizeBaseUrl(b.fileKeyBaseUrl) !== b.fileKeyBaseUrl)) return;
return { version: 1, id: b.id, authMode: b.authMode, ...(b.baseUrl !== undefined ? { baseUrl: b.baseUrl } : {}), ...(b.fileKeyBaseUrl !== undefined ? { fileKeyBaseUrl: b.fileKeyBaseUrl } : {}) };
} catch { return; }
}
export function customBindingMatches(cp: CustomProviderSettings | undefined, binding: CustomAuthBinding | undefined): boolean {
return Boolean(cp?.baseUrl && binding && binding.authMode !== "disabled" && cp.credentialBindingId === binding.id && cp.baseUrl === binding.baseUrl && cp.authMode === binding.authMode);
}
-146
View File
@@ -1,146 +0,0 @@
/** 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 };
});
}
+13 -9
View File
@@ -215,15 +215,12 @@ export interface ModelsSettings {
// secret in the auth store, never here. Registered into pi-ai as a normal
// provider (server/custom-provider.ts).
/** Defaults for a custom model — self-hosted endpoints are free. */
/** Budget fallbacks only; custom endpoint pricing is unknown. */
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
/** Floor for a user-pinned token count (owner, 2026-10-27): the read side
* clamps up, the write side rejects below — the compaction-floor discipline.
* Pins exist because endpoints that publish no limits (LM Studio) otherwise
* sit on the 8192 default with no way to say better. */
export const CUSTOM_MODEL_PIN_FLOOR = 2_048;
/** Custom budgets accept every positive integer, including small output caps. */
export const CUSTOM_MODEL_PIN_FLOOR = 1;
/** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are
* detected from the endpoint's /models metadata; anything it omits falls back
@@ -244,8 +241,8 @@ export interface CustomModel {
/** A user pin for one custom model: it beats what the endpoint publishes
* (owner ruling — explicit user intent wins; a local server's number is
* often wrong or absent). Absent field = no pin. `reasoning` is tri-state:
* true forces thinking on, false forces it off (a server that advertises
* reasoning but rejects reasoning_effort), absent = what discovery found. */
* true declares reasoning support, false removes that declaration, absent
* uses discovery. This is NOT a backend execution/disable switch. */
export interface CustomModelOverride {
context?: number;
maxTokens?: number;
@@ -254,6 +251,10 @@ export interface CustomModelOverride {
/** The single custom endpoint's stored config (delta section). */
export interface CustomProviderSettings {
/** Server-owned random authorization nonce; never a credential hash. */
credentialBindingId?: string;
/** Explicit authentication intent; never inferred from a saved key. */
authMode?: "keyless" | "apiKey";
/** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */
baseUrl?: string;
/** Models this endpoint offers: advertised ids merged with the typed
@@ -287,6 +288,7 @@ export function normalizeBaseUrl(raw: string): string {
return "";
}
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
if (url.username || url.password || url.search || url.hash || value.includes("?") || value.includes("#")) return "";
return url.toString().replace(/\/+$/, "");
}
@@ -625,7 +627,7 @@ export function sanitizeCustomModelOverrides(
const id = key.trim();
if (!id || (live && !live.has(id))) continue;
const pin = sanitizeCustomModelOverride(value);
if (pin) out[id] = pin;
if (pin) Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
}
return out;
}
@@ -635,6 +637,8 @@ export function sanitizeCustomModelOverrides(
* true; token counts only as positive ints). Clamp-never-reject. */
function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings {
const out: CustomProviderSettings = {};
if (typeof raw.credentialBindingId === "string" && /^[a-f0-9]{32}$/.test(raw.credentialBindingId)) out.credentialBindingId = raw.credentialBindingId;
if (raw.authMode === "keyless" || raw.authMode === "apiKey") out.authMode = raw.authMode;
if (typeof raw.baseUrl === "string") {
const base = normalizeBaseUrl(raw.baseUrl);
if (base) out.baseUrl = base;
+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();
+43 -4
View File
@@ -7,9 +7,10 @@
*
* The server (src/server/keys.ts) is the only intended writer: it applies
* shape validation before setAuthVar and env precedence on top of
* authVars(). This adapter persists what it is given, never throws
* (absent/corrupt file = {} = "no keys yet", the documented first-boot
* state, matching settings doctrine), and preserves unknown vars verbatim
* authVars(). Ordinary reads never throw (absent/corrupt file = {} =
* "no keys yet"); custom-only pair reads/writes below are deliberately
* strict so an unreadable private record is never overwritten as {}.
* Ordinary writes preserve unknown string vars verbatim
* (forward-compat: future entries — e.g. a plans-login token — ride along
* untouched until a catalog entry adopts them).
*/
@@ -18,6 +19,8 @@ import fs from "node:fs";
import path from "node:path";
import { AUTH_FILE } from "../../config.ts";
import type { AuthStore } from "../core/ports.ts";
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
import { CUSTOM_AUTH_BINDING, parseCustomAuthBinding, type CustomAuthBinding } from "../core/custom-provider-auth.ts";
/** The flat string→string map, or {} for absent/corrupt/non-object. */
function readMap(): Record<string, string> {
@@ -36,7 +39,7 @@ function readMap(): Record<string, string> {
* leave a 0644 file (secrets) or a truncated JSON (looks like "no keys").
* tmp-in-same-dir + rename keeps the swap atomic; chmod BEFORE the
* rename so the mode never exists relaxed on the final path. */
function writeMap(map: Record<string, string>): void {
function writeMap(map: Record<string, unknown>): void {
const dir = path.dirname(AUTH_FILE);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`);
@@ -50,14 +53,50 @@ export const authStore: AuthStore = {
return readMap();
},
setAuthVar(name, value) {
assertOrdinaryVar(name);
const map = readMap();
map[name] = value;
writeMap(map);
},
deleteAuthVar(name) {
assertOrdinaryVar(name);
const map = readMap();
if (!(name in map)) return; // absent = silent success, idempotent
delete map[name];
writeMap(map);
},
};
/** Custom writes must never turn an unreadable/corrupt private file into {}.
* Other providers retain their existing adapter semantics. Unknown JSON values
* survive custom-only updates, not just the ordinary string projection. */
function readCustomMap(): Record<string, unknown> {
let text: string;
try { text = fs.readFileSync(AUTH_FILE, "utf8"); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw new Error("Custom authorization storage unavailable"); }
let raw: unknown;
try { raw = JSON.parse(text); } catch { throw new Error("Custom authorization storage unavailable"); }
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Custom authorization storage unavailable");
const map = raw as Record<string, unknown>;
if (Object.hasOwn(map, CUSTOM_AUTH_BINDING) && !parseCustomAuthBinding(map[CUSTOM_AUTH_BINDING])) throw new Error("Invalid custom authorization record");
if (Object.hasOwn(map, CUSTOM_PROVIDER.envVar) && typeof map[CUSTOM_PROVIDER.envVar] !== "string") throw new Error("Invalid custom credential record");
return map;
}
function assertOrdinaryVar(name: string): void {
if (name === CUSTOM_AUTH_BINDING || name === CUSTOM_PROVIDER.envVar) throw new Error("Use the custom endpoint editor");
}
/** One immutable file snapshot. Keyless callers never invoke fileKey(). */
export function readCustomAuth() {
const map = readCustomMap();
return { binding: parseCustomAuthBinding(map[CUSTOM_AUTH_BINDING]), fileKey: () => map[CUSTOM_PROVIDER.envVar] as string | undefined };
}
/** Replace just the custom pair in ONE existing atomic record write. */
export function writeCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void {
const map = readCustomMap();
if (binding && !parseCustomAuthBinding(JSON.stringify(binding))) throw new Error("Invalid custom authorization record");
if (fileKey === undefined) delete map[CUSTOM_PROVIDER.envVar];
else Object.defineProperty(map, CUSTOM_PROVIDER.envVar, { value: fileKey, enumerable: true, configurable: true, writable: true });
if (binding === undefined) delete map[CUSTOM_AUTH_BINDING];
else Object.defineProperty(map, CUSTOM_AUTH_BINDING, { value: JSON.stringify(binding), enumerable: true, configurable: true, writable: true });
writeMap(map);
}
-12
View File
@@ -1,12 +0,0 @@
/** 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." };
}
}
+6 -7
View File
@@ -26,7 +26,6 @@ import {
buildOpenAiCompatibleProvider,
discoverModels,
mergeManualModels,
syncCustomProvider,
} from "../src/server/custom-provider.ts";
import { piModels } from "../src/agent/models.ts";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
@@ -92,13 +91,13 @@ describe("sanitizeUserSettings — customProvider", () => {
// ─── per-model pins: the read side + THE prune law ───────────────────────────
describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => {
it("keeps well-formed pins, clamps token counts up to the floor, keeps reasoning:false", () => {
it("keeps all positive integer pins and reasoning:false", () => {
const out = sanitizeUserSettings({
customProvider: {
baseUrl: "https://h/v1",
overrides: {
llama: { context: 131072, maxTokens: 8192, reasoning: true },
qwen: { context: 10 }, // under the floor → clamped up (clamp-never-reject on read)
qwen: { context: 10 }, // Small positive budgets are preserved.
quiet: { reasoning: false }, // a real pin, not an absent one
junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk
" ": { context: 4096 }, // no usable id
@@ -108,7 +107,7 @@ describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => {
});
assert.deepEqual(out?.customProvider?.overrides, {
llama: { context: 131072, maxTokens: 8192, reasoning: true },
qwen: { context: CUSTOM_MODEL_PIN_FLOOR },
qwen: { context: 10 },
quiet: { reasoning: false },
});
});
@@ -281,11 +280,11 @@ describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => {
assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]);
});
it("registers on a usable sync and removes when the base URL clears", () => {
syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] });
it("registers the native provider in the shared collection", () => {
piModels().setProvider(buildOpenAiCompatibleProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] }));
assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered");
assert.ok(piModels().getModel(CUSTOM_PROVIDER.id, "live"), "model resolvable");
syncCustomProvider({ baseUrl: "", models: [{ id: "live" }] });
piModels().deleteProvider(CUSTOM_PROVIDER.id);
assert.equal(piModels().getProvider(CUSTOM_PROVIDER.id), undefined, "provider removed");
});
});
+191
View File
@@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, beforeEach, test, mock } from "node:test";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-boundaries-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" };
const route = await import("../app/api/custom-provider/route.ts");
const keys = await import("../src/server/keys.ts");
const { authEnvRecord } = await import("../src/core/auth-env.ts");
const { CUSTOM_PROVIDER } = await import("../src/core/auth-keys.ts");
const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts");
const { readCustomAuth } = await import("../src/store/auth.ts");
const { persistCustomAuth } = await import("../src/server/custom-provider-auth.ts");
const settingsFile = path.join(scratch, "settings.json");
const seed = (cp: import("../src/core/settings.ts").CustomProviderSettings = { baseUrl: "http://old/v1", authMode: "keyless", models: [{ id: "m" }] }, key?: string) => fs.writeFileSync(settingsFile, JSON.stringify({ version: 1, customProvider: authorizeFixture(cp, key) }));
const setFileKey = (key: string) => { const auth = readCustomAuth(); persistCustomAuth({ ...auth.binding!, fileKeyBaseUrl: auth.binding!.baseUrl }, key); };
const current = () => JSON.parse(fs.readFileSync(settingsFile, "utf8"));
const get = (inspect = false) => route.GET(new Request(`http://localhost/api/custom-provider${inspect ? "?authMode=apiKey" : ""}`));
function mutate(body: unknown, method = "POST", headers: Record<string, string> = {}) {
return route.POST(new Request("http://localhost/api/custom-provider", { method, headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get().headers.get("etag")!, ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }));
}
let requests: { url: string; authorization: string | null }[];
beforeEach(() => {
mock.restoreAll();
process.env.LH_IGNORE_ENV_KEYS = "1";
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); seed();
requests = [];
globalThis.fetch = async (url, options) => {
requests.push({ url: String(url), authorization: new Headers(options?.headers).get("authorization") });
assert.equal(options?.redirect, "manual");
assert.ok(options?.signal instanceof AbortSignal);
return Response.json({ data: [{ id: "m" }] });
};
});
after(() => { mock.restoreAll(); process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); });
test("keyless probe never forwards a stored key, and clearing removes authentication", async () => {
setFileKey("private-old");
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "keyless", key: "do-not-use-this" })).status, 200);
assert.equal(requests[0]?.authorization, null);
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), undefined);
assert.equal(current().customProvider.authMode, "keyless");
});
test("new keys have no arbitrary minimum; the exact probed key is stored", async () => {
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: " k " })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer k");
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k");
const body = await get().text(); assert.ok(!body.includes("Bearer"));
});
test("changing destination cannot silently reuse an existing credential", async () => {
setFileKey("private-old");
const payload = { baseUrl: "http://new/v1", authMode: "apiKey" };
assert.equal((await mutate(payload)).status, 400); assert.equal(requests.length, 0);
assert.equal((await mutate({ ...payload, reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer private-old");
});
test("same-URL unbound development key needs existing explicit reuse consent", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "legacy-key");
const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts");
const file = path.join(scratch, "auth.json"); const map = JSON.parse(fs.readFileSync(file, "utf8")); delete map[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(map));
assert.equal((await get().json()).keyReuseRequired, true);
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 400); assert.equal(requests.length, 0);
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey", reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer legacy-key"); assert.equal((await get().json()).keyReuseRequired, false);
});
test("generic custom/reserved key writes cannot bypass the atomic binding seam", async () => {
const ordinary = await import("../app/api/keys/[id]/route.ts");
const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts");
const { authStore } = await import("../src/store/auth.ts");
const before = fs.readFileSync(path.join(scratch, "auth.json"), "utf8");
for (const id of [CUSTOM_PROVIDER.envVar, CUSTOM_AUTH_BINDING]) {
const context = { params: Promise.resolve({ id }) };
assert.equal((await ordinary.PUT(new Request("http://localhost", { method: "PUT", body: JSON.stringify({ value: "bypass" }) }), context)).status, 404);
assert.equal((await ordinary.DELETE(new Request("http://localhost", { method: "DELETE" }), context)).status, 404);
assert.equal(keys.saveKey(id, "bypass").ok, false); assert.throws(() => keys.deleteKey(id));
assert.throws(() => authStore.setAuthVar(id, "bypass")); assert.throws(() => authStore.deleteAuthVar(id));
}
assert.equal(fs.readFileSync(path.join(scratch, "auth.json"), "utf8"), before);
assert.ok(!keys.keyStatuses().some(row => row.id === CUSTOM_AUTH_BINDING)); assert.equal(process.env[CUSTOM_AUTH_BINDING], undefined);
});
test("same target can retain its effective key", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "retained");
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer retained");
});
test("environment ownership is shown; consent uses the environment key in probe and inference", async () => {
process.env.LH_IGNORE_ENV_KEYS = "0";
process.env[CUSTOM_PROVIDER.envVar] = "environment-secret";
authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar);
try {
assert.equal((await get(true).json()).keySource, "env");
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: "shadowed" })).status, 400);
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer environment-secret");
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "environment-secret");
assert.ok(!(await get().text()).includes("environment-secret"));
assert.equal((await mutate({ baseUrl: "http://other/v1", authMode: "keyless" })).status, 200);
assert.equal(requests.at(-1)?.authorization, null);
} finally { authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; }
});
test("cross-origin/opaque/missing-origin and non-JSON writes cannot probe or mutate", async () => {
for (const origin of ["http://evil", "null", ""]) assert.equal((await mutate({ baseUrl: "http://new", authMode: "keyless" }, "POST", { origin })).status, 403);
assert.equal((await mutate({}, "POST", { "content-type": "text/plain" })).status, 415);
assert.equal((await mutate({}, "POST", { "sec-fetch-site": "cross-site" })).status, 403);
assert.equal(requests.length, 0);
});
test("standalone internal URL uses the addressed Host origin, never forwarded origins", async () => {
const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, host: "127.0.0.1:3317", origin: "http://127.0.0.1:3317" };
assert.equal((await route.DELETE(new Request("http://localhost:3317/api/custom-provider", { method: "DELETE", headers }))).status, 200);
assert.equal((await mutate({}, "DELETE", { "x-forwarded-host": "evil", origin: "http://evil" })).status, 403);
});
test("empty DELETE streams normalize consistently without accepting malformed JSON", async () => {
const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, origin: "http://localhost" };
const init: RequestInit & { duplex: "half" } = { method: "DELETE", headers, body: new ReadableStream({ start(controller) { controller.close(); } }), duplex: "half" };
assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", init))).status, 200);
assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", { method: "DELETE", headers: { ...headers, "if-match": get().headers.get("etag")! }, body: "{" }))).status, 400);
assert.equal((await route.POST(new Request("http://localhost/api/custom-provider", { method: "POST", headers, body: "" }))).status, 400);
});
test("desktop capability remains mandatory in desktop mode", async () => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "x".repeat(64);
assert.equal((await mutate({}, "DELETE")).status, 403);
assert.equal((await mutate(undefined, "DELETE", { origin: "null", "x-learnharness-desktop": "x".repeat(64) })).status, 200);
});
test("forbidden URL components are rejected before probing", async () => {
for (const baseUrl of ["ftp://h", "https://user:pass@h/v1", "http://h/v1?q=secret", "http://h/v1#x", "http://h/v1?", "http://h/v1#"]) assert.equal((await mutate({ baseUrl, authMode: "keyless" })).status, 400);
assert.equal(requests.length, 0);
});
test("redirects/auth failures/model and byte limits cannot be bypassed with typed IDs", async () => {
for (const answer of [
() => new Response(null, { status: 302, headers: { location: "http://other" } }),
() => new Response("private response", { status: 401 }),
() => new Response("private response", { status: 403 }),
() => Response.json({ data: Array(10001).fill({ id: "m" }) }),
() => new Response("x".repeat(8 * 1024 * 1024 + 1)),
() => new Response("{}", { headers: { "content-length": String(8 * 1024 * 1024 + 1) } }),
]) {
globalThis.fetch = async () => answer();
const before = current();
const res = await mutate({ baseUrl: "http://h/v1", authMode: "keyless", manualModels: "m" });
assert.equal(res.status, 502); assert.deepEqual(current(), before);
assert.ok(!(await res.text()).includes("private response"));
}
});
test("write size bounds are explicit for streamed bodies", async () => {
assert.equal((await mutate({ baseUrl: "x".repeat(65536), authMode: "keyless" })).status, 413);
assert.equal((await mutate({ padding: "x".repeat(65536) }, "DELETE")).status, 413);
assert.equal(requests.length, 0);
});
test("stale save, pin edit and disconnect all return reload guidance without writing", async () => {
const etag = get().headers.get("etag")!;
seed({ baseUrl: "http://other/v1", authMode: "keyless", models: [{ id: "different" }] });
const before = current();
for (const method of ["POST", "PATCH", "DELETE"]) {
const res = await mutate({ baseUrl: "http://h", authMode: "keyless", overrides: {} }, method, { "if-match": etag });
assert.equal(res.status, 409); assert.match(await res.text(), /Reload/);
}
assert.equal(requests.length, 0); assert.deepEqual(current(), before);
});
test("after discovery preserves unrelated edits and rejects intervening custom edits", async () => {
globalThis.fetch = async () => { fs.writeFileSync(settingsFile, JSON.stringify({ ...current(), appearance: { mode: "dark" }, claudeCode: { enabled: true } })); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 200);
assert.deepEqual(current().appearance, { mode: "dark" }); assert.equal(current().claudeCode.enabled, true);
globalThis.fetch = async () => { seed({ baseUrl: "http://changed", authMode: "keyless", models: [] }); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 409);
assert.equal(current().customProvider.baseUrl, "http://changed");
});
test("after discovery rejects a changed effective key", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "before");
globalThis.fetch = async () => { setFileKey("rotated"); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 409);
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "rotated");
});
test("offline pins accept 512/1, retain support declaration, and do not probe", async () => {
const res = await mutate({ overrides: { m: { context: 1, maxTokens: 512, reasoning: false } } }, "PATCH");
assert.equal(res.status, 200); assert.equal(requests.length, 0);
assert.deepEqual(current().customProvider.overrides.m, { context: 1, maxTokens: 512, reasoning: false });
});
test("settings-write failure restores previous key/config and reports failure", async () => {
setFileKey("old-key");
const before = current();
const write = fs.writeFileSync;
mock.method(fs, "writeFileSync", (...args: Parameters<typeof write>) => { if (args[0] === settingsFile) throw new Error("private-io-details"); return write(...args); });
const res = await mutate({ baseUrl: "http://new", authMode: "apiKey", key: "new-key" });
assert.equal(res.status, 500); assert.ok(!(await res.text()).includes("private-io-details"));
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "old-key"); assert.deepEqual(current(), before);
});
+129
View File
@@ -0,0 +1,129 @@
/** Native Pi payloads with synthetic transport and owned scratch files only. */
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, beforeEach, test } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import { normalizeContext, fauxAssistantMessage, fauxToolCall, type Message } from "@earendil-works/pi-ai";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-lifecycle-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" };
const { writeSettings, readSettings } = await import("../src/store/settings.ts");
const { piEngineForSpec, piModels } = await import("../src/agent/models.ts");
await import("../src/server/keys.ts");
const { readCustomAuth } = await import("../src/store/auth.ts");
const { persistCustomAuth, customAuthState } = await import("../src/server/custom-provider-auth.ts");
const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts");
const { modelCatalog, catalogContextLimits } = await import("../src/server/model-catalog.ts");
const { callCost } = await import("../src/server/pricing.ts");
const { createReadTool } = await import("../src/agent/tools/read.ts");
const { installCustomProviderSource } = await import("../src/server/custom-provider.ts");
let requests: { url: string; auth: string | null; body: Record<string, unknown> }[];
function configure(baseUrl = "http://first/v1", reasoning = false, maxTokens = 512, authMode: "keyless" | "apiKey" = "apiKey") {
const key = authMode === "apiKey" ? readCustomAuth().fileKey() ?? "first-key" : undefined;
writeSettings({ version: 1, customProvider: authorizeFixture({ baseUrl, authMode, models: [{ id: "m", context: 16384, maxTokens, reasoning }] }, key) });
}
function answer() {
return new Response(`data: ${JSON.stringify({ id: "fake", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } });
}
function capture(url: unknown, init?: RequestInit) {
requests.push({ url: String(url), auth: new Headers(init?.headers).get("authorization"), body: JSON.parse(String(init?.body)) });
}
const context = (messages: Message[] = [{ role: "user", content: "synthetic", timestamp: 0 }]) => normalizeContext({ messages });
const run = (engine: ReturnType<typeof piEngineForSpec>, messages?: Message[]) => engine.streamFn(engine.model, context(messages), {}).result();
beforeEach(() => {
requests = []; fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); configure();
globalThis.fetch = async (url, init) => { capture(url, init); return answer(); };
});
after(() => { process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); });
test("native requests revalidate endpoint/model/key/limits with no fallback and retain in-flight setup", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
let started!: () => void; const ready = new Promise<void>((r) => { started = r; });
let release!: () => void;
globalThis.fetch = async (url, init) => { capture(url, init); started(); return new Promise<Response>((resolve) => { release = () => resolve(answer()); }); };
const inFlight = run(engine); await ready;
configure("http://second/v1", false, 1024); persistCustomAuth(readCustomAuth().binding, "second-key");
globalThis.fetch = async (url, init) => { capture(url, init); return answer(); };
assert.equal((await run(engine)).stopReason, "stop");
release(); assert.equal((await inFlight).stopReason, "stop");
assert.match(requests[0]!.url, /^http:\/\/first\/v1\/chat\/completions$/);
assert.equal(requests[0]!.auth, "Bearer first-key"); assert.equal(requests[0]!.body.max_completion_tokens, 512);
assert.match(requests[1]!.url, /^http:\/\/second\/v1\/chat\/completions$/);
assert.equal(requests[1]!.auth, "Bearer second-key"); assert.equal(requests[1]!.body.max_completion_tokens, 1024);
writeSettings({ version: 1, customProvider: { ...readSettings()!.customProvider, models: [{ id: "other" }] } });
assert.ok(piModels().getProvider("custom"), "provider stays authorized while this model is removed");
assert.equal((await run(engine)).stopReason, "error");
writeSettings({ version: 1 }); assert.equal((await run(engine)).stopReason, "error");
assert.equal(requests.length, 2);
assert.ok(piModels().getProvider("openai"), "native route survives custom removal");
});
test("current key changes without configuration edits are reflected on the next operation", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
await run(engine); persistCustomAuth(readCustomAuth().binding, "rotated-key"); await run(engine);
assert.deepEqual(requests.map((r) => r.auth), ["Bearer first-key", "Bearer rotated-key"]);
});
test("original saved thinking preference recovers after capability expansion and contracts again", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m", thinkingLevel: "high" });
assert.equal(engine.level, "off");
await run(engine); configure("http://first/v1", true); await run(engine); configure(); await run(engine);
assert.deepEqual(requests.map((r) => r.body.reasoning_effort), [undefined, "high", undefined]);
});
test("unset preference reselects the existing default against current reasoning metadata", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
configure("http://first/v1", true); await run(engine);
assert.equal(requests[0]!.body.reasoning_effort, "medium");
configure(); await run(engine); assert.equal(requests[1]!.body.reasoning_effort, undefined);
});
test("catalog capabilities remain reported and prices unknown despite runtime image permission", () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
assert.deepEqual(engine.model.input, ["text", "image"]);
const dto = modelCatalog().models.find((m) => m.provider === "custom")!;
assert.deepEqual(dto.input, ["text"]); assert.equal(dto.cost, null);
assert.equal(callCost({ inputTokens: 100, outputTokens: 100 }, "custom:m"), null);
assert.equal(catalogContextLimits().get("custom:m"), 16384);
});
const image = { type: "image" as const, data: createCanvas(1, 1).toBuffer("image/png").toString("base64"), mimeType: "image/png" };
test("actual native encoder preserves USER and tool-result images under missing metadata, including after reload", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
const messages: Message[] = [
{ role: "user", content: [{ type: "text", text: "look" }, image], timestamp: 0 },
fauxAssistantMessage([fauxToolCall("read", {}, { id: "read-1" })], { stopReason: "toolUse" }),
{ role: "toolResult", toolCallId: "read-1", toolName: "read", content: [image], isError: false, timestamp: 1 },
];
await run(engine, messages); configure("http://second/v1"); await run(engine, messages);
for (const request of requests) {
const body = JSON.stringify(request.body.messages);
assert.equal((body.match(/"type":"image_url"/g) ?? []).length, 2);
assert.equal((body.match(/data:image\/png;base64/g) ?? []).length, 2);
assert.ok(!body.includes("image omitted"));
}
assert.deepEqual(piModels().getModel("custom", "m")!.input, ["text"]);
});
test("teacher-bound read returns image without contradictory omission note", async () => {
const file = path.join(scratch, "synthetic.png"); fs.writeFileSync(file, Buffer.from(image.data, "base64"));
const engine = piEngineForSpec({ provider: "custom", id: "m" });
const result = await createReadTool(engine.model).execute("read-1", { path: file });
assert.ok(result.content.some((block) => block.type === "image"));
assert.ok(!JSON.stringify(result).includes("image will be omitted"));
});
test("backend image rejection surfaces once, never retries without images", async () => {
globalThis.fetch = async (url, init) => { capture(url, init); return Response.json({ error: { message: "images rejected by backend" } }, { status: 400 }); };
const result = await run(piEngineForSpec({ provider: "custom", id: "m" }), [{ role: "user", content: [image], timestamp: 0 }]);
assert.equal(result.stopReason, "error"); assert.match(result.errorMessage ?? "", /images rejected/); assert.equal(requests.length, 1);
});
test("existing engine refuses nonce mismatch on EVERY operation, without falling back to env", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" }); await run(engine);
persistCustomAuth({ ...readCustomAuth().binding!, id: "b".repeat(32) }, "different-key");
assert.equal((await run(engine)).stopReason, "error"); assert.equal(requests.length, 1);
});
test("keyless publication never reads the credential lookup", async () => {
configure("http://first/v1", false, 512, "keyless");
let credentialLookups = 0;
installCustomProviderSource((inspect, cp) => { if (inspect) credentialLookups++; return customAuthState(inspect, cp); });
try { assert.equal((await run(piEngineForSpec({ provider: "custom", id: "m" }))).stopReason, "stop"); assert.equal(credentialLookups, 0); assert.equal(requests[0]!.auth, "Bearer not-needed"); }
finally { installCustomProviderSource(); }
});
+81
View File
@@ -0,0 +1,81 @@
/** No real endpoints/accounts. Actual process death and cold startup, not HMR. */
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { after, test } from "node:test";
import { CUSTOM_AUTH_BINDING } from "../src/core/custom-provider-auth.ts";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-restart-"));
after(() => fs.rmSync(root, { recursive: true, force: true }));
const base = "http://first.invalid/v1";
function profile() {
const home = fs.mkdtempSync(path.join(root, "case-"));
const id = "a".repeat(32);
fs.writeFileSync(path.join(home, "settings.json"), JSON.stringify({ version: 1, appearance: { mode: "dark" }, customProvider: { baseUrl: base, authMode: "apiKey", credentialBindingId: id, models: [{ id: "m" }] } }));
fs.writeFileSync(path.join(home, "auth.json"), JSON.stringify({ CUSTOM_OPENAI_API_KEY: "fixture-old-key", OTHER_PROVIDER_KEY: "untouched", future: { preserve: true }, [CUSTOM_AUTH_BINDING]: JSON.stringify({ version: 1, id, baseUrl: base, authMode: "apiKey", fileKeyBaseUrl: base }) }), { mode: 0o600 });
return home;
}
function child(home: string, mode: "mutate" | "inspect", action = "key", fault = "none", envKey?: string) {
const result = spawnSync(process.execPath, ["--import", "tsx", "tests/helpers/custom-provider-crash.ts", mode, action, fault], { cwd: process.cwd(), env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, NODE_ENV: "test", LH_DATA_DIR: home, LH_AUTH_FILE: path.join(home, "auth.json"), LH_IGNORE_ENV_KEYS: "0", ...(envKey ? { CUSTOM_OPENAI_API_KEY: envKey } : {}) }, encoding: "utf8", timeout: 15000 });
assert.ifError(result.error);
assert.ok(result.status === 0 || result.status === 77, `${result.status}: ${result.stderr}`);
return { exit: result.status, data: result.stdout.trim() ? JSON.parse(result.stdout.trim().split("\n").at(-1)!) : undefined };
}
function closed(home: string, envKey?: string) {
const { data } = child(home, "inspect", "key", "none", envKey);
assert.equal(data.enabled, false); assert.deepEqual(data.requests, []); assert.equal(data.metadataInEnv, false);
assert.ok(!data.statusIds.includes(CUSTOM_AUTH_BINDING));
}
test("cold restart refuses the new auth/old URL gap and double-I/O failure, with or without env", () => {
for (const fault of ["after-auth", "double-io"]) {
const home = profile(); const result = child(home, "mutate", "key", fault);
if (fault === "after-auth") assert.equal(result.exit, 77); else assert.equal(result.data.status, 500);
closed(home); closed(home, "fixture-real-env");
const auth = JSON.parse(fs.readFileSync(path.join(home, "auth.json"), "utf8"));
assert.equal(auth.OTHER_PROVIDER_KEY, "untouched"); assert.deepEqual(auth.future, { preserve: true });
}
});
test("failed auth publication retains old matching setup; landed settings plus restored auth fails closed", () => {
const home = profile(); assert.equal(child(home, "mutate", "key", "auth-before-rename").data.status, 500);
const old = child(home, "inspect").data; assert.equal(old.enabled, true); assert.equal(old.requests[0].url, base + "/chat/completions"); assert.equal(old.requests[0].authorization, "Bearer fixture-old-key");
const other = profile(); assert.equal(child(other, "mutate", "key", "settings-landed").data.status, 500); closed(other);
});
test("clear/disconnect half commits stay closed across restart even with genuine env keys", () => {
for (const action of ["keyless", "disconnect"]) {
for (const fault of ["after-auth", "double-io"]) {
const home = profile(); child(home, "mutate", action, fault, "fixture-real-env"); closed(home, "fixture-real-env");
}
}
});
test("successful keyless checks only binding; disconnect tombstone blocks latent env after restart", () => {
const home = profile(); assert.equal(child(home, "mutate", "keyless", "none", "fixture-real-env").data.status, 200);
const state = child(home, "inspect", "key", "none", "fixture-real-env").data; assert.equal(state.enabled, true); assert.equal(state.requests[0].authorization, "Bearer not-needed");
assert.equal(child(home, "mutate", "disconnect", "none", "fixture-real-env").data.status, 200); closed(home, "fixture-real-env");
});
test("env reuse never reauthorizes a shadowed file key for another destination", () => {
const home = profile(); assert.equal(child(home, "mutate", "envMove", "none", "fixture-real-env").data.status, 200);
const live = child(home, "inspect", "key", "none", "fixture-real-env").data; assert.equal(live.enabled, true); assert.equal(live.requests[0].url, "http://second.invalid/v1/chat/completions"); assert.equal(live.requests[0].authorization, "Bearer fixture-real-env");
closed(home);
const auth = JSON.parse(fs.readFileSync(path.join(home, "auth.json"), "utf8")); assert.equal(JSON.parse(auth[CUSTOM_AUTH_BINDING]).fileKeyBaseUrl, base); assert.equal(auth.CUSTOM_OPENAI_API_KEY, "fixture-old-key");
assert.equal(child(home, "mutate", "reuse").data.status, 200); assert.equal(child(home, "inspect").data.requests[0].authorization, "Bearer fixture-old-key");
});
test("unbound development records require explicit manual reauthorization, never automatic migration", () => {
const home = profile(); const file = path.join(home, "auth.json"); const auth = JSON.parse(fs.readFileSync(file, "utf8")); delete auth[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(auth));
closed(home); closed(home, "fixture-real-env");
assert.equal(child(home, "mutate", "reuse").data.status, 200); assert.equal(child(home, "inspect").data.enabled, true);
});
test("malformed/unreadable private snapshots are never rewritten or used, unrelated bytes survive", () => {
for (const raw of ['{"OTHER_PROVIDER_KEY":"do-not-erase",', '[]', JSON.stringify({ OTHER_PROVIDER_KEY: "do-not-erase", [CUSTOM_AUTH_BINDING]: "not-json" })]) {
const home = profile(); const file = path.join(home, "auth.json"); fs.writeFileSync(file, raw);
closed(home, "fixture-real-env"); assert.equal(child(home, "mutate").data.status, 500); assert.equal(fs.readFileSync(file, "utf8"), raw);
}
const home = profile(); const file = path.join(home, "auth.json"); const before = fs.readFileSync(file, "utf8");
assert.equal(child(home, "mutate", "key", "unreadable").data.status, 500); assert.equal(fs.readFileSync(file, "utf8"), before);
assert.equal(child(home, "inspect", "key", "unreadable", "fixture-real-env").data.enabled, false);
});
test("missing auth file initializes normally with explicit new key, preserving other settings", () => {
const home = profile(); fs.unlinkSync(path.join(home, "auth.json")); closed(home);
assert.equal(child(home, "mutate").data.status, 200); const live = child(home, "inspect").data; assert.equal(live.enabled, true); assert.equal(live.requests[0].authorization, "Bearer fixture-new-key");
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(home, "settings.json"), "utf8")).appearance, { mode: "dark" });
});
+3 -2
View File
@@ -37,6 +37,7 @@ const CONNECTED = {
version: 1,
customProvider: {
baseUrl: BASE,
authMode: "keyless",
models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }],
overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } },
manualModels: ["m1"],
@@ -59,8 +60,8 @@ function post(payload: unknown): Promise<Response> {
return route.POST(
new Request("http://localhost/api/custom-provider", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
headers: { "content-type": "application/json", origin: "http://localhost", "if-match": route.GET(new Request("http://localhost/api/custom-provider")).headers.get("etag")! },
body: JSON.stringify({ authMode: payload && typeof payload === "object" && "key" in payload ? "apiKey" : "keyless", ...payload as object }),
}),
);
}
-362
View File
@@ -1,362 +0,0 @@
import assert from "node:assert/strict";
import { after, describe, it } from "node:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createModels, normalizeContext, type SimpleStreamOptions } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { parseCustomProviders, resolveCustomValue } from "../src/core/custom-providers.ts";
import { createCustomProvider, customProviderApis } from "../src/agent/custom-providers.ts";
import { CLAUDE_CODE_PROVIDER } from "../src/core/contracts/claude-code.ts";
const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
function model(id = "chat", extra = {}) {
return { id, contextWindow: 8192, maxTokens: 1024, cost: zero, ...extra };
}
function config(extra = {}, models = [model()]) {
return { providers: { gateway: { baseUrl: "http://example.invalid/custom/v1", api: "openai-completions", apiKey: "dummy", models, ...extra } } };
}
function parse(value: unknown) { return parseCustomProviders(JSON.stringify(value), new Set(["openai", "chatgpt"]), customProviderApis); }
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-providers-"));
process.env.LH_DATA_DIR = temp;
process.env.LH_AUTH_FILE = path.join(temp, "auth.json");
process.env.LH_IGNORE_ENV_KEYS = "1";
fs.writeFileSync(process.env.LH_AUTH_FILE, JSON.stringify({ GW_KEY: "first-key", GW_HEADER: "first-header" }));
const { CUSTOM_MODELS_FILE, SETTINGS_FILE } = await import("../config.ts");
const { modelCatalog, validateModelsSettings, clampModelsLevels, catalogContextLimits } = await import("../src/server/model-catalog.ts");
const { piModels, piEngineForSpec } = await import("../src/agent/models.ts");
const { providerKeyPresent, keyValue } = await import("../src/server/keys.ts");
const { authStore } = await import("../src/store/auth.ts");
const { CustomProviderSource, customProviderConfigured, installCustomProviderSource } = await import("../src/server/custom-providers.ts");
const { callCost } = await import("../src/server/pricing.ts");
after(() => fs.rmSync(temp, { recursive: true, force: true }));
function save(value: unknown) { fs.writeFileSync(CUSTOM_MODELS_FILE, JSON.stringify(value)); }
function sseText(text = "ok") {
const chunks = [
{ choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] },
{ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } },
];
return new Response(chunks.map((chunk) => `data: ${JSON.stringify({ id: "test", object: "chat.completion.chunk", created: 1, model: "chat", ...chunk })}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } });
}
describe("declarative custom-provider configuration", () => {
it("uses Pi metadata and provider/model inheritance without rewriting base URLs", () => {
const [parsed] = parse(config({ compat: { supportsStore: false, chatTemplateKwargs: { a: true } } }, [model("named", {
name: "Named model", reasoning: true, input: ["text", "image"], thinkingLevelMap: { high: "high", xhigh: null },
compat: { maxTokensField: "max_tokens", chatTemplateKwargs: { b: false } },
samplingParams: { temperature: 0.5 }, samplingParamsByThinkingLevel: { high: { top_p: 0.9 } },
})]));
assert.equal(parsed!.models[0]!.baseUrl, "http://example.invalid/custom/v1");
assert.equal(parsed!.models[0]!.provider, "gateway");
assert.equal(parsed!.models[0]!.name, "Named model");
assert.deepEqual(parsed!.models[0]!.input, ["text", "image"]);
assert.deepEqual(parsed!.models[0]!.compat, { supportsStore: false, maxTokensField: "max_tokens", chatTemplateKwargs: { a: true, b: false } });
assert.deepEqual(parsed!.models[0]!.samplingParamsByThinkingLevel, { high: { top_p: 0.9 } });
});
it("accepts Pi line comments, trailing commas and BOM without corrupting strings", () => {
const text = `\uFEFF${JSON.stringify(config()).replace('"providers":', '// comment\n"providers":').replace(/}$/, ',}')}`;
assert.equal(parseCustomProviders(text, new Set(), customProviderApis)[0]!.models[0]!.baseUrl, "http://example.invalid/custom/v1");
});
it("registers multiple providers and mixed native APIs without fixed backend IDs", () => {
const value = config();
const raw = { providers: { ...value.providers, other: { ...value.providers.gateway, models: [model("chat"), model("anthropic", { api: "anthropic-messages", baseUrl: "https://example.invalid/anthropic" })] } } };
const models = createModels();
for (const entry of parse(raw)) models.setProvider(createCustomProvider(entry, () => undefined));
assert.equal(models.getProviders().length, 2);
assert.equal(models.getModel("other", "anthropic")!.api, "anthropic-messages");
assert.equal(models.getModel("other", "anthropic")!.baseUrl, "https://example.invalid/anthropic");
});
it("requires explicit finite limits and nonnegative costs, instead of guessing", () => {
for (const field of ["contextWindow", "maxTokens", "cost"]) {
const m: Record<string, unknown> = model(); delete m[field];
assert.throws(() => parse(config({}, [m as ReturnType<typeof model>])));
}
assert.throws(() => parse(config({}, [model("chat", { contextWindow: -1 })])));
assert.throws(() => parse(config({}, [model("chat", { cost: { ...zero, output: -1 } })])));
assert.throws(() => parseCustomProviders(JSON.stringify(config()).replace('"contextWindow":8192', '"contextWindow":1e999'), new Set(), customProviderApis));
});
it("rejects collisions, duplicate IDs, unsupported protocols, executable fields and invalid URLs", () => {
for (const id of ["openai", "chatgpt", "", "has:colon"]) assert.throws(() => parse({ providers: { [id]: config().providers.gateway } }));
assert.throws(() => parse(config({}, [model(), model()])));
assert.throws(() => parse(config({ api: "unknown-api" })));
assert.throws(() => parse(config({ oauth: "radius" })));
assert.throws(() => parse(config({ modelOverrides: {} })));
assert.throws(() => parse(config({ apiKey: "!touch /never-execute" })));
assert.throws(() => parse(config({ headers: { "x-key": "!secret-command" } })));
assert.throws(() => parse(config({}, [model("chat", { headers: { Authorization: "!command" } })])));
for (const baseUrl of ["file:///tmp/model", "https://secret@example.invalid", "http://example.invalid/#fragment"]) assert.throws(() => parse(config({ baseUrl })));
});
it("matches Pi value interpolation and escapes, without recursive expansion or commands", () => {
const lookup = (key: string) => ({ KEY: "secret", DATA: "!never-executed" })[key as "KEY"];
assert.equal(resolveCustomValue("Bearer ${KEY}/$KEY", lookup), "Bearer secret/secret");
assert.equal(resolveCustomValue("$$KEY $!literal", lookup), "$KEY !literal");
assert.equal(resolveCustomValue("$MISSING", lookup), undefined);
assert.equal(resolveCustomValue("$DATA", lookup), "!never-executed");
});
});
describe("custom providers in the existing application wiring", () => {
it("is empty before configuration and never creates a configuration file", () => {
assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false);
assert.equal(modelCatalog().customProvidersError, undefined);
assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false);
});
it("projects into existing pickers, validation, context limits and Pi pricing", async () => {
save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" } }, [model("chat", { cost: { ...zero, input: 2, output: 4 } })]));
const catalog = modelCatalog();
assert.ok(catalog.providers.find((p) => p.id === "gateway")?.keyPresent);
assert.ok(catalog.models.some((m) => m.provider === "gateway" && m.id === "chat"));
assert.equal(JSON.stringify(catalog).includes("first-key"), false);
assert.equal(JSON.stringify(catalog).includes("first-header"), false);
assert.equal(validateModelsSettings({ strong: { model: { provider: "gateway", id: "chat" } } }).ok, true);
assert.equal(catalogContextLimits().get("gateway:chat"), 8192);
assert.ok(Math.abs(callCost({ inputTokens: 100, outputTokens: 50 }, "gateway:chat")! - 0.0004) < 1e-12);
assert.equal(providerKeyPresent("gateway"), true);
assert.equal((await piModels().getAuth("gateway"))!.auth.apiKey, "first-key");
});
it("uses current app key precedence and marks unresolved credentials/headers unavailable", () => {
process.env.LH_IGNORE_ENV_KEYS = "0";
process.env.GW_KEY = "real-env-key";
try { assert.equal(keyValue("GW_KEY"), "real-env-key"); }
finally { process.env.LH_IGNORE_ENV_KEYS = "1"; delete process.env.GW_KEY; }
save(config({ apiKey: "$MISSING_KEY" }));
assert.equal(providerKeyPresent("gateway"), false);
assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false);
save(config({ headers: { "x-required": "$MISSING_HEADER" } }));
assert.equal(providerKeyPresent("gateway"), false);
save(config({ apiKey: undefined }));
assert.equal(providerKeyPresent("gateway"), false, "Pi-style keyless endpoints need an explicit dummy key");
});
it("revalidates every request of one engine while leaving an in-flight HTTP operation alone", async () => {
save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, maxTokensField: "max_tokens" } }));
const engine = piEngineForSpec({ provider: "gateway", id: "chat" });
const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] });
let started!: () => void, release!: (response: Response) => void;
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
const firstResponse = new Promise<Response>((resolve) => { release = resolve; });
const requests: { url: string; headers: Headers; body: Record<string, unknown> }[] = [];
const original = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
requests.push({ url: request.url, headers: request.headers, body: await request.json() as Record<string, unknown> });
if (requests.length === 1) { started(); return firstResponse; }
return sseText("héllo 🌍");
};
const pending = engine.streamFn(engine.model, context);
try {
await requestStarted;
authStore.setAuthVar("GW_KEY", "second-key");
authStore.setAuthVar("GW_HEADER", "second-header");
save(config({ baseUrl: "https://new.example.invalid/inference/v1", apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, maxTokensField: "max_tokens" } }, [model("chat", { maxTokens: 512 })]));
const second = await engine.streamFn(engine.model, context).result();
assert.equal(second.stopReason, "stop");
assert.deepEqual(second.content, [{ type: "text", text: "héllo 🌍" }]);
assert.equal(second.usage.input, 10);
assert.equal(second.usage.output, 2);
// Credential-only edits must also take effect on this same engine.
authStore.setAuthVar("GW_KEY", "third-key");
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "stop");
// None of these cases may dispatch using the engine's old model/key.
save(config({ apiKey: "$MISSING_KEY" }));
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error");
save(config({}, [model("other-model")]));
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error");
fs.writeFileSync(CUSTOM_MODELS_FILE, "invalid configuration");
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error");
fs.rmSync(CUSTOM_MODELS_FILE);
assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error");
release(sseText("original request finished"));
const first = await pending.result();
assert.equal(first.stopReason, "stop");
assert.deepEqual(first.content, [{ type: "text", text: "original request finished" }]);
} finally {
release(sseText());
await pending.result();
globalThis.fetch = original;
}
assert.equal(requests.length, 3, "unavailable configurations must not cause additional transport calls");
assert.equal(requests[0]!.url, "http://example.invalid/custom/v1/chat/completions");
assert.equal(requests[0]!.headers.get("authorization"), "Bearer first-key");
assert.equal(requests[0]!.headers.get("x-gateway"), "first-header");
assert.equal(requests[1]!.url, "https://new.example.invalid/inference/v1/chat/completions");
assert.equal(requests[1]!.headers.get("authorization"), "Bearer second-key");
assert.equal(requests[1]!.headers.get("x-gateway"), "second-header");
assert.equal(requests[2]!.headers.get("authorization"), "Bearer third-key");
assert.equal(requests[0]!.body.model, "chat");
assert.equal("store" in requests[0]!.body, false);
assert.equal(requests[0]!.body.max_tokens, 1024);
assert.equal(requests[1]!.body.max_tokens, 512, "current Pi model metadata, not the stale engine model, controls the request");
});
const thinking = (...levels: ThinkingLevel[]) => ({
reasoning: true,
// Pi treats omitted levels through high as supported; mark every
// unselected level explicitly unsupported for these narrow fixtures.
thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: null, xhigh: null, max: null, ...Object.fromEntries(levels.map((level) => [level, level])) },
});
const thinkingCases: { name: string; preference?: ThinkingLevel; initial: object; updated: object; before: ThinkingLevel; after: ThinkingLevel }[] = [
{ name: "recovers saved max after high-only metadata expands", preference: "max", initial: thinking("high"), updated: thinking("high", "max"), before: "high", after: "max" },
{ name: "recovers saved max after reasoning-off metadata expands", preference: "max", initial: { reasoning: false }, updated: thinking("high", "max"), before: "off", after: "max" },
{ name: "selects the existing medium-first default after reasoning becomes supported", initial: { reasoning: false }, updated: thinking("low", "medium", "high"), before: "off", after: "medium" },
{ name: "reselects the existing low-before-high default against updated metadata", initial: thinking("high"), updated: thinking("low", "high"), before: "high", after: "low" },
{ name: "clamps saved max when supported thinking contracts", preference: "max", initial: thinking("high", "max"), updated: thinking("high"), before: "max", after: "high" },
{ name: "omits reasoning when the model loses thinking support", preference: "max", initial: thinking("high", "max"), updated: { reasoning: false }, before: "max", after: "off" },
];
for (const scenario of thinkingCases) it(scenario.name, async () => {
const definition = (metadata: object) => config({ compat: { supportsReasoningEffort: true, thinkingFormat: "openai" } }, [model("chat", metadata)]);
save(definition(scenario.initial));
const engine = piEngineForSpec({ provider: "gateway", id: "chat", thinkingLevel: scenario.preference });
assert.equal(engine.level, scenario.before);
const requests: Record<string, unknown>[] = [];
const original = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
requests.push(await request.json() as Record<string, unknown>);
return sseText();
};
try {
const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] });
// A long-lived agent still forwards its initial thinking setting.
const options: SimpleStreamOptions = { reasoning: engine.level === "off" ? undefined : engine.level };
assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop");
save(definition(scenario.updated));
assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop");
} finally { globalThis.fetch = original; }
assert.equal(requests.length, 2);
for (const [index, level] of [scenario.before, scenario.after].entries()) {
assert.equal(requests[index]!.reasoning_effort, level === "off" ? undefined : level);
if (level === "off") assert.equal("reasoning_effort" in requests[index]!, false);
}
});
it("disables custom routes on malformed edits without removing built-ins or saved choices", () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, models: { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" } } }));
const before = fs.readFileSync(SETTINGS_FILE, "utf8");
fs.writeFileSync(CUSTOM_MODELS_FILE, '{ secret-token-do-not-log');
const catalog = modelCatalog();
assert.ok(catalog.customProvidersError);
assert.equal(catalog.customProvidersError!.includes("secret-token"), false);
assert.equal(piModels().getProvider("gateway"), undefined);
assert.ok(piModels().getProvider("openai"));
assert.deepEqual(catalog.slots.strong!.model, { provider: "gateway", id: "chat" });
const stored = { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" as const } };
const repaired = { strong: { ...stored.strong, thinkingLevel: "max" as const }, cheap: { model: { provider: "openai", id: piModels().getModels("openai")[0]!.id } } };
assert.equal(validateModelsSettings(repaired, stored).ok, true, "an unavailable custom pin must not prevent independent slot repair");
assert.equal(clampModelsLevels(repaired).strong!.thinkingLevel, "max", "preserve thinking while custom metadata is unavailable");
assert.equal(validateModelsSettings({ cheap: stored.strong }, stored).ok, false, "unchanged identity preservation is same-slot only");
assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before);
assert.throws(() => piEngineForSpec({ provider: "gateway", id: "chat" }), /catalog has no model/);
save(config());
assert.equal(modelCatalog().customProvidersError, undefined);
assert.ok(piModels().getProvider("gateway"));
fs.rmSync(CUSTOM_MODELS_FILE);
assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false);
assert.equal(modelCatalog().customProvidersError, undefined);
assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before);
});
it("refreshes credentials without a file edit, preserving native header precedence and snapshots", async () => {
const values = new Map([["KEY", "one"], ["HEADER", "first"]]);
const source = new CustomProviderSource(() => ({ text: JSON.stringify(config({ apiKey: "$KEY", authHeader: true, headers: { "x-shared": "$HEADER" } }, [model("chat", { headers: { "x-shared": "model-${HEADER}" } })])) }), (name) => values.get(name));
const models = createModels();
source.sync(models);
const captured = createModels();
captured.setProvider(models.getProvider("gateway")!);
values.set("KEY", "two"); values.set("HEADER", "second");
source.sync(models);
const current = (await models.getAuth(models.getModel("gateway", "chat")!))!.auth;
const previous = (await captured.getAuth(captured.getModel("gateway", "chat")!))!.auth;
assert.equal(current.apiKey, "two");
assert.equal(current.headers!["x-shared"], "model-second");
assert.equal(current.headers!.Authorization, "Bearer two");
assert.equal(previous.apiKey, "one");
assert.equal(previous.headers!["x-shared"], "model-first");
});
it("rejects built-in, subscription and native CLI collisions without replacing their providers", () => {
for (const id of ["openai", "github-copilot", "chatgpt", "github-copilot-subscription", "xai-subscription", CLAUDE_CODE_PROVIDER]) {
const existing = piModels().getProvider("openai")!;
const nativeCli = piModels().getProvider(CLAUDE_CODE_PROVIDER)!;
const models = createModels(); models.setProvider(existing); models.setProvider(nativeCli);
const source = new CustomProviderSource(() => ({ text: JSON.stringify({ providers: { [id]: config().providers.gateway } }) }), () => undefined);
source.sync(models);
assert.ok(source.getError());
assert.equal(models.getProvider("openai"), existing);
assert.equal(models.getProvider(CLAUDE_CODE_PROVIDER), nativeCli);
assert.equal(models.getProviders().length, 2);
}
});
it("cannot bypass CLI opt-in or replace/remove the native provider across custom configuration reloads", (t) => {
const nativeCli = piModels().getProvider(CLAUDE_CODE_PROVIDER)!;
assert.ok(nativeCli);
const aliases = piModels().getModels(CLAUDE_CODE_PROVIDER);
const savedConfig = fs.existsSync(CUSTOM_MODELS_FILE) ? fs.readFileSync(CUSTOM_MODELS_FILE, "utf8") : undefined;
const savedSettings = fs.existsSync(SETTINGS_FILE) ? fs.readFileSync(SETTINGS_FILE, "utf8") : undefined;
t.after(() => {
if (savedConfig === undefined) fs.rmSync(CUSTOM_MODELS_FILE, { force: true }); else fs.writeFileSync(CUSTOM_MODELS_FILE, savedConfig);
if (savedSettings === undefined) fs.rmSync(SETTINGS_FILE, { force: true }); else fs.writeFileSync(SETTINGS_FILE, savedSettings);
piModels();
});
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, claudeCode: { enabled: false } }));
const collision = { providers: { [CLAUDE_CODE_PROVIDER]: config().providers.gateway } };
for (const [value, error] of [
[collision, true], // Initial collision must never enter registered/ready.
[config(), false], // A normal custom route still works.
[collision, true], // Invalid reload retires custom routes, not native ones.
["malformed configuration", true],
[undefined, false], // Removing the file must not delete the native CLI.
[config(), false],
[undefined, false],
] as const) {
if (value === undefined) fs.rmSync(CUSTOM_MODELS_FILE, { force: true });
else if (typeof value === "string") fs.writeFileSync(CUSTOM_MODELS_FILE, value);
else save(value);
const catalog = modelCatalog();
assert.equal(Boolean(catalog.customProvidersError), error);
assert.equal(customProviderConfigured(CLAUDE_CODE_PROVIDER), undefined);
assert.equal(providerKeyPresent(CLAUDE_CODE_PROVIDER), false, "custom auth must never bypass disabled CLI admission");
assert.equal(piModels().getProvider(CLAUDE_CODE_PROVIDER), nativeCli, "preserve the native provider identity, not a reconstructed substitute");
assert.deepEqual(piModels().getModels(CLAUDE_CODE_PROVIDER), aliases);
assert.equal(catalog.models.some((m) => m.provider === CLAUDE_CODE_PROVIDER), false);
}
});
it("fails closed across partial publication and recovers without leaking internal errors", () => {
const text = JSON.stringify({ providers: { ...config().providers, second: config().providers.gateway } });
const source = new CustomProviderSource(() => ({ text }), () => undefined);
const models = createModels();
const builtin = piModels().getProvider("openai")!;
models.setProvider(builtin);
const publish = models.setProvider.bind(models);
models.setProvider = (provider) => {
if (provider.id === "second") throw new Error("private-value-do-not-log");
publish(provider);
};
source.sync(models);
assert.equal(models.getProvider("gateway"), undefined);
assert.equal(models.getProvider("second"), undefined);
assert.equal(models.getProvider("openai"), builtin);
assert.equal(source.getError()!.includes("private-value"), false);
models.setProvider = publish;
source.sync(models);
assert.ok(models.getProvider("gateway"));
assert.ok(models.getProvider("second"));
assert.equal(source.getError(), undefined);
});
it("retires old custom routes when server configuration bootstrap is reinstalled", () => {
save(config());
assert.ok(piModels().getProvider("gateway"));
fs.rmSync(CUSTOM_MODELS_FILE);
installCustomProviderSource(keyValue);
assert.equal(piModels().getProvider("gateway"), undefined);
assert.ok(piModels().getProvider("openai"));
});
it("treats unreadable config as unavailable and recovers without a restart", () => {
let unreadable = true;
const source = new CustomProviderSource(() => unreadable ? { error: "Unable to read models.json." } : { text: JSON.stringify(config()) }, () => undefined);
const models = createModels();
source.sync(models);
assert.equal(source.getError(), "Unable to read models.json.");
unreadable = false;
source.sync(models);
assert.ok(models.getProvider("gateway"));
assert.equal(source.getError(), undefined);
});
});
+10
View File
@@ -0,0 +1,10 @@
/** Synthetic authorized records, imported only after each test's isolated env. */
import { randomBytes } from "node:crypto";
import type { CustomProviderSettings } from "../../src/core/settings.ts";
import { persistCustomAuth } from "../../src/server/custom-provider-auth.ts";
export function authorizeFixture(cp: CustomProviderSettings, fileKey?: string): CustomProviderSettings {
const id = randomBytes(16).toString("hex");
persistCustomAuth({ version: 1, id, baseUrl: cp.baseUrl, authMode: cp.authMode ?? "keyless", ...(fileKey ? { fileKeyBaseUrl: cp.baseUrl } : {}) }, fileKey);
return { ...cp, credentialBindingId: id };
}
+56
View File
@@ -0,0 +1,56 @@
/** Child-process fixture: owned profile only; every upstream fetch is synthetic. */
import fs from "node:fs";
import { AUTH_FILE, SETTINGS_FILE } from "../../config.ts";
const [mode, action = "key", fault = "none"] = process.argv.slice(2);
const requests: { url: string; authorization: string | null }[] = [];
globalThis.fetch = async (url, init) => {
requests.push({ url: String(url), authorization: new Headers(init?.headers).get("authorization") });
if (String(url).endsWith("/models")) return Response.json({ data: [{ id: "m" }] });
return new Response('data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { headers: { "content-type": "text/event-stream" } });
};
const rename = fs.renameSync;
const write = fs.writeFileSync;
const read = fs.readFileSync;
let authRenames = 0;
fs.renameSync = (...args: Parameters<typeof rename>) => {
if (args[1] === AUTH_FILE) {
authRenames++;
if (fault === "auth-before-rename" || (fault === "double-io" && authRenames > 1)) throw new Error("fixture auth I/O failure");
rename(...args);
if (fault === "after-auth") process.exit(77);
return;
}
return rename(...args);
};
fs.writeFileSync = (...args: Parameters<typeof write>) => {
if (args[0] === SETTINGS_FILE && fault === "double-io") throw new Error("fixture settings I/O failure");
if (args[0] === SETTINGS_FILE && fault === "settings-landed") { write(...args); throw new Error("fixture ambiguous settings completion"); }
return write(...args);
};
fs.readFileSync = ((...args: Parameters<typeof read>) => {
if (args[0] === AUTH_FILE && fault === "unreadable") throw Object.assign(new Error("fixture unreadable auth"), { code: "EACCES" });
return read(...args);
}) as typeof read;
await import("../../src/server/keys.ts");
if (mode === "inspect") {
const { piModels, piEngineForSpec } = await import("../../src/agent/models.ts");
const { normalizeContext } = await import("@earendil-works/pi-ai");
const enabled = Boolean(piModels().getProvider("custom"));
let reason: string | undefined;
try {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
reason = (await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "fixture", timestamp: 0 }] }), {}).result()).stopReason;
} catch { reason = "refused"; }
const { keyStatuses } = await import("../../src/server/keys.ts");
console.log(JSON.stringify({ enabled, reason, requests, statusIds: keyStatuses().map(row => row.id), metadataInEnv: process.env.__learnharness_custom_provider_binding !== undefined }));
} else {
const route = await import("../../app/api/custom-provider/route.ts");
const get = route.GET(new Request("http://localhost/api/custom-provider"));
const payload = action === "keyless" ? { baseUrl: "http://second.invalid/v1", authMode: "keyless" }
: action === "envMove" ? { baseUrl: "http://second.invalid/v1", authMode: "apiKey", reuseKey: true }
: action === "reuse" ? { baseUrl: "http://first.invalid/v1", authMode: "apiKey", reuseKey: true }
: { baseUrl: "http://second.invalid/v1", authMode: "apiKey", key: "fixture-new-key" };
const request = new Request("http://localhost/api/custom-provider", { method: action === "disconnect" ? "DELETE" : "POST", headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get.headers.get("etag")! }, body: JSON.stringify(payload) });
const result = await route.POST(request);
console.log(JSON.stringify({ status: result.status, body: await result.json(), requests }));
}