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 @@
/** /** PR #62's single custom endpoint: discover/manual connect, offline pins,
* The custom endpoint's connect surface (one provider in the Keys list). * and disconnect. Host boundaries and mutations live behind the server barrel. */
* GET → non-secret config { baseUrl, models, overrides, manualModels } + import { customProviderGet, customProviderMutation } from "@/src/server";
* 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";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const GET = customProviderGet;
export async function GET() { export const POST = customProviderMutation;
const cp = effectiveSettings().customProvider; export const PATCH = customProviderMutation;
return Response.json({ export const DELETE = customProviderMutation;
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 });
}
+105 -43
View File
@@ -29,6 +29,7 @@ import {
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
CUSTOM_MODEL_DEFAULT_MAX_TOKENS, CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
CUSTOM_PROVIDER, CUSTOM_PROVIDER,
normalizeBaseUrl,
type AuthKeyStatus, type AuthKeyStatus,
type CustomModel, type CustomModel,
type CustomModelOverride, type CustomModelOverride,
@@ -56,6 +57,10 @@ interface KeysResponse {
/** The custom endpoint's non-secret status (GET /api/custom-provider). */ /** The custom endpoint's non-secret status (GET /api/custom-provider). */
interface CustomStatus { interface CustomStatus {
keyReuseRequired: boolean;
revision: string;
authMode: "keyless" | "apiKey";
keySource: "env" | "file" | "none";
baseUrl: string; baseUrl: string;
models: CustomModel[]; models: CustomModel[];
/** User pins by model id (context / max output / thinking). */ /** User pins by model id (context / max output / thinking). */
@@ -64,6 +69,13 @@ interface CustomStatus {
keyPresent: boolean; 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; /** The curated tools group, pinned by id (the catalog owns the copy;
* these ids are the contract). Derived from the catalog (search wave, * these ids are the contract). Derived from the catalog (search wave,
* 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the * 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 // Best-effort and independent: the custom endpoint's status must never
// blank the provider-key list above if its fetch fails. // blank the provider-key list above if its fetch fails.
try { try {
const cp = (await fetch("/api/custom-provider").then((r) => r.json())) as Partial<CustomStatus>; setCustom(await readCustomStatus());
setCustom({
baseUrl: cp.baseUrl ?? "",
models: cp.models ?? [],
overrides: cp.overrides ?? {},
manualModels: cp.manualModels ?? [],
keyPresent: Boolean(cp.keyPresent),
});
} catch { } catch {
/* custom status unavailable — leave it unset */ /* custom status unavailable — leave it unset */
} }
@@ -460,11 +465,13 @@ export function KeysSection({
/** Forget the custom endpoint (config + key server-side), same rhythm as /** Forget the custom endpoint (config + key server-side), same rhythm as
* deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */ * deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */
async function disconnectCustom(): Promise<void> { async function disconnectCustom(revision: string): Promise<string | null> {
await fetch("/api/custom-provider", { method: "DELETE" }).catch(() => undefined); 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); setAddingCustom(false);
await reload(); await reload();
notifySettingsSaved("keys"); notifySettingsSaved("keys");
return null;
} }
if (loading || statuses === null) { if (loading || statuses === null) {
@@ -512,6 +519,11 @@ export function KeysSection({
)} )}
{addingCustom && ( {addingCustom && (
<CustomEndpointEditor <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 ?? ""} baseUrl0={custom?.baseUrl ?? ""}
keyPresent={custom?.keyPresent ?? false} keyPresent={custom?.keyPresent ?? false}
models={custom?.models ?? []} 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. */ /** The custom endpoint's status row (shown once configured); clicking re-opens the editor. */
function CustomEndpointRow({ function CustomEndpointRow({
status, status,
@@ -669,7 +695,7 @@ function CustomEndpointRow({
onToggle: () => void; onToggle: () => void;
}) { }) {
const count = status.models.length; 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 ( return (
<div data-testid="custom-endpoint-row"> <div data-testid="custom-endpoint-row">
<button <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.context.trim()) pin.context = Number(d.context);
if (d.maxTokens.trim()) pin.maxTokens = Number(d.maxTokens); if (d.maxTokens.trim()) pin.maxTokens = Number(d.maxTokens);
if (d.reasoning !== "") pin.reasoning = d.reasoning === "on"; 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; 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 * no probe, so it works with the local server switched off). Image capability is
* detected automatically. */ * detected automatically. */
function CustomEndpointEditor({ function CustomEndpointEditor({
setup = false,
revision0, authMode0, keySource0, keyReuseRequired0, onReload,
baseUrl0, baseUrl0,
keyPresent, keyPresent: initialKeyPresent,
models, models,
overrides, overrides,
manualModels0, manualModels0,
@@ -742,6 +770,12 @@ function CustomEndpointEditor({
onCancel, onCancel,
onDelete, onDelete,
}: { }: {
setup?: boolean;
revision0: string;
authMode0: "keyless" | "apiKey";
keySource0: "env" | "file" | "none";
keyReuseRequired0: boolean;
onReload: () => void | Promise<void>;
baseUrl0: string; baseUrl0: string;
keyPresent: boolean; keyPresent: boolean;
/** The discovered models — one limits row each. */ /** The discovered models — one limits row each. */
@@ -753,8 +787,29 @@ function CustomEndpointEditor({
/** Pins saved — the editor stays open (a form, not a connect). */ /** Pins saved — the editor stays open (a form, not a connect). */
onPinsSaved: () => void | Promise<void>; onPinsSaved: () => void | Promise<void>;
onCancel: () => 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 [baseUrl, setBaseUrl] = useState(baseUrl0);
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [keyTouched, setKeyTouched] = useState(false); const [keyTouched, setKeyTouched] = useState(false);
@@ -767,10 +822,8 @@ function CustomEndpointEditor({
const [baseline, setBaseline] = useState(() => JSON.stringify(pinsPayload(draftsFor(models, overrides)))); const [baseline, setBaseline] = useState(() => JSON.stringify(pinsPayload(draftsFor(models, overrides))));
const [pinsBusy, setPinsBusy] = useState(false); const [pinsBusy, setPinsBusy] = useState(false);
const [pinsError, setPinsError] = useState<string | null>(null); const [pinsError, setPinsError] = useState<string | null>(null);
// A key is required to enable chatting; local servers accept any value. A const changedTarget = normalizeBaseUrl(baseUrl) !== normalizeBaseUrl(baseUrl0);
// stored key is kept when the field is left blank. const canConnect = Boolean(baseUrl.trim()) && (authMode === "keyless" || (!authLoading && ((Boolean(key.trim()) && keySource !== "env") || (keyPresent && (!(changedTarget || keyReuseRequired) || reuseKey)))));
const needsKey = !keyPresent;
const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim()));
const draftOf = (m: CustomModel): PinDraft => drafts[m.id] ?? pinDraft(overrides[m.id]); const draftOf = (m: CustomModel): PinDraft => drafts[m.id] ?? pinDraft(overrides[m.id]);
const setDraft = (m: CustomModel, patch: Partial<PinDraft>) => const setDraft = (m: CustomModel, patch: Partial<PinDraft>) =>
@@ -779,44 +832,47 @@ function CustomEndpointEditor({
const dirty = JSON.stringify(pinsPayload(current)) !== baseline; const dirty = JSON.stringify(pinsPayload(current)) !== baseline;
async function savePins() { async function savePins() {
if (pinsBusy || !dirty) return; if (pinsBusy || busy || !dirty) return;
setPinsBusy(true); setPinsBusy(true);
setPinsError(null); setPinsError(null);
const res = await fetch("/api/custom-provider", { const res = await fetch("/api/custom-provider", {
method: "PATCH", method: "PATCH",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", "if-match": revision },
body: JSON.stringify({ overrides: pinsPayload(current) }), body: JSON.stringify({ overrides: pinsPayload(current) }),
}).catch(() => null); }).catch(() => null);
setPinsBusy(false);
if (!res || !res.ok) { if (!res || !res.ok) {
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
setPinsError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection."); setPinsError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection.");
setPinsBusy(false);
return; 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 body = (await res.json().catch(() => null)) as { overrides?: Record<string, CustomModelOverride> } | null;
const next = draftsFor(models, body?.overrides ?? pinsPayload(current)); const next = draftsFor(models, body?.overrides ?? pinsPayload(current));
setDrafts(next); setDrafts(next);
setBaseline(JSON.stringify(pinsPayload(next))); setBaseline(JSON.stringify(pinsPayload(next)));
await onPinsSaved(); await onPinsSaved();
setPinsBusy(false);
} }
async function save() { async function save() {
if (busy || !baseUrl.trim()) return; if (busy || pinsBusy || !canConnect) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
const res = await fetch("/api/custom-provider", { const res = await fetch("/api/custom-provider", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", "if-match": revision },
body: JSON.stringify({ baseUrl, manualModels, ...(keyTouched ? { key } : {}) }), body: JSON.stringify({ baseUrl, authMode, reuseKey, manualModels, ...(authMode === "apiKey" && keyTouched && key.trim() && keySource !== "env" ? { key } : {}) }),
}).catch(() => null); }).catch(() => null);
setBusy(false);
if (!res || !res.ok) { if (!res || !res.ok) {
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection."); setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection.");
setBusy(false);
return; return;
} }
await onSaved(); await onSaved();
setBusy(false);
} }
return ( return (
@@ -845,24 +901,32 @@ function CustomEndpointEditor({
disabled={busy} 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" 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"> <div className="flex items-center gap-2">
<input <input
type="text" type="password"
value={key} value={key}
onChange={(e) => { onChange={(e) => {
setKey(e.target.value); setKey(e.target.value);
setKeyTouched(true); 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" aria-label="Custom endpoint API key"
data-testid="custom-key-input" 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" 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 <button
type="button" type="button"
onClick={() => void save()} onClick={() => void save()}
disabled={busy || !canConnect} disabled={busy || pinsBusy || !canConnect}
data-testid="custom-save" data-testid="custom-save"
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline" 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() && ( {onDelete && baseUrl0.trim() && (
<button <button
type="button" type="button"
onClick={() => void onDelete()} onClick={async () => { setBusy(true); const err = await onDelete(revision); if (err) setError(err); setBusy(false); }}
disabled={busy} disabled={busy || pinsBusy}
data-testid="custom-delete" data-testid="custom-delete"
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline" 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> </button>
)} )}
</div> </div>
{models.length > 0 && ( {!setup && models.length > 0 && (
<div className="flex flex-col gap-1" data-testid="custom-pins"> <div className="flex flex-col gap-1" data-testid="custom-pins">
<span className="text-[11px] text-muted-foreground"> <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> </span>
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1"> <div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1">
{models.map((m) => { {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)}> <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}> <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground" title={m.id}>
{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> </span>
<input <input
type="text" type="text"
@@ -930,14 +995,14 @@ function CustomEndpointEditor({
<select <select
value={d.reasoning} value={d.reasoning}
onChange={(e) => setDraft(m, { reasoning: e.target.value as PinDraft["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)} data-testid={pinTestid("custom-pin-reasoning", m.id)}
disabled={pinsBusy} 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" 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="">Support: reported</option>
<option value="on">think: on</option> <option value="on">Support: yes</option>
<option value="off">think: off</option> <option value="off">Support: no</option>
</select> </select>
</div> </div>
); );
@@ -961,11 +1026,8 @@ function CustomEndpointEditor({
</div> </div>
</div> </div>
)} )}
{needsKey && ( <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>
<p className="text-[11px] text-muted-foreground"> {(error || pinsError) && <button type="button" data-testid="custom-reload" onClick={() => void onReload()} className="text-left text-xs underline">Reload configuration</button>}
A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value.
</p>
)}
{error && ( {error && (
<p role="alert" data-testid="custom-error" className="text-xs text-red-500"> <p role="alert" data-testid="custom-error" className="text-xs text-red-500">
{error} {error}
+9 -34
View File
@@ -48,7 +48,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
import { useModelCatalog } from "@/components/model-catalog"; import { useModelCatalog } from "@/components/model-catalog";
import { savedSlotsConfigured } from "@/components/setup-cta"; import { savedSlotsConfigured } from "@/components/setup-cta";
import { Hint } from "@/components/ui/hint"; 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 { ProviderMenuList } from "@/components/settings/model-menu";
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection"; import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection";
import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections"; import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections";
@@ -135,8 +135,7 @@ export function SetupKeysBody({
onSave, onSave,
menuAnchor, menuAnchor,
isCustom, isCustom,
baseUrl, onCustomSaved,
onBaseUrl,
}: { }: {
/** The full provider roster (GET /api/keys providers) — undefined /** The full provider roster (GET /api/keys providers) — undefined
* while it loads; the picker renders an honest empty state then. */ * 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 /** 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. */ * field. All three optional so plain-key callers (and tests) stay unchanged. */
isCustom?: boolean; isCustom?: boolean;
baseUrl?: string; onCustomSaved?: () => void;
onBaseUrl?: (v: string) => void;
}) { }) {
return ( return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys"> <div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
@@ -192,20 +190,8 @@ export function SetupKeysBody({
</MenuShell> </MenuShell>
)} )}
</div> </div>
{provider && ( {provider && (isCustom ? <CustomEndpointSetup onSaved={onCustomSaved ?? onSave} /> : (
<div className="flex max-w-96 flex-col gap-2"> <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"> <div className="flex items-center gap-2">
<input <input
type="text" type="text"
@@ -214,7 +200,7 @@ export function SetupKeysBody({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave(); 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`} aria-label={`${provider.label} key`}
data-testid="setup-key-input" data-testid="setup-key-input"
disabled={saving} disabled={saving}
@@ -228,11 +214,11 @@ export function SetupKeysBody({
data-testid="setup-key-save" 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" 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> </button>
</div> </div>
</div> </div>
)} ))}
{error && ( {error && (
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400"> <p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
{error} {error}
@@ -286,7 +272,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
const [keyProviders, setKeyProviders] = useState<ProviderKeyInfo[] | undefined>(undefined); const [keyProviders, setKeyProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
const [keyProvider, setKeyProvider] = useState<ProviderKeyInfo | null>(null); const [keyProvider, setKeyProvider] = useState<ProviderKeyInfo | null>(null);
const [keyValue, setKeyValue] = useState(""); const [keyValue, setKeyValue] = useState("");
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [keySaving, setKeySaving] = useState(false); const [keySaving, setKeySaving] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null); const [keyError, setKeyError] = useState<string | null>(null);
const [keyMenuOpen, setKeyMenuOpen] = useState(false); const [keyMenuOpen, setKeyMenuOpen] = useState(false);
@@ -398,18 +383,10 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
* 400s keep their user-facing line inline. */ * 400s keep their user-facing line inline. */
async function saveKey() { async function saveKey() {
if (!keyProvider || keySaving) return; if (!keyProvider || keySaving) return;
const isCustom = keyProvider.id === CUSTOM_PROVIDER.id;
if (!keyValue.trim()) return; if (!keyValue.trim()) return;
setKeySaving(true); setKeySaving(true);
setKeyError(null); setKeyError(null);
// Custom endpoint connects through its own route; every other provider is a plain key PUT. const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
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)}`, {
method: "PUT", method: "PUT",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ value: keyValue }), body: JSON.stringify({ value: keyValue }),
@@ -422,7 +399,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
} }
setKeyProvider(null); setKeyProvider(null);
setKeyValue(""); setKeyValue("");
setCustomBaseUrl("");
notifySettingsSaved("keys"); notifySettingsSaved("keys");
} }
@@ -461,8 +437,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
menuOpen={keyMenuOpen} menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef} menuAnchor={keyTriggerRef}
isCustom={keyProvider?.id === CUSTOM_PROVIDER.id} isCustom={keyProvider?.id === CUSTOM_PROVIDER.id}
baseUrl={customBaseUrl} onCustomSaved={() => { setKeyProvider(null); notifySettingsSaved("keys"); }}
onBaseUrl={setCustomBaseUrl}
onToggleMenu={() => setKeyMenuOpen((v) => !v)} onToggleMenu={() => setKeyMenuOpen((v) => !v)}
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }} onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
onValue={setKeyValue} 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"); 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 * User prompt-override files (settings-storage wave, 2026-09-19): the
* per-segment system-prompt overrides mirroring the SOURCE layout * 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 LearnHarness supports **one custom OpenAI-compatible endpoint with multiple models**.
existing Pi AI runtime. This is configuration wiring, not another agent runtime: The setup and Settings UI are based on PR #62 by Daniel Buckner and Abhishek Thakur.
Pi owns the model protocols, request conversion, streaming, tools, thinking and Pi owns inference, message conversion, tools, streaming, and reasoning-effort handling.
usage accounting. There is no additional dependency on Pi coding-agent.
## Configuration location ## Configure an endpoint
Create `models.json` in the LearnHarness app-data directory (the directory 1. Open **Settings → Providers → + Add a provider → Custom endpoint**. The same
containing `settings.json`; `LH_DATA_DIR` relocates it). LearnHarness does not read editor is available from the first-run provider picker.
`~/.pi/agent/models.json`, install a model server, or edit this file for you. 2. Enter the server's complete API base URL, for example
Custom-provider setup UI is not included yet. Once configured, models appear in `http://localhost:11434/v1`. LearnHarness does **not** append `/v1`.
the existing model selectors when their credential/header references resolve. 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 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
"providers": { same URL preserves its previous model snapshot and pins. A different URL starts
"my-local-server": { with its own models. **401/403 always fails**, even with entered IDs. Response-size,
"baseUrl": "http://127.0.0.1:11434/v1", model-count, and redirect violations also fail explicitly rather than silently
"api": "openai-completions", truncating or bypassing a discovery limit.
"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
}
}
]
}
}
}
```
**Replace all example IDs, limits, prices and compatibility flags with the actual Configuration is not a continuous health check. An endpoint may be configured while
server/model configuration.** These numbers are examples, not recommendations or offline, and model capability or output quality is not proven by listing its ID.
automatically detected capabilities. Rates are USD per million tokens. Explicit No backend/model installation, downloads, or OAuth flows are performed.
zero rates are allowed; LearnHarness does not assume that custom gateways are
free. Verify the endpoint's billing independently.
Use the exact API base URL your server documents. LearnHarness does not append ## Model budgets and reasoning support
`/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.
## Supported fields Settings exposes offline-editable pins per model:
Provider fields: `name`, `baseUrl`, `api`, `apiKey`, `headers`, `authHeader`, - **Context budget**: user pin → reported context → **8192 fallback**.
`compat`, and the explicit `models` array. Each model requires `id`, - **Maximum output**: user pin → reported output limit → **4096 fallback**.
`contextWindow`, `maxTokens`, and `cost` with all four base rates. Unlike Pi - **Reasoning support**: user declaration → reported support.
coding-agent's defaults, LearnHarness never invents limits or prices.
Optional Pi model fields are `name`, `api`, `baseUrl`, `reasoning`, `input`, Every positive integer token pin is accepted, including a 512-token output limit.
`thinkingLevelMap`, `inputLimits`, `promptCache`, `samplingParams`, Blank fields remove that pin. The editor identifies reported, fallback, and user-pin
`samplingParamsByThinkingLevel`, `headers`, and `compat`. Cost tiers and Pi's values. **These are request/compaction budgets; changing them does not resize the
exported compatibility schemas are supported. A model's `api` and `baseUrl` backend's actual context.** Configure the server's active context separately and
override its provider's values; model headers override provider headers. Names enter budgets that match it. Reconnecting prunes pins only to the resulting model
default to model IDs, `reasoning` defaults to false, and `input` defaults to IDs; pin saves do not contact the server.
`["text"]`. Declare image input only when the server/model supports it. Existing
onboarding and vision-slot requirements are unchanged.
The native chat transports available in the pinned Pi AI 1.4.0 dependency are: Reasoning support is a **capability declaration, not an execution toggle**. The
`openai-completions`, `openai-responses`, `anthropic-messages`, existing Pi-backed effort picker remains available. In particular, **Off may not
`google-generative-ai`, `azure-openai-responses`, `google-vertex`, disable a custom backend's default thinking**: an absent effort parameter is not a
`bedrock-converse-stream`, `mistral-conversations`, `openai-codex-responses`, and universal disable command. There is no compatibility/thinking-map editor, Ollama
`pi-messages`. Protocol availability does **not** establish account access, preset, or model-name inference. Backends requiring additional nonstandard headers
OAuth support, cloud credentials, subscription permission, or vendor approval. or compatibility configuration are outside this release's custom-endpoint UI.
This feature adds no login flows. In particular, an Anthropic Messages endpoint
is not a Claude Agent SDK or Claude Code runtime.
Use new provider IDs; built-in and subscription-route IDs cannot be replaced. ## Images and Vision
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.
## 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 Missing discovery metadata does not silently remove images: **every custom-engine
use LearnHarness's existing key lookup: environment first, then the existing image-bearing request attempts delivery**, including teaching `read` results,
flat `auth.json` file, respecting the fresh-user environment-ignore setting. rendered PDF pages, and visual-maker screenshots. A request-local model clone permits
There is no auth migration or new secret file format. Prefer references over Pi's native image encoding; registered/catalog metadata is not promoted to claim
putting secrets into `models.json`; protect any file containing literal secrets. that every model understands images. A text-only backend may reject the request;
Unknown variable references keep the provider unavailable. All required header that error is surfaced, with no silent text-only fallback. This does not establish
references, including model-specific ones, must resolve before a provider becomes real-model image-understanding quality.
available. An empty key does not configure a provider.
Pi's `$$` and `$!` literal escapes are supported. `!command` credential/header ## Credentials, reloads, and concurrent editing
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.
Only trusted people should edit this file: its endpoints receive model prompts, Nonsecret configuration is stored in the existing `settings.json.customProvider`;
attachments, tool results and configured credentials. Only HTTP(S) URLs are file-saved credentials use the existing `auth.json` key seam. A reserved internal
accepted, without embedded URL credentials or fragments. This is not an string entry, `__learnharness_custom_provider_binding`, binds authorization to the
arbitrary-endpoint discovery or config-editing API and does not add a network endpoint, mode, and a random nonsecret `credentialBindingId` in custom settings.
sandbox, endpoint reachability checks, or vendor policy exemptions. 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 Use the custom endpoint editor to change custom credentials; generic
operation without restarting. Credential/header reference changes are also `/api/keys` PUT/DELETE cannot bypass binding. No credential-derived fingerprint is
picked up through the existing key lookup. Every upstream model operation stored in settings or a public revision. These guards cover process crashes and
revalidates the same saved provider/model ID against the current Pi catalog, restart using the existing atomic rename; they are not a power-loss/fsync
including later requests inside a long-running agent/tool loop. It uses the transaction system. Missing auth storage can initialize normally; unreadable or
current endpoint, model metadata and resolved credentials. A removed model, malformed private storage fails closed without overwriting its contents.
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.
Deleting the file means no custom providers. Invalid JSON, invalid metadata, Each upstream model operation resolves the current custom model, endpoint, effective
reserved IDs, unsupported configuration, or an unreadable file disables the credential, limits, and original saved effort preference against Pi's current model
custom routes, **not** built-in providers. The file accepts Pi's line comments metadata. Already-started requests keep their setup. A removed model or disconnected
and trailing commas. `GET /api/models` includes an optional sanitized provider fails rather than falling back; saved model selections and history are not
`customProvidersError` diagnostic; rendering it in setup/settings UI is deferred. rewritten.
Correcting the file restores valid providers on the next operation.
Saved selections and thinking preferences are not erased when a provider/model Edits use an opaque nonsecret ETag/If-Match revision. A stale edit returns **409** with
is removed or becomes unavailable. New work using an unavailable selection fails reload/retry guidance. A connect also checks configuration and effective credentials
rather than falling back to another model. Select another configured model to after discovery, and merges only the custom section into the latest settings record,
repair the slot. Custom-provider removal does not rewrite historical records; preserving intervening unrelated edits. Failed mutations never claim success.
like existing API-key models, historical cost estimates use the current catalog
and become unknown when the relevant model definition is unavailable. 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. // 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 entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js"));
const { createModels } = await import(entry.href); 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(); const models = createModels();
for (const [name, factory, api, label] of [ for (const [name, factory, api, label] of [
["google", "googleProvider", "google-generative-ai", "Google"], ["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, type StreamFunction,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; 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 { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type ModelRole } from "../../config.ts"; import { type ModelRole } from "../../config.ts";
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts"; import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
@@ -82,7 +82,14 @@ export function configureProviderSource(source: ProviderSource): void {
export function piModels() { export function piModels() {
if (!collection) { if (!collection) {
collection = createModels({ identity: APP_IDENTITY }); 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)); collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
} }
providerSource?.sync(collection); 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 { export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
const models: Models = piModels(); const models: Models = piModels();
const nativeId = nativeSubscriptionId(spec.provider); 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) { if (!model) {
throw new Error(`${spec.provider} catalog has no model ${spec.id}`); throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
} }
@@ -137,7 +149,6 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
: defaultThinkingLevel(model); : defaultThinkingLevel(model);
// A lesson's agent loop can stay alive through many tool calls. Remember // 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. // only that this is a custom route, never its configuration for that run.
const custom = providerSource?.isCustom(spec.provider) ?? false;
return { return {
model, model,
level, level,
@@ -156,7 +167,7 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
const currentLevel = thinkingPreference const currentLevel = thinkingPreference
? clampThinkingLevel(currentModel, thinkingPreference) ? clampThinkingLevel(currentModel, thinkingPreference)
: defaultThinkingLevel(currentModel); : 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 requestOptions = { ...options, reasoning: level === "off" ? undefined : level };
const purpose = typeof background === "function" ? background : () => background; 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 * are simply absent (the Keys page is the discovery surface, same
* discipline as the search engines). */ * discipline as the search engines). */
providers: CatalogProvider[]; providers: CatalogProvider[];
/** Sanitized configuration diagnostic; built-in catalogs remain available. */
customProvidersError?: string;
} }
/** One row of the providers surface. */ /** 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 // secret in the auth store, never here. Registered into pi-ai as a normal
// provider (server/custom-provider.ts). // 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_CONTEXT_WINDOW = 8192;
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096; export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
/** Floor for a user-pinned token count (owner, 2026-10-27): the read side /** Custom budgets accept every positive integer, including small output caps. */
* clamps up, the write side rejects below — the compaction-floor discipline. export const CUSTOM_MODEL_PIN_FLOOR = 1;
* 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;
/** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are /** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are
* detected from the endpoint's /models metadata; anything it omits falls back * 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 /** 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 * (owner ruling — explicit user intent wins; a local server's number is
* often wrong or absent). Absent field = no pin. `reasoning` is tri-state: * often wrong or absent). Absent field = no pin. `reasoning` is tri-state:
* true forces thinking on, false forces it off (a server that advertises * true declares reasoning support, false removes that declaration, absent
* reasoning but rejects reasoning_effort), absent = what discovery found. */ * uses discovery. This is NOT a backend execution/disable switch. */
export interface CustomModelOverride { export interface CustomModelOverride {
context?: number; context?: number;
maxTokens?: number; maxTokens?: number;
@@ -254,6 +251,10 @@ export interface CustomModelOverride {
/** The single custom endpoint's stored config (delta section). */ /** The single custom endpoint's stored config (delta section). */
export interface CustomProviderSettings { 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. */ /** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */
baseUrl?: string; baseUrl?: string;
/** Models this endpoint offers: advertised ids merged with the typed /** Models this endpoint offers: advertised ids merged with the typed
@@ -287,6 +288,7 @@ export function normalizeBaseUrl(raw: string): string {
return ""; return "";
} }
if (url.protocol !== "http:" && url.protocol !== "https:") 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(/\/+$/, ""); return url.toString().replace(/\/+$/, "");
} }
@@ -625,7 +627,7 @@ export function sanitizeCustomModelOverrides(
const id = key.trim(); const id = key.trim();
if (!id || (live && !live.has(id))) continue; if (!id || (live && !live.has(id))) continue;
const pin = sanitizeCustomModelOverride(value); 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; return out;
} }
@@ -635,6 +637,8 @@ export function sanitizeCustomModelOverrides(
* true; token counts only as positive ints). Clamp-never-reject. */ * true; token counts only as positive ints). Clamp-never-reject. */
function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings { function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings {
const out: 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") { if (typeof raw.baseUrl === "string") {
const base = normalizeBaseUrl(raw.baseUrl); const base = normalizeBaseUrl(raw.baseUrl);
if (base) out.baseUrl = base; 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. * 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 { 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 { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
import { customBindingMatches } from "../core/custom-provider-auth.ts";
import { customAuthState, customKeyAuthorized } from "./custom-provider-auth.ts";
import { import {
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
CUSTOM_MODEL_DEFAULT_MAX_TOKENS, CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
@@ -16,9 +20,8 @@ import {
normalizeBaseUrl, normalizeBaseUrl,
type CustomModel, type CustomModel,
type CustomModelOverride, type CustomModelOverride,
type CustomProviderSettings,
} from "../core/settings.ts"; } from "../core/settings.ts";
import { keyValue } from "./keys.ts";
/** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */ /** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */
const KEYLESS_PLACEHOLDER = "not-needed"; const KEYLESS_PLACEHOLDER = "not-needed";
@@ -27,14 +30,32 @@ const KEYLESS_PLACEHOLDER = "not-needed";
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === "object" && value !== null && !Array.isArray(value);
/** Auth: the reserved key via the keys seam, else the keyless placeholder. */ /** Install the sole custom source: donor settings + explicit authentication.
const customEndpointAuth: ApiKeyAuth = { * Publication captures a consistent snapshot; an already-started operation
name: `${CUSTOM_PROVIDER.label} API key`, * cannot acquire a later endpoint's key. No file configuration source exists. */
resolve: async () => { export function installCustomProviderSource(readAuth = customAuthState): void {
const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim(); let fingerprint: string | undefined;
return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" }; 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 /** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking
* levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27): * levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27):
@@ -72,13 +93,18 @@ export function buildOpenAiCompatibleProvider(input: {
models: readonly CustomModel[]; models: readonly CustomModel[];
/** User pins by model id (see CustomModelOverride) — absent = none. */ /** User pins by model id (see CustomModelOverride) — absent = none. */
overrides?: Record<string, CustomModelOverride>; overrides?: Record<string, CustomModelOverride>;
/** Already resolved for this publication; absent means explicit keyless. */
apiKey?: string;
}): Provider<Api> { }): Provider<Api> {
const overrides = input.overrides ?? {}; const overrides = input.overrides ?? {};
return createProvider<Api>({ return createProvider<Api>({
id: CUSTOM_PROVIDER.id, id: CUSTOM_PROVIDER.id,
name: CUSTOM_PROVIDER.label, name: CUSTOM_PROVIDER.label,
baseUrl: input.baseUrl, 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)), models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)),
api: openAICompletionsApi(), api: openAICompletionsApi(),
}); });
@@ -86,19 +112,8 @@ export function buildOpenAiCompatibleProvider(input: {
/** Re-sync the provider into piModels() (idempotent): usable → rebuild; /** Re-sync the provider into piModels() (idempotent): usable → rebuild;
* not usable → remove. Never throws. */ * not usable → remove. Never throws. */
export function syncCustomProvider(cp: CustomProviderSettings | undefined): void { export function syncCustomProvider(): void {
const models = piModels(); piModels();
if (!customProviderUsable(cp)) {
models.deleteProvider(CUSTOM_PROVIDER.id);
return;
}
models.setProvider(
buildOpenAiCompatibleProvider({
baseUrl: normalizeBaseUrl(cp.baseUrl),
models: cp.models ?? [],
overrides: cp.overrides ?? {},
}),
);
} }
/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status /** 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. */ * model list. */
export type DiscoverResult = export type DiscoverResult =
| { ok: true; models: CustomModel[] } | { 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 /** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https
* only); keyless endpoints answer without a bearer. Non-2xx or unparseable * 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> { export async function discoverModels(baseUrl: string, apiKey?: string): Promise<DiscoverResult> {
const base = normalizeBaseUrl(baseUrl); const base = normalizeBaseUrl(baseUrl);
if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" }; 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 controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000); const timer = setTimeout(() => controller.abort(), 15_000);
try { try {
const res = await fetch(`${base}/models`, { const res = await fetch(`${base}/models`, {
headers: key ? { authorization: `Bearer ${key}` } : {}, headers: key ? { authorization: `Bearer ${key}` } : {},
signal: controller.signal, signal: controller.signal,
redirect: "manual",
}); });
if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}`, status: res.status }; if (res.status >= 300 && res.status < 400) {
const body = (await res.json().catch(() => null)) as unknown; 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); const list = extractModels(body);
if (list === null) return { ok: false, error: "Endpoint response was not a model list" }; if (list === null) return { ok: false, error: "Endpoint response was not a model list" };
return { ok: true, models: list }; return { ok: true, models: list };
} catch (err) { } catch (err) {
const msg = err instanceof Error ? (err.name === "AbortError" ? "Endpoint timed out" : err.message) : "Request failed"; return { ok: false, error: err instanceof CustomBodyLimitError ? err.message : controller.signal.aborted ? "Endpoint timed out" : "Discovery request failed", fatal: err instanceof CustomBodyLimitError };
return { ok: false, error: msg };
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
@@ -210,6 +229,7 @@ const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
function extractModels(body: unknown): CustomModel[] | null { function extractModels(body: unknown): CustomModel[] | null {
const rows = modelRows(body); const rows = modelRows(body);
if (rows === null) return null; if (rows === null) return null;
if (rows.length > 10_000) throw new CustomBodyLimitError("Model list exceeds 10000 entries");
const byId = new Map<string, CustomModel>(); const byId = new Map<string, CustomModel>();
for (const row of rows) { for (const row of rows) {
if (typeof row === "string") { 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). // The custom endpoint module (registers into piModels() + probes for models).
// No import side effects; the boot sync lives in stores.ts. // No import side effects; the boot sync lives in stores.ts.
export * from "./custom-provider.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"; } from "../core/auth-keys.ts";
import { authStore } from "../store/auth.ts"; import { authStore } from "../store/auth.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
import { piModels } from "../agent/models.ts"; import { customProviderAvailable, installCustomProviderSource } from "./custom-provider.ts";
import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.ts";
import { claudeCodeAvailable } from "./claude-code-config.ts"; import { claudeCodeAvailable } from "./claude-code-config.ts";
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.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 // Shared HMR-stable provenance lets external CLIs exclude file-only injections
// without importing this store-backed module into the agent layer. // without importing this store-backed module into the agent layer.
import { authEnvRecord as boot } from "../core/auth-env.ts"; import { authEnvRecord as boot } from "../core/auth-env.ts";
/** Is `id` a WRITABLE key name — curated catalog, model-provider env var, or /** Ordinary writable key names. Custom auth writes must use the atomic
* the custom endpoint's reserved key (writable through the same seam). */ * endpoint-binding seam, never the generic single-key API. */
export function isWritableAuthKey(id: string): boolean { 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 /** The settings page's provider dropdown: every pi-ai provider that takes
@@ -92,7 +92,7 @@ export function envKeysIgnored(): boolean {
*/ */
export function initAuthEnv(): void { export function initAuthEnv(): void {
const record = boot(); 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>([ const managed = new Set<string>([
...Object.keys(fileVars), ...Object.keys(fileVars),
...AUTH_KEYS.map((k) => k.id), ...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 // an env-exported OPENAI_API_KEY with a file copy gets honest shadow
// semantics, exactly like the curated vars. // semantics, exactly like the curated vars.
...AUTH_PROVIDER_KEYS.map((p) => p.envVar), ...AUTH_PROVIDER_KEYS.map((p) => p.envVar),
CUSTOM_PROVIDER.envVar,
]); ]);
if (envKeysIgnored()) { if (envKeysIgnored()) {
// THE FRESH-USER SWITCH: delete every managed env var (re-run on // 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, /** Is this env-var name one the keys system manages (curated, provider,
* or held in the file)? */ * or held in the file)? */
function isManagedName(name: string): boolean { 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[] = []; const rows: AuthKeyStatus[] = [];
for (const id of ids) { for (const id of ids) {
if (id === CUSTOM_AUTH_BINDING) continue;
const envValue = env[id]; const envValue = env[id];
const fileValue = fileVars[id]; const fileValue = fileVars[id];
const isRealEnv = realEnv.has(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. */ /** The GET view: every curated key (present or not) + unknown file vars. */
export function keyStatuses(): AuthKeyStatus[] { 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 /** 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 * STT route was the one cold-boot hole: a first-ever request to it would
* otherwise read env before any fill-gap ran). */ * otherwise read env before any fill-gap ran). */
export function keyValue(id: string): string | undefined { 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- // The fresh-user switch: ONLY the file store counts (belt-and-
// suspenders — the boot deletion already emptied the env; this guards // suspenders — the boot deletion already emptied the env; this guards
// any env key reintroduced after boot). // 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 * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
* no key at all: nothing to gate, present by definition. */ * no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean { export function providerKeyPresent(provider: string): boolean {
piModels(); // Synchronize hand-edited custom configuration before admission. if (provider === CUSTOM_PROVIDER.id) return customProviderAvailable();
const custom = customProviderConfigured(provider);
if (custom !== undefined) return custom;
// Explicit opt-in + installation only, never authentication readiness. // Explicit opt-in + installation only, never authentication readiness.
// Consulted at admission, not inside already-running agent loops. // Consulted at admission, not inside already-running agent loops.
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable(); 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). * reports shadowedByEnv so the page can explain it).
*/ */
export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } { export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } {
const shape = if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) return { ok: false, error: "Use the custom endpoint editor" };
isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value);
? genericProviderKeyGuard(id, value)
: validateKeyShape(id, value);
if (!shape.ok) return { ok: false, error: shape.error }; if (!shape.ok) return { ok: false, error: shape.error };
const trimmed = value.trim(); const trimmed = value.trim();
authStore.setAuthVar(id, trimmed); 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. * wins) and the status shows the key still present from env.
*/ */
export function deleteKey(id: string): AuthKeyStatus { 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); authStore.deleteAuthVar(id);
const record = boot(); const record = boot();
if (record.fillGap.has(id) && !record.realEnv.has(id)) { 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 // Reflect the file into the env at first import (idempotent; the module is
// imported by every route/handler path that touches keys). // imported by every route/handler path that touches keys).
initAuthEnv(); 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 { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { piModels } from "../agent/models.ts"; 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 { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.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: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() }, { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), ...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 // The custom endpoint: a row only when usable (base URL set). keyPresent
// reflects usability, not key presence (keyless local servers are allowed). // reflects usability, not key presence (keyless local servers are allowed).
...(customProviderUsable(effectiveSettings().customProvider) ...(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)); ]).sort((a, b) => a.id.localeCompare(b.id));
} }
@@ -180,7 +179,7 @@ export function toModelDto(model: Model<Api>): ModelDto {
reasoning: model.reasoning, reasoning: model.reasoning,
input: [...model.input], input: [...model.input],
supportedLevels: getSupportedThinkingLevels(model), 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, input: model.cost.input,
output: model.cost.output, output: model.cost.output,
cacheRead: model.cost.cacheRead, cacheRead: model.cost.cacheRead,
@@ -231,7 +230,6 @@ export function modelCatalog(): ModelCatalogResponse {
rolePins: catalogRolePins(), rolePins: catalogRolePins(),
slots: catalogSlots(), slots: catalogSlots(),
providers: catalogProviders(), providers: catalogProviders(),
...(customProvidersError() ? { customProvidersError: customProvidersError() } : {}),
}; };
} }
+2 -1
View File
@@ -4,6 +4,7 @@
*/ */
import { calculateCost, type Usage } from "@earendil-works/pi-ai"; import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../agent/models.ts"; 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 { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.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"; 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(":"); const colon = id.indexOf(":");
// Native CLI routing determines billing. Adapter zero-rate placeholders // Native CLI routing determines billing. Adapter zero-rate placeholders
// must never become an apparent $0 estimate or subscription classification. // 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)); 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 * 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 * token count must be a whole number at or above the floor (the read side
* clamps, this rejects — the compaction discipline). `reasoning` is the * 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( export function parseCustomModelOverridesBody(
body: unknown, body: unknown,
): SettingsBodyResult<Record<string, CustomModelOverride>> { ): 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` }; if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` };
pin.reasoning = entry.reasoning; pin.reasoning = entry.reasoning;
} }
out[id] = pin; Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
} }
return { ok: true, settings: out }; 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 // Boot: register the custom endpoint (when configured) into piModels(); the
// POST /api/custom-provider route re-syncs after every connect. // 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 * The server (src/server/keys.ts) is the only intended writer: it applies
* shape validation before setAuthVar and env precedence on top of * shape validation before setAuthVar and env precedence on top of
* authVars(). This adapter persists what it is given, never throws * authVars(). Ordinary reads never throw (absent/corrupt file = {} =
* (absent/corrupt file = {} = "no keys yet", the documented first-boot * "no keys yet"); custom-only pair reads/writes below are deliberately
* state, matching settings doctrine), and preserves unknown vars verbatim * 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 * (forward-compat: future entries — e.g. a plans-login token — ride along
* untouched until a catalog entry adopts them). * untouched until a catalog entry adopts them).
*/ */
@@ -18,6 +19,8 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { AUTH_FILE } from "../../config.ts"; import { AUTH_FILE } from "../../config.ts";
import type { AuthStore } from "../core/ports.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. */ /** The flat string→string map, or {} for absent/corrupt/non-object. */
function readMap(): Record<string, string> { 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"). * 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 * tmp-in-same-dir + rename keeps the swap atomic; chmod BEFORE the
* rename so the mode never exists relaxed on the final path. */ * 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); const dir = path.dirname(AUTH_FILE);
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`); const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`);
@@ -50,14 +53,50 @@ export const authStore: AuthStore = {
return readMap(); return readMap();
}, },
setAuthVar(name, value) { setAuthVar(name, value) {
assertOrdinaryVar(name);
const map = readMap(); const map = readMap();
map[name] = value; map[name] = value;
writeMap(map); writeMap(map);
}, },
deleteAuthVar(name) { deleteAuthVar(name) {
assertOrdinaryVar(name);
const map = readMap(); const map = readMap();
if (!(name in map)) return; // absent = silent success, idempotent if (!(name in map)) return; // absent = silent success, idempotent
delete map[name]; delete map[name];
writeMap(map); 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, buildOpenAiCompatibleProvider,
discoverModels, discoverModels,
mergeManualModels, mergeManualModels,
syncCustomProvider,
} from "../src/server/custom-provider.ts"; } from "../src/server/custom-provider.ts";
import { piModels } from "../src/agent/models.ts"; import { piModels } from "../src/agent/models.ts";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
@@ -92,13 +91,13 @@ describe("sanitizeUserSettings — customProvider", () => {
// ─── per-model pins: the read side + THE prune law ─────────────────────────── // ─── per-model pins: the read side + THE prune law ───────────────────────────
describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => { 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({ const out = sanitizeUserSettings({
customProvider: { customProvider: {
baseUrl: "https://h/v1", baseUrl: "https://h/v1",
overrides: { overrides: {
llama: { context: 131072, maxTokens: 8192, reasoning: true }, 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 quiet: { reasoning: false }, // a real pin, not an absent one
junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk
" ": { context: 4096 }, // no usable id " ": { context: 4096 }, // no usable id
@@ -108,7 +107,7 @@ describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => {
}); });
assert.deepEqual(out?.customProvider?.overrides, { assert.deepEqual(out?.customProvider?.overrides, {
llama: { context: 131072, maxTokens: 8192, reasoning: true }, llama: { context: 131072, maxTokens: 8192, reasoning: true },
qwen: { context: CUSTOM_MODEL_PIN_FLOOR }, qwen: { context: 10 },
quiet: { reasoning: false }, quiet: { reasoning: false },
}); });
}); });
@@ -281,11 +280,11 @@ describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => {
assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]); assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]);
}); });
it("registers on a usable sync and removes when the base URL clears", () => { it("registers the native provider in the shared collection", () => {
syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] }); piModels().setProvider(buildOpenAiCompatibleProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] }));
assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered"); assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered");
assert.ok(piModels().getModel(CUSTOM_PROVIDER.id, "live"), "model resolvable"); 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"); 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, version: 1,
customProvider: { customProvider: {
baseUrl: BASE, baseUrl: BASE,
authMode: "keyless",
models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }], models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }],
overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } }, overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } },
manualModels: ["m1"], manualModels: ["m1"],
@@ -59,8 +60,8 @@ function post(payload: unknown): Promise<Response> {
return route.POST( return route.POST(
new Request("http://localhost/api/custom-provider", { new Request("http://localhost/api/custom-provider", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, 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(payload), 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 }));
}