Merge PR #62 contribution for donor-led integration
This commit is contained in:
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* The custom endpoint's connect surface (one provider in the Keys list).
|
||||||
|
* GET → non-secret config { baseUrl, models, overrides, manualModels } +
|
||||||
|
* keyPresent (masked).
|
||||||
|
* POST → connect: validate + normalize the base URL, probe /models, store
|
||||||
|
* the key, persist { baseUrl, models, overrides, manualModels },
|
||||||
|
* re-sync the provider into piModels(). Discovery lives here because
|
||||||
|
* connecting and discovering are one user action. A failed probe is
|
||||||
|
* fatal UNLESS the user typed model IDs: an endpoint that serves chat
|
||||||
|
* but no model list is configured from those ids alone. A failed
|
||||||
|
* probe for the endpoint already stored keeps its last snapshot, so
|
||||||
|
* a blip cannot prune the stored pins. A 401/403 always fails: a
|
||||||
|
* rejected key is not a missing model list. Stored user pins survive
|
||||||
|
* a reconnect, pruned to the ids the connect ends up offering.
|
||||||
|
* PATCH → store per-model pins only. No probe: pins must be editable with
|
||||||
|
* the local server switched off (the case they exist for).
|
||||||
|
* DELETE → disconnect: drop the stored config + the stored key and unregister
|
||||||
|
* the provider (the mirror image of POST; idempotent).
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
deleteKey,
|
||||||
|
discoverModels,
|
||||||
|
effectiveSettings,
|
||||||
|
keyValue,
|
||||||
|
mergeManualModels,
|
||||||
|
parseCustomModelOverridesBody,
|
||||||
|
readSettingsRecord,
|
||||||
|
saveKey,
|
||||||
|
syncCustomProvider,
|
||||||
|
writeSettingsRecord,
|
||||||
|
} from "@/src/server";
|
||||||
|
import { CUSTOM_PROVIDER, normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides } from "@/src/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const cp = effectiveSettings().customProvider;
|
||||||
|
return Response.json({
|
||||||
|
baseUrl: cp.baseUrl,
|
||||||
|
models: cp.models,
|
||||||
|
overrides: cp.overrides,
|
||||||
|
manualModels: cp.manualModels,
|
||||||
|
keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown; manualModels?: unknown } | null;
|
||||||
|
const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : "");
|
||||||
|
if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 });
|
||||||
|
|
||||||
|
// Empty key string clears it, absent leaves it stored; only a real key rides the probe.
|
||||||
|
const keyProvided = typeof body?.key === "string";
|
||||||
|
const key = keyProvided ? (body!.key as string).trim() : undefined;
|
||||||
|
|
||||||
|
const manualModels = parseModelIds(body?.manualModels);
|
||||||
|
const record = readSettingsRecord() ?? { version: 1 };
|
||||||
|
const found = await discoverModels(base, key ? key : undefined);
|
||||||
|
// A rejected key is not a missing model list: never let typed ids turn an
|
||||||
|
// auth failure into a stored "connected" endpoint.
|
||||||
|
if (!found.ok && (found.status === 401 || found.status === 403)) {
|
||||||
|
return Response.json({ error: found.error }, { status: 502 });
|
||||||
|
}
|
||||||
|
if (!found.ok && !manualModels.length) {
|
||||||
|
return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 });
|
||||||
|
}
|
||||||
|
// A failed probe says nothing about what the endpoint offers: keep the last
|
||||||
|
// snapshot of the SAME endpoint, so a transient failure cannot shrink the
|
||||||
|
// model list and prune its pins. A different base URL starts clean.
|
||||||
|
const discovered = found.ok
|
||||||
|
? found.models
|
||||||
|
: record.customProvider?.baseUrl === base
|
||||||
|
? (record.customProvider?.models ?? [])
|
||||||
|
: [];
|
||||||
|
const models = mergeManualModels(discovered, manualModels);
|
||||||
|
|
||||||
|
if (keyProvided) {
|
||||||
|
if (key) {
|
||||||
|
const saved = saveKey(CUSTOM_PROVIDER.envVar, key);
|
||||||
|
if (!saved.ok) return Response.json({ error: saved.error }, { status: 400 });
|
||||||
|
} else {
|
||||||
|
deleteKey(CUSTOM_PROVIDER.envVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User pins are never rewritten by discovery — only pruned to the ids this
|
||||||
|
// connect offers (discovered ∪ typed).
|
||||||
|
const overrides = sanitizeCustomModelOverrides(
|
||||||
|
record.customProvider?.overrides,
|
||||||
|
models.map((m) => m.id),
|
||||||
|
);
|
||||||
|
const next = writeSettingsRecord({
|
||||||
|
...record,
|
||||||
|
customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) },
|
||||||
|
});
|
||||||
|
syncCustomProvider(next.customProvider);
|
||||||
|
return Response.json({ ok: true, models: next.customProvider?.models ?? [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store the per-model pins (replace-whole). Pruned against the STORED model
|
||||||
|
* list, never a probe — an offline endpoint keeps its pins editable. */
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const parsed = parseCustomModelOverridesBody(await request.json().catch(() => null));
|
||||||
|
if (!parsed.ok) return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
|
||||||
|
const cp = effectiveSettings().customProvider;
|
||||||
|
if (!cp.baseUrl) return Response.json({ error: "No custom endpoint configured" }, { status: 409 });
|
||||||
|
|
||||||
|
const overrides = sanitizeCustomModelOverrides(
|
||||||
|
parsed.settings,
|
||||||
|
cp.models.map((m) => m.id),
|
||||||
|
);
|
||||||
|
const record = readSettingsRecord() ?? { version: 1 };
|
||||||
|
const next = writeSettingsRecord({
|
||||||
|
...record,
|
||||||
|
customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides, ...(cp.manualModels.length ? { manualModels: cp.manualModels } : {}) },
|
||||||
|
});
|
||||||
|
syncCustomProvider(next.customProvider);
|
||||||
|
return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnect: forget the endpoint AND its key, then unregister the provider.
|
||||||
|
* Deleting the section (rather than blanking baseUrl) keeps settings.json the
|
||||||
|
* same shape as a never-connected install. Idempotent by construction. */
|
||||||
|
export async function DELETE() {
|
||||||
|
const record = readSettingsRecord() ?? { version: 1 };
|
||||||
|
const { customProvider: _dropped, ...rest } = record;
|
||||||
|
const next = writeSettingsRecord(rest);
|
||||||
|
deleteKey(CUSTOM_PROVIDER.envVar);
|
||||||
|
syncCustomProvider(next.customProvider);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -600,9 +600,6 @@ export function AgentSection({
|
|||||||
const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined;
|
const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined;
|
||||||
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
||||||
const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null;
|
const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null;
|
||||||
// Vision slots pick from image-capable models only.
|
|
||||||
const slotCatalog =
|
|
||||||
slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog;
|
|
||||||
return (
|
return (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
key={slot}
|
key={slot}
|
||||||
@@ -647,9 +644,10 @@ export function AgentSection({
|
|||||||
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
<ModelMenuList
|
<ModelMenuList
|
||||||
models={slotCatalog ?? undefined}
|
models={catalog ?? undefined}
|
||||||
effectiveId={modelId}
|
effectiveId={modelId}
|
||||||
effectiveProvider={provider}
|
effectiveProvider={provider}
|
||||||
|
vision={slot === "vision"}
|
||||||
onPick={(entry) => {
|
onPick={(entry) => {
|
||||||
// Auto-populate (chunk 2, owner: "populate everywhere"): the
|
// Auto-populate (chunk 2, owner: "populate everywhere"): the
|
||||||
// fresh pick writes a level — the stored one rides as-is
|
// fresh pick writes a level — the stored one rides as-is
|
||||||
|
|||||||
@@ -24,7 +24,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
import {
|
||||||
|
AUTH_KEYS,
|
||||||
|
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||||
|
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||||
|
CUSTOM_PROVIDER,
|
||||||
|
type AuthKeyStatus,
|
||||||
|
type CustomModel,
|
||||||
|
type CustomModelOverride,
|
||||||
|
} from "@/src/core";
|
||||||
import { SettingsGroup } from "./controls.tsx";
|
import { SettingsGroup } from "./controls.tsx";
|
||||||
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||||
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
|
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
|
||||||
@@ -46,12 +54,29 @@ interface KeysResponse {
|
|||||||
providers?: ProviderKeyInfo[];
|
providers?: ProviderKeyInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The custom endpoint's non-secret status (GET /api/custom-provider). */
|
||||||
|
interface CustomStatus {
|
||||||
|
baseUrl: string;
|
||||||
|
models: CustomModel[];
|
||||||
|
/** User pins by model id (context / max output / thinking). */
|
||||||
|
overrides: Record<string, CustomModelOverride>;
|
||||||
|
manualModels: string[];
|
||||||
|
keyPresent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
* section can never drift from core/auth-keys.ts again. */
|
* section can never drift from core/auth-keys.ts again. */
|
||||||
const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[];
|
const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[];
|
||||||
|
|
||||||
|
/** The custom endpoint's synthetic Add-provider entry (offered only while unconfigured). */
|
||||||
|
export const CUSTOM_PROVIDER_INFO: ProviderKeyInfo = {
|
||||||
|
id: CUSTOM_PROVIDER.id,
|
||||||
|
envVar: CUSTOM_PROVIDER.envVar,
|
||||||
|
label: CUSTOM_PROVIDER.label,
|
||||||
|
};
|
||||||
|
|
||||||
/** The classification driving the table groups. Pure — exported for
|
/** The classification driving the table groups. Pure — exported for
|
||||||
* tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
|
* tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
|
||||||
* no special row — it rides the same provider classification. */
|
* no special row — it rides the same provider classification. */
|
||||||
@@ -84,6 +109,10 @@ export function classifyKeys(
|
|||||||
const unknownRows: AuthKeyStatus[] = [];
|
const unknownRows: AuthKeyStatus[] = [];
|
||||||
for (const s of statuses) {
|
for (const s of statuses) {
|
||||||
if (isTool(s.id)) continue;
|
if (isTool(s.id)) continue;
|
||||||
|
// The custom endpoint's key has its OWN home (the Custom endpoint
|
||||||
|
// section), so it never shows here — not as a provider row, not as an
|
||||||
|
// "other stored variable".
|
||||||
|
if (s.id === CUSTOM_PROVIDER.envVar) continue;
|
||||||
if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s);
|
if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s);
|
||||||
else if (!s.info) unknownRows.push(s);
|
else if (!s.info) unknownRows.push(s);
|
||||||
// (pre-interlude catalogs: a curated-but-unclassified status still
|
// (pre-interlude catalogs: a curated-but-unclassified status still
|
||||||
@@ -300,7 +329,7 @@ function AddProviderRow({
|
|||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<span>+ Add provider key</span>
|
<span>+ Add a provider</span>
|
||||||
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -343,13 +372,16 @@ export function KeysSection({
|
|||||||
/** The paste editor for a freshly chosen Add-provider target. */
|
/** The paste editor for a freshly chosen Add-provider target. */
|
||||||
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
|
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
|
||||||
const [addError, setAddError] = useState<string | null>(null);
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
/** The custom endpoint's status + whether its connect editor is open. */
|
||||||
|
const [custom, setCustom] = useState<CustomStatus | null>(null);
|
||||||
|
const [addingCustom, setAddingCustom] = useState(false);
|
||||||
|
|
||||||
// Escape folds any open editor (unfolded row or the Add-provider paste)
|
// Escape folds any open editor (unfolded row or the Add-provider paste)
|
||||||
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
|
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
|
||||||
// only stopPropagation that outruns both the input's React handler and
|
// only stopPropagation that outruns both the input's React handler and
|
||||||
// the dialog's window-level listener.
|
// the dialog's window-level listener.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openRow === null && adding === null) return;
|
if (openRow === null && adding === null && !addingCustom) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
// A language dropdown above this editor in the dismissal stack owns
|
// A language dropdown above this editor in the dismissal stack owns
|
||||||
@@ -359,20 +391,37 @@ export function KeysSection({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenRow(null);
|
setOpenRow(null);
|
||||||
setAdding(null);
|
setAdding(null);
|
||||||
|
setAddingCustom(false);
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", onKeyDown, true);
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||||
}, [openRow, adding]);
|
}, [openRow, adding, addingCustom]);
|
||||||
|
|
||||||
const reload = () =>
|
const reload = async () => {
|
||||||
fetch("/api/keys")
|
try {
|
||||||
.then((r) => r.json())
|
const body = (await fetch("/api/keys").then((r) => r.json())) as KeysResponse;
|
||||||
.then((body: KeysResponse) => {
|
|
||||||
setStatuses(body.keys ?? []);
|
setStatuses(body.keys ?? []);
|
||||||
if (body.providers) setProviders(body.providers);
|
if (body.providers) setProviders(body.providers);
|
||||||
})
|
} catch {
|
||||||
.catch(() => setStatuses([]))
|
setStatuses([]);
|
||||||
.finally(() => setLoading(false));
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// Best-effort and independent: the custom endpoint's status must never
|
||||||
|
// blank the provider-key list above if its fetch fails.
|
||||||
|
try {
|
||||||
|
const cp = (await fetch("/api/custom-provider").then((r) => r.json())) as Partial<CustomStatus>;
|
||||||
|
setCustom({
|
||||||
|
baseUrl: cp.baseUrl ?? "",
|
||||||
|
models: cp.models ?? [],
|
||||||
|
overrides: cp.overrides ?? {},
|
||||||
|
manualModels: cp.manualModels ?? [],
|
||||||
|
keyPresent: Boolean(cp.keyPresent),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* custom status unavailable — leave it unset */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
@@ -409,6 +458,15 @@ export function KeysSection({
|
|||||||
notifySettingsSaved("keys");
|
notifySettingsSaved("keys");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forget the custom endpoint (config + key server-side), same rhythm as
|
||||||
|
* deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */
|
||||||
|
async function disconnectCustom(): Promise<void> {
|
||||||
|
await fetch("/api/custom-provider", { method: "DELETE" }).catch(() => undefined);
|
||||||
|
setAddingCustom(false);
|
||||||
|
await reload();
|
||||||
|
notifySettingsSaved("keys");
|
||||||
|
}
|
||||||
|
|
||||||
if (loading || statuses === null) {
|
if (loading || statuses === null) {
|
||||||
return <p className="px-6 py-5 text-xs text-muted-foreground/70">Loading keys…</p>;
|
return <p className="px-6 py-5 text-xs text-muted-foreground/70">Loading keys…</p>;
|
||||||
}
|
}
|
||||||
@@ -445,12 +503,45 @@ export function KeysSection({
|
|||||||
testid={`key-${status.id.toLowerCase()}`}
|
testid={`key-${status.id.toLowerCase()}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{custom?.baseUrl && (
|
||||||
|
<CustomEndpointRow
|
||||||
|
status={custom}
|
||||||
|
expanded={addingCustom}
|
||||||
|
onToggle={() => setAddingCustom((v) => !v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{addingCustom && (
|
||||||
|
<CustomEndpointEditor
|
||||||
|
baseUrl0={custom?.baseUrl ?? ""}
|
||||||
|
keyPresent={custom?.keyPresent ?? false}
|
||||||
|
models={custom?.models ?? []}
|
||||||
|
overrides={custom?.overrides ?? {}}
|
||||||
|
manualModels0={custom?.manualModels ?? []}
|
||||||
|
onSaved={async () => {
|
||||||
|
setAddingCustom(false);
|
||||||
|
await reload();
|
||||||
|
notifySettingsSaved("keys");
|
||||||
|
}}
|
||||||
|
onPinsSaved={async () => {
|
||||||
|
await reload();
|
||||||
|
notifySettingsSaved("keys");
|
||||||
|
}}
|
||||||
|
onDelete={disconnectCustom}
|
||||||
|
onCancel={() => setAddingCustom(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{providers !== undefined && (
|
{providers !== undefined && (
|
||||||
<AddProviderRow
|
<AddProviderRow
|
||||||
addable={addable}
|
addable={custom?.baseUrl ? addable : [...addable, CUSTOM_PROVIDER_INFO]}
|
||||||
onPick={(p) => {
|
onPick={(p) => {
|
||||||
setAdding(p);
|
|
||||||
setAddError(null);
|
setAddError(null);
|
||||||
|
if (p.id === CUSTOM_PROVIDER.id) {
|
||||||
|
setAdding(null);
|
||||||
|
setAddingCustom(true);
|
||||||
|
} else {
|
||||||
|
setAddingCustom(false);
|
||||||
|
setAdding(p);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -566,3 +657,320 @@ function AddProviderEditor({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The custom endpoint's status row (shown once configured); clicking re-opens the editor. */
|
||||||
|
function CustomEndpointRow({
|
||||||
|
status,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
status: CustomStatus;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const count = status.models.length;
|
||||||
|
const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${status.keyPresent ? "" : " · keyless"}`;
|
||||||
|
return (
|
||||||
|
<div data-testid="custom-endpoint-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="custom-endpoint-row-btn"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left cursor-pointer transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<span className="font-ui text-xs text-foreground">Custom endpoint</span>
|
||||||
|
<span data-testid="custom-endpoint-status" className="max-w-[60%] truncate font-mono text-[11px] text-muted-foreground">
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pin row's raw input state — "" means "no pin": the endpoint's own value,
|
||||||
|
* then the compiled default, decides. `reasoning` is tri-state. */
|
||||||
|
interface PinDraft {
|
||||||
|
context: string;
|
||||||
|
maxTokens: string;
|
||||||
|
reasoning: "" | "on" | "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinDraft = (pin: CustomModelOverride | undefined): PinDraft => ({
|
||||||
|
context: pin?.context === undefined ? "" : String(pin.context),
|
||||||
|
maxTokens: pin?.maxTokens === undefined ? "" : String(pin.maxTokens),
|
||||||
|
reasoning: pin?.reasoning === undefined ? "" : pin.reasoning ? "on" : "off",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One draft row per discovered model, seeded from the stored pins. */
|
||||||
|
function draftsFor(
|
||||||
|
models: readonly CustomModel[],
|
||||||
|
overrides: Record<string, CustomModelOverride>,
|
||||||
|
): Record<string, PinDraft> {
|
||||||
|
return Object.fromEntries(models.map((m) => [m.id, pinDraft(overrides[m.id])]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drafts → the PATCH payload (replace-whole): a blank field sends nothing and
|
||||||
|
* a fully blank model is absent — which IS the un-pin. */
|
||||||
|
function pinsPayload(drafts: Record<string, PinDraft>): Record<string, CustomModelOverride> {
|
||||||
|
const out: Record<string, CustomModelOverride> = {};
|
||||||
|
for (const [id, d] of Object.entries(drafts)) {
|
||||||
|
const pin: CustomModelOverride = {};
|
||||||
|
if (d.context.trim()) pin.context = Number(d.context);
|
||||||
|
if (d.maxTokens.trim()) pin.maxTokens = Number(d.maxTokens);
|
||||||
|
if (d.reasoning !== "") pin.reasoning = d.reasoning === "on";
|
||||||
|
if (Object.keys(pin).length) out[id] = pin;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** testids must survive ids carrying slashes, colons, dots. */
|
||||||
|
const pinTestid = (prefix: string, id: string) => `${prefix}-${id.replace(/[^a-z0-9-]/gi, "_")}`;
|
||||||
|
|
||||||
|
/** The custom endpoint's connect editor: base URL + key → POST /api/custom-provider
|
||||||
|
* (validate + discover + register), and the per-model limits form → PATCH (pins,
|
||||||
|
* no probe, so it works with the local server switched off). Image capability is
|
||||||
|
* detected automatically. */
|
||||||
|
function CustomEndpointEditor({
|
||||||
|
baseUrl0,
|
||||||
|
keyPresent,
|
||||||
|
models,
|
||||||
|
overrides,
|
||||||
|
manualModels0,
|
||||||
|
onSaved,
|
||||||
|
onPinsSaved,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
baseUrl0: string;
|
||||||
|
keyPresent: boolean;
|
||||||
|
/** The discovered models — one limits row each. */
|
||||||
|
models: readonly CustomModel[];
|
||||||
|
/** Their stored user pins. */
|
||||||
|
overrides: Record<string, CustomModelOverride>;
|
||||||
|
manualModels0: readonly string[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
/** Pins saved — the editor stays open (a form, not a connect). */
|
||||||
|
onPinsSaved: () => void | Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDelete?: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [baseUrl, setBaseUrl] = useState(baseUrl0);
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [keyTouched, setKeyTouched] = useState(false);
|
||||||
|
const [manualModels, setManualModels] = useState(manualModels0.join(", "));
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// The limits form: edited drafts over the stored pins, plus the last saved
|
||||||
|
// payload as the dirty baseline (the server's echo is what gets stored).
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, PinDraft>>(() => draftsFor(models, overrides));
|
||||||
|
const [baseline, setBaseline] = useState(() => JSON.stringify(pinsPayload(draftsFor(models, overrides))));
|
||||||
|
const [pinsBusy, setPinsBusy] = useState(false);
|
||||||
|
const [pinsError, setPinsError] = useState<string | null>(null);
|
||||||
|
// A key is required to enable chatting; local servers accept any value. A
|
||||||
|
// stored key is kept when the field is left blank.
|
||||||
|
const needsKey = !keyPresent;
|
||||||
|
const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim()));
|
||||||
|
|
||||||
|
const draftOf = (m: CustomModel): PinDraft => drafts[m.id] ?? pinDraft(overrides[m.id]);
|
||||||
|
const setDraft = (m: CustomModel, patch: Partial<PinDraft>) =>
|
||||||
|
setDrafts((prev) => ({ ...prev, [m.id]: { ...draftOf(m), ...patch } }));
|
||||||
|
const current = Object.fromEntries(models.map((m) => [m.id, draftOf(m)])) as Record<string, PinDraft>;
|
||||||
|
const dirty = JSON.stringify(pinsPayload(current)) !== baseline;
|
||||||
|
|
||||||
|
async function savePins() {
|
||||||
|
if (pinsBusy || !dirty) return;
|
||||||
|
setPinsBusy(true);
|
||||||
|
setPinsError(null);
|
||||||
|
const res = await fetch("/api/custom-provider", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ overrides: pinsPayload(current) }),
|
||||||
|
}).catch(() => null);
|
||||||
|
setPinsBusy(false);
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
|
||||||
|
setPinsError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The server's stored map is the truth (it floors + prunes) — echo it back.
|
||||||
|
const body = (await res.json().catch(() => null)) as { overrides?: Record<string, CustomModelOverride> } | null;
|
||||||
|
const next = draftsFor(models, body?.overrides ?? pinsPayload(current));
|
||||||
|
setDrafts(next);
|
||||||
|
setBaseline(JSON.stringify(pinsPayload(next)));
|
||||||
|
await onPinsSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (busy || !baseUrl.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/custom-provider", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ baseUrl, manualModels, ...(keyTouched ? { key } : {}) }),
|
||||||
|
}).catch(() => null);
|
||||||
|
setBusy(false);
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
|
||||||
|
setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="custom-endpoint-editor" className="flex flex-col gap-2 rounded-sm px-1 py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="shrink-0 font-ui text-xs text-foreground">Custom endpoint</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
placeholder="Base URL, e.g. http://localhost:11434/v1"
|
||||||
|
aria-label="Custom endpoint base URL"
|
||||||
|
data-testid="custom-baseurl-input"
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualModels}
|
||||||
|
onChange={(e) => setManualModels(e.target.value)}
|
||||||
|
placeholder="Model IDs, comma-separated — optional, needed when the endpoint has no /models list"
|
||||||
|
aria-label="Custom endpoint model IDs"
|
||||||
|
data-testid="custom-models-input"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => {
|
||||||
|
setKey(e.target.value);
|
||||||
|
setKeyTouched(true);
|
||||||
|
}}
|
||||||
|
placeholder={keyPresent ? "Key set — paste to replace, leave empty to keep" : "API key — required (any value works for local servers)"}
|
||||||
|
aria-label="Custom endpoint API key"
|
||||||
|
data-testid="custom-key-input"
|
||||||
|
disabled={busy}
|
||||||
|
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={busy || !canConnect}
|
||||||
|
data-testid="custom-save"
|
||||||
|
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||||||
|
>
|
||||||
|
{busy ? "Connecting…" : "Connect"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="custom-cancel"
|
||||||
|
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/* Disconnect rides beside Connect, exactly the stored-key rows' Delete:
|
||||||
|
quiet text, one click, no confirm — and only once there IS an
|
||||||
|
endpoint (a fresh add has nothing to delete). */}
|
||||||
|
{onDelete && baseUrl0.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onDelete()}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="custom-delete"
|
||||||
|
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{models.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1" data-testid="custom-pins">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
Model limits — blank uses the endpoint’s value, then the default
|
||||||
|
</span>
|
||||||
|
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1">
|
||||||
|
{models.map((m) => {
|
||||||
|
const d = draftOf(m);
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="flex items-center gap-2" data-testid={pinTestid("custom-pin-row", m.id)}>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground" title={m.id}>
|
||||||
|
{m.id}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={d.context}
|
||||||
|
onChange={(e) => setDraft(m, { context: e.target.value })}
|
||||||
|
placeholder={String(m.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW)}
|
||||||
|
aria-label={`${m.id} context window in tokens`}
|
||||||
|
data-testid={pinTestid("custom-pin-context", m.id)}
|
||||||
|
disabled={pinsBusy}
|
||||||
|
className="h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={d.maxTokens}
|
||||||
|
onChange={(e) => setDraft(m, { maxTokens: e.target.value })}
|
||||||
|
placeholder={String(m.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS)}
|
||||||
|
aria-label={`${m.id} max output tokens`}
|
||||||
|
data-testid={pinTestid("custom-pin-maxtokens", m.id)}
|
||||||
|
disabled={pinsBusy}
|
||||||
|
className="h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={d.reasoning}
|
||||||
|
onChange={(e) => setDraft(m, { reasoning: e.target.value as PinDraft["reasoning"] })}
|
||||||
|
aria-label={`${m.id} thinking`}
|
||||||
|
data-testid={pinTestid("custom-pin-reasoning", m.id)}
|
||||||
|
disabled={pinsBusy}
|
||||||
|
className="h-7 shrink-0 rounded-sm border border-border bg-background px-1 font-ui text-[11px] text-foreground outline-none focus:border-muted-foreground/50"
|
||||||
|
>
|
||||||
|
<option value="">think: auto</option>
|
||||||
|
<option value="on">think: on</option>
|
||||||
|
<option value="off">think: off</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void savePins()}
|
||||||
|
disabled={pinsBusy || !dirty}
|
||||||
|
data-testid="custom-pins-save"
|
||||||
|
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||||||
|
>
|
||||||
|
{pinsBusy ? "Saving…" : "Save limits"}
|
||||||
|
</button>
|
||||||
|
{pinsError && (
|
||||||
|
<p role="alert" data-testid="custom-pins-error" className="text-[11px] text-red-500">
|
||||||
|
{pinsError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsKey && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" data-testid="custom-error" className="text-xs text-red-500">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ModelCatalogEntry } from "@/src/core";
|
import type { ModelCatalogEntry } from "@/src/core";
|
||||||
import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
|
import { CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||||
import { formatTokens } from "@/components/usage-format";
|
import { formatTokens } from "@/components/usage-format";
|
||||||
import { SearchMenuList } from "./search-menu-list";
|
import { SearchMenuList } from "./search-menu-list";
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ export function ModelMenuList({
|
|||||||
effectiveProvider,
|
effectiveProvider,
|
||||||
onPick,
|
onPick,
|
||||||
footer,
|
footer,
|
||||||
|
vision,
|
||||||
}: {
|
}: {
|
||||||
/** The full catalog — undefined while it loads. */
|
/** The full catalog — undefined while it loads. */
|
||||||
models: ModelCatalogEntry[] | undefined;
|
models: ModelCatalogEntry[] | undefined;
|
||||||
@@ -54,14 +55,25 @@ export function ModelMenuList({
|
|||||||
onPick: (entry: ModelCatalogEntry) => void;
|
onPick: (entry: ModelCatalogEntry) => void;
|
||||||
/** Quiet footer note under the list (context-specific). */
|
/** Quiet footer note under the list (context-specific). */
|
||||||
footer?: string;
|
footer?: string;
|
||||||
|
/** Vision slot: only image-capable models are selectable — plus every
|
||||||
|
* custom-endpoint model, whose capability the endpoint may not report.
|
||||||
|
* Image-capable ones float to the top. */
|
||||||
|
vision?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
// Vision slot: built-ins must report image input; custom-endpoint models
|
||||||
|
// stay selectable (the endpoint may not report a modality). Image-capable
|
||||||
|
// ones float to the top of the list.
|
||||||
|
const visionItems = vision
|
||||||
|
? models?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id)
|
||||||
|
: models;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchMenuList
|
<SearchMenuList
|
||||||
items={models}
|
items={visionItems}
|
||||||
itemKey={(m) => `${m.provider}/${m.id}`}
|
itemKey={(m) => `${m.provider}/${m.id}`}
|
||||||
searchTerms={(m) => [m.provider, m.id, m.name]}
|
searchTerms={(m) => [m.provider, m.id, m.name]}
|
||||||
compare={(a, b) => a.id.localeCompare(b.id)}
|
compare={(a, b) => a.id.localeCompare(b.id)}
|
||||||
|
prioritize={vision ? (m) => m.input.includes("image") : undefined}
|
||||||
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
|
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
searchTestid="model-search"
|
searchTestid="model-search"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { fuzzyScore } from "@/src/core";
|
|||||||
* Models and dictation languages supply only their catalog and row content.
|
* Models and dictation languages supply only their catalog and row content.
|
||||||
* The caller owns the shared MenuShell and dismissal behavior. */
|
* The caller owns the shared MenuShell and dismissal behavior. */
|
||||||
export function SearchMenuList<T>({
|
export function SearchMenuList<T>({
|
||||||
items, itemKey, searchTerms, compare, isSelected, onPick, renderItem,
|
items, itemKey, searchTerms, compare, prioritize, isSelected, onPick, renderItem,
|
||||||
searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty,
|
searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
@@ -15,6 +15,10 @@ export function SearchMenuList<T>({
|
|||||||
itemKey: (item: T) => string;
|
itemKey: (item: T) => string;
|
||||||
searchTerms: (item: T) => string[];
|
searchTerms: (item: T) => string[];
|
||||||
compare?: (a: T, b: T) => number;
|
compare?: (a: T, b: T) => number;
|
||||||
|
/** Optional grouping key: items it answers true for sort ahead of the
|
||||||
|
* rest, fuzzy rank still deciding within each group (a stable partition,
|
||||||
|
* never a filter — nothing is hidden). */
|
||||||
|
prioritize?: (item: T) => boolean;
|
||||||
isSelected: (item: T) => boolean;
|
isSelected: (item: T) => boolean;
|
||||||
onPick: (item: T) => void;
|
onPick: (item: T) => void;
|
||||||
renderItem: (item: T) => ReactNode;
|
renderItem: (item: T) => ReactNode;
|
||||||
@@ -41,7 +45,12 @@ export function SearchMenuList<T>({
|
|||||||
const results = (items ?? [])
|
const results = (items ?? [])
|
||||||
.map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) }))
|
.map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) }))
|
||||||
.filter((result) => result.score !== null)
|
.filter((result) => result.score !== null)
|
||||||
.sort((a, b) => b.score! - a.score! || (compare?.(a.item, b.item) ?? 0))
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(prioritize ? Number(prioritize(b.item)) - Number(prioritize(a.item)) : 0) ||
|
||||||
|
b.score! - a.score! ||
|
||||||
|
(compare?.(a.item, b.item) ?? 0),
|
||||||
|
)
|
||||||
.map(({ item }) => item);
|
.map(({ item }) => item);
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
|
|||||||
@@ -16,10 +16,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core";
|
import { CUSTOM_PROVIDER, MODEL_SLOTS, type AuthKeyStatus, type ModelCatalogResponse } from "@/src/core";
|
||||||
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { useModelCatalog } from "@/components/model-catalog";
|
import { useModelCatalog } from "@/components/model-catalog";
|
||||||
import type { ModelCatalogResponse } from "@/src/core";
|
|
||||||
|
|
||||||
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
||||||
* classifyKeys (the keys section's own classification — no drift):
|
* classifyKeys (the keys section's own classification — no drift):
|
||||||
@@ -30,7 +29,13 @@ export function hasProviderKey(
|
|||||||
statuses: readonly AuthKeyStatus[],
|
statuses: readonly AuthKeyStatus[],
|
||||||
providers: readonly ProviderKeyInfo[] | undefined,
|
providers: readonly ProviderKeyInfo[] | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
|
// A stored custom-endpoint key counts too: classifyKeys drops it from
|
||||||
|
// providerRows (it has its own dedicated row), but a connected custom
|
||||||
|
// endpoint IS a way to run inference, so it clears the zero-keys gate.
|
||||||
|
return (
|
||||||
|
classifyKeys(statuses, providers).providerRows.some((s) => s.present) ||
|
||||||
|
statuses.some((s) => s.id === CUSTOM_PROVIDER.envVar && s.present)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Completeness is saved identity, not current provider availability. */
|
/** Completeness is saved identity, not current provider availability. */
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
|
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings, CUSTOM_PROVIDER } from "@/src/core";
|
||||||
import { prettyModel } from "@/components/usage-format";
|
import { prettyModel } from "@/components/usage-format";
|
||||||
import { ModelMenuList } from "@/components/settings/model-menu";
|
import { ModelMenuList } from "@/components/settings/model-menu";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
@@ -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 } from "@/components/settings/keys-section";
|
import { type ProviderKeyInfo, CUSTOM_PROVIDER_INFO } 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";
|
||||||
@@ -134,6 +134,9 @@ export function SetupKeysBody({
|
|||||||
onValue,
|
onValue,
|
||||||
onSave,
|
onSave,
|
||||||
menuAnchor,
|
menuAnchor,
|
||||||
|
isCustom,
|
||||||
|
baseUrl,
|
||||||
|
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. */
|
||||||
@@ -151,6 +154,11 @@ export function SetupKeysBody({
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
|
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
|
||||||
menuAnchor: React.RefObject<HTMLDivElement | null>;
|
menuAnchor: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** Chosen provider is the custom endpoint: a base URL field joins the key
|
||||||
|
* field. All three optional so plain-key callers (and tests) stay unchanged. */
|
||||||
|
isCustom?: boolean;
|
||||||
|
baseUrl?: string;
|
||||||
|
onBaseUrl?: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
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">
|
||||||
@@ -185,7 +193,20 @@ export function SetupKeysBody({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{provider && (
|
{provider && (
|
||||||
<div className="flex max-w-96 items-center 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">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
@@ -193,7 +214,7 @@ export function SetupKeysBody({
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && value.trim()) onSave();
|
if (e.key === "Enter" && value.trim()) onSave();
|
||||||
}}
|
}}
|
||||||
placeholder="Paste key…"
|
placeholder={isCustom ? "API key — required (any value works for local servers)" : "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}
|
||||||
@@ -207,9 +228,10 @@ 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…" : "Save key"}
|
{saving ? "Saving…" : isCustom ? "Connect" : "Save key"}
|
||||||
</button>
|
</button>
|
||||||
</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">
|
||||||
@@ -264,6 +286,7 @@ 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);
|
||||||
@@ -374,10 +397,19 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
* close recovery or advance to the missing model slots. Shape-gate
|
* close recovery or advance to the missing model slots. Shape-gate
|
||||||
* 400s keep their user-facing line inline. */
|
* 400s keep their user-facing line inline. */
|
||||||
async function saveKey() {
|
async function saveKey() {
|
||||||
if (!keyProvider || !keyValue.trim() || keySaving) return;
|
if (!keyProvider || keySaving) return;
|
||||||
|
const isCustom = keyProvider.id === CUSTOM_PROVIDER.id;
|
||||||
|
if (!keyValue.trim()) return;
|
||||||
setKeySaving(true);
|
setKeySaving(true);
|
||||||
setKeyError(null);
|
setKeyError(null);
|
||||||
const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
|
// Custom endpoint connects through its own route; every other provider is a plain key PUT.
|
||||||
|
const res = isCustom
|
||||||
|
? await fetch("/api/custom-provider", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ baseUrl: customBaseUrl, key: keyValue }),
|
||||||
|
}).catch(() => null)
|
||||||
|
: await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ value: keyValue }),
|
body: JSON.stringify({ value: keyValue }),
|
||||||
@@ -390,6 +422,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
}
|
}
|
||||||
setKeyProvider(null);
|
setKeyProvider(null);
|
||||||
setKeyValue("");
|
setKeyValue("");
|
||||||
|
setCustomBaseUrl("");
|
||||||
notifySettingsSaved("keys");
|
notifySettingsSaved("keys");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,13 +453,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keysBody = <SetupKeysBody
|
const keysBody = <SetupKeysBody
|
||||||
providers={keyProviders}
|
providers={keyProviders ? [...keyProviders, CUSTOM_PROVIDER_INFO] : undefined}
|
||||||
provider={keyProvider}
|
provider={keyProvider}
|
||||||
value={keyValue}
|
value={keyValue}
|
||||||
saving={keySaving}
|
saving={keySaving}
|
||||||
error={keyError}
|
error={keyError}
|
||||||
menuOpen={keyMenuOpen}
|
menuOpen={keyMenuOpen}
|
||||||
menuAnchor={keyTriggerRef}
|
menuAnchor={keyTriggerRef}
|
||||||
|
isCustom={keyProvider?.id === CUSTOM_PROVIDER.id}
|
||||||
|
baseUrl={customBaseUrl}
|
||||||
|
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}
|
||||||
@@ -483,7 +519,9 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
// Unavailable metadata cannot erase or clamp a saved preference.
|
// Unavailable metadata cannot erase or clamp a saved preference.
|
||||||
const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []);
|
const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []);
|
||||||
const available = catalog?.models ?? (failed ? [] : undefined);
|
const available = catalog?.models ?? (failed ? [] : undefined);
|
||||||
const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available;
|
const slotCatalog = slot === "vision"
|
||||||
|
? available?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id)
|
||||||
|
: available;
|
||||||
return (
|
return (
|
||||||
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
@@ -537,6 +575,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
models={slotCatalog}
|
models={slotCatalog}
|
||||||
effectiveId={entry?.id}
|
effectiveId={entry?.id}
|
||||||
effectiveProvider={entry?.provider}
|
effectiveProvider={entry?.provider}
|
||||||
|
vision={slot === "vision"}
|
||||||
onPick={(m) => {
|
onPick={(m) => {
|
||||||
editedSlots.current.add(slot);
|
editedSlots.current.add(slot);
|
||||||
setPicked((prev) => ({ ...prev, [slot]: m }));
|
setPicked((prev) => ({ ...prev, [slot]: m }));
|
||||||
|
|||||||
@@ -122,6 +122,19 @@ export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [
|
|||||||
{ id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" },
|
{ id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE single custom endpoint's identity: a user-configured OpenAI-compatible
|
||||||
|
* server registered into pi-ai at runtime (server/custom-provider.ts). NOT part
|
||||||
|
* of AUTH_PROVIDER_KEYS (that table is test-pinned to pi-ai's built-ins and is
|
||||||
|
* env-var-keyed); this one is keyed by a user-supplied base URL, and its key
|
||||||
|
* rides this reserved env var (optional — keyless local servers allowed).
|
||||||
|
*/
|
||||||
|
export const CUSTOM_PROVIDER = {
|
||||||
|
id: "custom",
|
||||||
|
envVar: "CUSTOM_OPENAI_API_KEY",
|
||||||
|
label: "Custom endpoint",
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Every distinct env var a model-provider key rides in (the write side's
|
/** Every distinct env var a model-provider key rides in (the write side's
|
||||||
* writability check — see isProviderEnvVar below; also drives which
|
* writability check — see isProviderEnvVar below; also drives which
|
||||||
* PRESENT provider vars surface as status rows). */
|
* PRESENT provider vars surface as status rows). */
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
type GeneralSettings,
|
type GeneralSettings,
|
||||||
type DictationSettings,
|
type DictationSettings,
|
||||||
type CompactionSettings,
|
type CompactionSettings,
|
||||||
|
type CustomModel,
|
||||||
|
type CustomModelOverride,
|
||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type ModelSlot,
|
type ModelSlot,
|
||||||
type ModelSlotName,
|
type ModelSlotName,
|
||||||
@@ -95,6 +97,9 @@ export interface EffectiveSettings {
|
|||||||
/** Compaction (owner round 14): resolved concrete — absent record
|
/** Compaction (owner round 14): resolved concrete — absent record
|
||||||
* section resolves to the factory 150k. */
|
* section resolves to the factory 150k. */
|
||||||
compaction: Required<CompactionSettings>;
|
compaction: Required<CompactionSettings>;
|
||||||
|
/** Custom endpoint, resolved concrete: baseUrl defaults "", models and
|
||||||
|
* user pins default to empty. */
|
||||||
|
customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record<string, CustomModelOverride>; manualModels: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own
|
/** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own
|
||||||
@@ -221,6 +226,12 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
|||||||
compaction: {
|
compaction: {
|
||||||
thresholdTokens: record?.compaction?.thresholdTokens ?? FACTORY_SETTINGS.compaction.thresholdTokens,
|
thresholdTokens: record?.compaction?.thresholdTokens ?? FACTORY_SETTINGS.compaction.thresholdTokens,
|
||||||
},
|
},
|
||||||
|
customProvider: {
|
||||||
|
baseUrl: record?.customProvider?.baseUrl ?? "",
|
||||||
|
models: record?.customProvider?.models ?? [],
|
||||||
|
overrides: record?.customProvider?.overrides ?? {},
|
||||||
|
manualModels: record?.customProvider?.manualModels ?? [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,96 @@ export interface ModelsSettings {
|
|||||||
vision?: ModelSlot;
|
vision?: ModelSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Custom OpenAI-compatible endpoint ───────────────────────────────────────
|
||||||
|
// ONE user-configured OpenAI-compatible server (vLLM / Ollama / a proxy). It is
|
||||||
|
// one more provider in the Keys list, keyed by a base URL instead of an env var.
|
||||||
|
// NON-SECRET config only (base URL + discovered model ids); the API key is a
|
||||||
|
// secret in the auth store, never here. Registered into pi-ai as a normal
|
||||||
|
// provider (server/custom-provider.ts).
|
||||||
|
|
||||||
|
/** Defaults for a custom model — self-hosted endpoints are free. */
|
||||||
|
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
|
||||||
|
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
|
||||||
|
|
||||||
|
/** Floor for a user-pinned token count (owner, 2026-10-27): the read side
|
||||||
|
* clamps up, the write side rejects below — the compaction-floor discipline.
|
||||||
|
* Pins exist because endpoints that publish no limits (LM Studio) otherwise
|
||||||
|
* sit on the 8192 default with no way to say better. */
|
||||||
|
export const CUSTOM_MODEL_PIN_FLOOR = 2_048;
|
||||||
|
|
||||||
|
/** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are
|
||||||
|
* detected from the endpoint's /models metadata; anything it omits falls back
|
||||||
|
* to a default. */
|
||||||
|
export interface CustomModel {
|
||||||
|
id: string;
|
||||||
|
/** Accepts image input (Vision-capable). Default false (text-only). */
|
||||||
|
image?: boolean;
|
||||||
|
/** Accepts a reasoning effort (thinking). Default false → the only
|
||||||
|
* thinking level is "off", exactly like a non-reasoning built-in. */
|
||||||
|
reasoning?: boolean;
|
||||||
|
/** Context window in tokens, from the endpoint. Absent → the default. */
|
||||||
|
context?: number;
|
||||||
|
/** Max output tokens, from the endpoint. Absent → the default. */
|
||||||
|
maxTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A user pin for one custom model: it beats what the endpoint publishes
|
||||||
|
* (owner ruling — explicit user intent wins; a local server's number is
|
||||||
|
* often wrong or absent). Absent field = no pin. `reasoning` is tri-state:
|
||||||
|
* true forces thinking on, false forces it off (a server that advertises
|
||||||
|
* reasoning but rejects reasoning_effort), absent = what discovery found. */
|
||||||
|
export interface CustomModelOverride {
|
||||||
|
context?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
reasoning?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single custom endpoint's stored config (delta section). */
|
||||||
|
export interface CustomProviderSettings {
|
||||||
|
/** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** Models this endpoint offers: advertised ids merged with the typed
|
||||||
|
* manual ones (rewritten on connect; a failed probe keeps the same
|
||||||
|
* endpoint's last snapshot). */
|
||||||
|
models?: CustomModel[];
|
||||||
|
/** User pins keyed by model id — pure user intent, never rewritten by
|
||||||
|
* discovery, pruned on connect to the ids that connect offers. */
|
||||||
|
overrides?: Record<string, CustomModelOverride>;
|
||||||
|
/** Model ids the user typed by hand, for an endpoint that publishes no
|
||||||
|
* usable /models list. Kept apart from the snapshot so a reconnect can
|
||||||
|
* re-merge them after discovery refreshes. */
|
||||||
|
manualModels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseModelIds(raw: unknown): string[] {
|
||||||
|
const items = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : [];
|
||||||
|
const ids = items.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean);
|
||||||
|
return [...new Set(ids)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical base-URL form (single source): trims, requires absolute http(s),
|
||||||
|
* drops trailing slashes. Returns "" for anything invalid ("not configured"). */
|
||||||
|
export function normalizeBaseUrl(raw: string): string {
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value) return "";
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
||||||
|
return url.toString().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** THE single source of "is the custom endpoint usable?": a non-empty base URL.
|
||||||
|
* Key presence is deliberately excluded (keyless local servers are allowed). */
|
||||||
|
export function customProviderUsable(
|
||||||
|
cp: CustomProviderSettings | undefined,
|
||||||
|
): cp is CustomProviderSettings & { baseUrl: string } {
|
||||||
|
return Boolean(cp?.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
/** First-run gate (model-slots wave, owner 2026-09-20): the app refuses
|
/** First-run gate (model-slots wave, owner 2026-09-20): the app refuses
|
||||||
* to start chats until the user has actively picked all three slots —
|
* to start chats until the user has actively picked all three slots —
|
||||||
* there ARE no defaults for 33 providers; a configured app is one whose
|
* there ARE no defaults for 33 providers; a configured app is one whose
|
||||||
@@ -375,6 +465,8 @@ export interface UserSettings {
|
|||||||
agent?: AgentSettings;
|
agent?: AgentSettings;
|
||||||
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
||||||
models?: ModelsSettings;
|
models?: ModelsSettings;
|
||||||
|
/** The custom OpenAI-compatible endpoint (non-secret config only). */
|
||||||
|
customProvider?: CustomProviderSettings;
|
||||||
appearance?: AppearanceSettings;
|
appearance?: AppearanceSettings;
|
||||||
graph?: GraphSettings;
|
graph?: GraphSettings;
|
||||||
compaction?: CompactionSettings;
|
compaction?: CompactionSettings;
|
||||||
@@ -501,6 +593,78 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A positive integer, or undefined — keeps only meaningful token counts. */
|
||||||
|
function positiveInt(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pin, clamped up to the floor (clamp-never-reject on read). */
|
||||||
|
function sanitizeCustomModelOverride(raw: unknown): CustomModelOverride | undefined {
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||||
|
const rec = raw as Record<string, unknown>;
|
||||||
|
const out: CustomModelOverride = {};
|
||||||
|
const context = positiveInt(rec.context);
|
||||||
|
if (context) out.context = Math.max(CUSTOM_MODEL_PIN_FLOOR, context);
|
||||||
|
const maxTokens = positiveInt(rec.maxTokens);
|
||||||
|
if (maxTokens) out.maxTokens = Math.max(CUSTOM_MODEL_PIN_FLOOR, maxTokens);
|
||||||
|
if (typeof rec.reasoning === "boolean") out.reasoning = rec.reasoning;
|
||||||
|
return Object.keys(out).length ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** THE pins map + THE prune law: well-formed entries only, ids trimmed, and
|
||||||
|
* when `liveIds` is given every entry for a model the endpoint no longer
|
||||||
|
* advertises drops. POST passes the discovered ids, PATCH the stored list. */
|
||||||
|
export function sanitizeCustomModelOverrides(
|
||||||
|
raw: unknown,
|
||||||
|
liveIds?: Iterable<string>,
|
||||||
|
): Record<string, CustomModelOverride> {
|
||||||
|
const out: Record<string, CustomModelOverride> = {};
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return out;
|
||||||
|
const live = liveIds ? new Set(liveIds) : undefined;
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
const id = key.trim();
|
||||||
|
if (!id || (live && !live.has(id))) continue;
|
||||||
|
const pin = sanitizeCustomModelOverride(value);
|
||||||
|
if (pin) out[id] = pin;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The customProvider section: normalized base URL (invalid → dropped) and
|
||||||
|
* discovered models (ids trimmed, dupes/empties dropped; image kept only when
|
||||||
|
* true; token counts only as positive ints). Clamp-never-reject. */
|
||||||
|
function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings {
|
||||||
|
const out: CustomProviderSettings = {};
|
||||||
|
if (typeof raw.baseUrl === "string") {
|
||||||
|
const base = normalizeBaseUrl(raw.baseUrl);
|
||||||
|
if (base) out.baseUrl = base;
|
||||||
|
}
|
||||||
|
if (Array.isArray(raw.models)) {
|
||||||
|
const byId = new Map<string, CustomModel>();
|
||||||
|
for (const m of raw.models) {
|
||||||
|
if (typeof m !== "object" || m === null) continue;
|
||||||
|
const rec = m as Record<string, unknown>;
|
||||||
|
if (typeof rec.id !== "string") continue;
|
||||||
|
const id = rec.id.trim();
|
||||||
|
if (!id) continue;
|
||||||
|
const model: CustomModel = { id };
|
||||||
|
if (rec.image === true) model.image = true;
|
||||||
|
if (rec.reasoning === true) model.reasoning = true;
|
||||||
|
const context = positiveInt(rec.context);
|
||||||
|
if (context) model.context = context;
|
||||||
|
const maxTokens = positiveInt(rec.maxTokens);
|
||||||
|
if (maxTokens) model.maxTokens = maxTokens;
|
||||||
|
byId.set(id, model);
|
||||||
|
}
|
||||||
|
out.models = [...byId.values()];
|
||||||
|
}
|
||||||
|
const overrides = sanitizeCustomModelOverrides(raw.overrides);
|
||||||
|
if (Object.keys(overrides).length) out.overrides = overrides;
|
||||||
|
const manualModels = parseModelIds(raw.manualModels);
|
||||||
|
if (manualModels.length) out.manualModels = manualModels;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeBooleanMap(raw: unknown): Record<string, boolean> | undefined {
|
function sanitizeBooleanMap(raw: unknown): Record<string, boolean> | undefined {
|
||||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||||
const out: Record<string, boolean> = {};
|
const out: Record<string, boolean> = {};
|
||||||
@@ -651,6 +815,9 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
|||||||
const models = sanitizeModels(r.models as Record<string, unknown>);
|
const models = sanitizeModels(r.models as Record<string, unknown>);
|
||||||
if (Object.keys(models).length) out.models = models;
|
if (Object.keys(models).length) out.models = models;
|
||||||
}
|
}
|
||||||
|
if (r.customProvider !== null && typeof r.customProvider === "object" && !Array.isArray(r.customProvider)) {
|
||||||
|
out.customProvider = sanitizeCustomProvider(r.customProvider as Record<string, unknown>);
|
||||||
|
}
|
||||||
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
|
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
|
||||||
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
|
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
|
||||||
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
|
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* The custom OpenAI-compatible endpoint: turns the user's non-secret config
|
||||||
|
* (base URL + discovered models) into a pi-ai provider registered in
|
||||||
|
* `piModels()`, so its models fill the same slots as any built-in. Keyless
|
||||||
|
* 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 { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
||||||
|
import { piModels } from "../agent/models.ts";
|
||||||
|
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||||
|
import {
|
||||||
|
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||||
|
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||||
|
customProviderUsable,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
type CustomModel,
|
||||||
|
type CustomModelOverride,
|
||||||
|
type CustomProviderSettings,
|
||||||
|
} from "../core/settings.ts";
|
||||||
|
import { keyValue } from "./keys.ts";
|
||||||
|
|
||||||
|
/** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */
|
||||||
|
const KEYLESS_PLACEHOLDER = "not-needed";
|
||||||
|
|
||||||
|
/** Narrow an unknown to a plain object (not null, not an array). */
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
/** Auth: the reserved key via the keys seam, else the keyless placeholder. */
|
||||||
|
const customEndpointAuth: ApiKeyAuth = {
|
||||||
|
name: `${CUSTOM_PROVIDER.label} API key`,
|
||||||
|
resolve: async () => {
|
||||||
|
const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim();
|
||||||
|
return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking
|
||||||
|
* levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27):
|
||||||
|
* the user's pin wins, then the endpoint's own metadata, then the compiled
|
||||||
|
* default — a local server that says nothing (or says 8k) can be corrected.
|
||||||
|
* baseUrl is stamped per-model. */
|
||||||
|
function toPiModel(
|
||||||
|
model: CustomModel,
|
||||||
|
baseUrl: string,
|
||||||
|
overrides: Record<string, CustomModelOverride>,
|
||||||
|
): Model<Api> {
|
||||||
|
const pin = overrides[model.id];
|
||||||
|
return {
|
||||||
|
id: model.id,
|
||||||
|
name: model.id,
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: CUSTOM_PROVIDER.id,
|
||||||
|
baseUrl,
|
||||||
|
// Pin wins; else detected from the endpoint's metadata (see
|
||||||
|
// detectReasoning). true makes pi-ai offer the standard levels and send
|
||||||
|
// reasoning_effort on the wire; no thinkingLevelMap → the default
|
||||||
|
// off/minimal/low/medium/high set.
|
||||||
|
reasoning: pin?.reasoning ?? Boolean(model.reasoning),
|
||||||
|
input: model.image ? ["text", "image"] : ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: pin?.context ?? model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||||
|
maxTokens: pin?.maxTokens ?? model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||||
|
type: "chat",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an OpenAI-compatible provider from parts. baseUrl must be normalized. */
|
||||||
|
export function buildOpenAiCompatibleProvider(input: {
|
||||||
|
baseUrl: string;
|
||||||
|
models: readonly CustomModel[];
|
||||||
|
/** User pins by model id (see CustomModelOverride) — absent = none. */
|
||||||
|
overrides?: Record<string, CustomModelOverride>;
|
||||||
|
}): Provider<Api> {
|
||||||
|
const overrides = input.overrides ?? {};
|
||||||
|
return createProvider<Api>({
|
||||||
|
id: CUSTOM_PROVIDER.id,
|
||||||
|
name: CUSTOM_PROVIDER.label,
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
auth: { apiKey: customEndpointAuth },
|
||||||
|
models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)),
|
||||||
|
api: openAICompletionsApi(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-sync the provider into piModels() (idempotent): usable → rebuild;
|
||||||
|
* not usable → remove. Never throws. */
|
||||||
|
export function syncCustomProvider(cp: CustomProviderSettings | undefined): void {
|
||||||
|
const models = piModels();
|
||||||
|
if (!customProviderUsable(cp)) {
|
||||||
|
models.deleteProvider(CUSTOM_PROVIDER.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
models.setProvider(
|
||||||
|
buildOpenAiCompatibleProvider({
|
||||||
|
baseUrl: normalizeBaseUrl(cp.baseUrl),
|
||||||
|
models: cp.models ?? [],
|
||||||
|
overrides: cp.overrides ?? {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status
|
||||||
|
* when it answered at all, so callers can tell a rejected key from a missing
|
||||||
|
* model list. */
|
||||||
|
export type DiscoverResult =
|
||||||
|
| { ok: true; models: CustomModel[] }
|
||||||
|
| { ok: false; error: string; status?: number };
|
||||||
|
|
||||||
|
/** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https
|
||||||
|
* only); keyless endpoints answer without a bearer. Non-2xx or unparseable
|
||||||
|
* body → honest error, never a silent empty list. */
|
||||||
|
export async function discoverModels(baseUrl: string, apiKey?: string): Promise<DiscoverResult> {
|
||||||
|
const base = normalizeBaseUrl(baseUrl);
|
||||||
|
if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" };
|
||||||
|
const key = apiKey?.trim() || keyValue(CUSTOM_PROVIDER.envVar)?.trim();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/models`, {
|
||||||
|
headers: key ? { authorization: `Bearer ${key}` } : {},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}`, status: res.status };
|
||||||
|
const body = (await res.json().catch(() => null)) as unknown;
|
||||||
|
const list = extractModels(body);
|
||||||
|
if (list === null) return { ok: false, error: "Endpoint response was not a model list" };
|
||||||
|
return { ok: true, models: list };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? (err.name === "AbortError" ? "Endpoint timed out" : err.message) : "Request failed";
|
||||||
|
return { ok: false, error: msg };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The connect's model list: what the endpoint advertises, plus every typed id
|
||||||
|
* it does not advertise (at default limits, so the pins can correct them).
|
||||||
|
* Discovered metadata always wins over a typed id of the same name. */
|
||||||
|
export function mergeManualModels(discovered: readonly CustomModel[], manualIds: readonly string[]): CustomModel[] {
|
||||||
|
const byId = new Map(discovered.map((m) => [m.id, m]));
|
||||||
|
for (const id of manualIds) if (!byId.has(id)) byId.set(id, { id });
|
||||||
|
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Model rows from a list response ({data:[…]} | {models:[…]} | bare array),
|
||||||
|
* or null if unrecognized. */
|
||||||
|
function modelRows(body: unknown): unknown[] | null {
|
||||||
|
if (Array.isArray(body)) return body;
|
||||||
|
if (isRecord(body)) {
|
||||||
|
if (Array.isArray(body.data)) return body.data;
|
||||||
|
if (Array.isArray(body.models)) return body.models;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a /models row advertise image input? Scans the common modality/
|
||||||
|
* capability fields; absent or unrecognized → false (text-only). */
|
||||||
|
function detectImage(row: Record<string, unknown>): boolean {
|
||||||
|
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
|
||||||
|
const candidates = [
|
||||||
|
architecture?.input_modalities,
|
||||||
|
row.input_modalities,
|
||||||
|
row.modalities,
|
||||||
|
row.supported_modalities,
|
||||||
|
row.capabilities,
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (Array.isArray(c) && c.some((v) => typeof v === "string" && /image|vision/i.test(v))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a /models row advertise reasoning? Metadata only — no name guessing,
|
||||||
|
* because a false positive sends reasoning_effort an endpoint will reject.
|
||||||
|
* Trusted signals: an explicit boolean (reasoning / supports_reasoning /
|
||||||
|
* thinking) or a reasoning token in the capability/parameter lists servers
|
||||||
|
* publish (vLLM + OpenRouter's supported_parameters, capabilities, tags).
|
||||||
|
* Absent or unrecognized → false (thinking stays "off"). */
|
||||||
|
function detectReasoning(row: Record<string, unknown>): boolean {
|
||||||
|
for (const key of ["reasoning", "supports_reasoning", "thinking"] as const) {
|
||||||
|
if (row[key] === true) return true;
|
||||||
|
}
|
||||||
|
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
|
||||||
|
const candidates = [row.supported_parameters, row.capabilities, row.tags, architecture?.output_modalities];
|
||||||
|
return candidates.some(
|
||||||
|
(list) =>
|
||||||
|
Array.isArray(list) &&
|
||||||
|
list.some((v) => typeof v === "string" && (/reasoning/i.test(v) || v.toLowerCase() === "thinking")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First positive integer among `keys` on a row (endpoints name limits
|
||||||
|
* inconsistently). */
|
||||||
|
function firstPositive(row: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key];
|
||||||
|
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field names seen in the wild for each limit (first present wins).
|
||||||
|
const CONTEXT_KEYS = ["max_input_tokens", "context_length", "context_window", "max_context", "n_ctx", "max_seq_len", "max_position_embeddings"] as const;
|
||||||
|
const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
|
||||||
|
|
||||||
|
/** Pull models from a list response (objects with `id` or bare strings), each
|
||||||
|
* with detected image + token limits. Null if the shape is unrecognized. */
|
||||||
|
function extractModels(body: unknown): CustomModel[] | null {
|
||||||
|
const rows = modelRows(body);
|
||||||
|
if (rows === null) return null;
|
||||||
|
const byId = new Map<string, CustomModel>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (typeof row === "string") {
|
||||||
|
if (row.trim()) byId.set(row, { id: row });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isRecord(row) && typeof row.id === "string" && row.id.trim()) {
|
||||||
|
const model: CustomModel = { id: row.id };
|
||||||
|
if (detectImage(row)) model.image = true;
|
||||||
|
if (detectReasoning(row)) model.reasoning = true;
|
||||||
|
const context = firstPositive(row, CONTEXT_KEYS);
|
||||||
|
if (context) model.context = context;
|
||||||
|
const maxTokens = firstPositive(row, MAX_TOKEN_KEYS);
|
||||||
|
if (maxTokens) model.maxTokens = maxTokens;
|
||||||
|
byId.set(row.id, model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
@@ -46,3 +46,6 @@ export * from "./model-catalog.ts";
|
|||||||
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
||||||
export { sessionCost } from "./pricing.ts";
|
export { sessionCost } from "./pricing.ts";
|
||||||
export { claudeCodeInstallationResponse } from "./claude-code.ts";
|
export { claudeCodeInstallationResponse } from "./claude-code.ts";
|
||||||
|
// The custom endpoint module (registers into piModels() + probes for models).
|
||||||
|
// No import side effects; the boot sync lives in stores.ts.
|
||||||
|
export * from "./custom-provider.ts";
|
||||||
|
|||||||
+8
-4
@@ -43,6 +43,7 @@ import {
|
|||||||
AUTH_KEYS,
|
AUTH_KEYS,
|
||||||
AUTH_PROVIDER_KEYS,
|
AUTH_PROVIDER_KEYS,
|
||||||
AUTH_PROVIDER_ENV_VARS,
|
AUTH_PROVIDER_ENV_VARS,
|
||||||
|
CUSTOM_PROVIDER,
|
||||||
type AuthKeyInfo,
|
type AuthKeyInfo,
|
||||||
type AuthKeyStatus,
|
type AuthKeyStatus,
|
||||||
genericProviderKeyGuard,
|
genericProviderKeyGuard,
|
||||||
@@ -62,10 +63,10 @@ import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
|
|||||||
// 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 or model-provider env
|
/** Is `id` a WRITABLE key name — curated catalog, model-provider env var, or
|
||||||
* var (provider-keys interlude: PUT/DELETE accept both). */
|
* the custom endpoint's reserved key (writable through the same seam). */
|
||||||
export function isWritableAuthKey(id: string): boolean {
|
export function isWritableAuthKey(id: string): boolean {
|
||||||
return isKnownAuthKey(id) || isProviderEnvVar(id);
|
return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The settings page's provider dropdown: every pi-ai provider that takes
|
/** The settings page's provider dropdown: every pi-ai provider that takes
|
||||||
@@ -209,7 +210,10 @@ 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 = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value);
|
const shape =
|
||||||
|
isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar
|
||||||
|
? genericProviderKeyGuard(id, value)
|
||||||
|
: validateKeyShape(id, value);
|
||||||
if (!shape.ok) return { ok: false, error: shape.error };
|
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);
|
||||||
|
|||||||
@@ -49,10 +49,17 @@ import type {
|
|||||||
SlotPins,
|
SlotPins,
|
||||||
} from "../core/contracts/models.ts";
|
} from "../core/contracts/models.ts";
|
||||||
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
||||||
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
|
import { AUTH_PROVIDER_KEYS, CUSTOM_PROVIDER } from "../core/auth-keys.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 { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
|
import {
|
||||||
|
BACKGROUND_MODEL_ROLES,
|
||||||
|
MODEL_SLOTS,
|
||||||
|
customProviderUsable,
|
||||||
|
type BackgroundModelRole,
|
||||||
|
type ModelSlotName,
|
||||||
|
type ModelsSettings,
|
||||||
|
} from "../core/settings.ts";
|
||||||
|
|
||||||
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
|
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
|
||||||
export type ModelDto = ModelCatalogEntry;
|
export type ModelDto = ModelCatalogEntry;
|
||||||
@@ -149,6 +156,11 @@ export function catalogProviders(): CatalogProvider[] {
|
|||||||
{ 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(),
|
...customProviderRows(),
|
||||||
|
// The custom endpoint: a row only when usable (base URL set). keyPresent
|
||||||
|
// reflects usability, not key presence (keyless local servers are allowed).
|
||||||
|
...(customProviderUsable(effectiveSettings().customProvider)
|
||||||
|
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }]
|
||||||
|
: []),
|
||||||
]).sort((a, b) => a.id.localeCompare(b.id));
|
]).sort((a, b) => a.id.localeCompare(b.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import type {
|
|||||||
AppearanceSettings,
|
AppearanceSettings,
|
||||||
BackgroundModelRole,
|
BackgroundModelRole,
|
||||||
CompactionSettings,
|
CompactionSettings,
|
||||||
|
CustomModelOverride,
|
||||||
GeneralSettings,
|
GeneralSettings,
|
||||||
DictationSettings,
|
DictationSettings,
|
||||||
GraphSettings,
|
GraphSettings,
|
||||||
ModelsSettings,
|
ModelsSettings,
|
||||||
RoleModelOverride,
|
RoleModelOverride,
|
||||||
} from "../core/settings.ts";
|
} from "../core/settings.ts";
|
||||||
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, CUSTOM_MODEL_PIN_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
||||||
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
|
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
|
||||||
import { isDictationLanguage } from "../core/dictation.ts";
|
import { isDictationLanguage } from "../core/dictation.ts";
|
||||||
|
|
||||||
@@ -395,8 +396,47 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<Di
|
|||||||
return { ok: true, settings: { language: raw.language } };
|
return { ok: true, settings: { language: raw.language } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The section→parser map for PUT /api/settings/[section] (the route also
|
/** The custom endpoint's per-model pins (PATCH /api/custom-provider): the
|
||||||
* carries the special prompts sub-resource). */
|
* whole desired map, replace-whole like every section. Strict write side — a
|
||||||
|
* token count must be a whole number at or above the floor (the read side
|
||||||
|
* clamps, this rejects — the compaction discipline). `reasoning` is the
|
||||||
|
* tri-state pin: true forces thinking on, false forces it off. */
|
||||||
|
export function parseCustomModelOverridesBody(
|
||||||
|
body: unknown,
|
||||||
|
): SettingsBodyResult<Record<string, CustomModelOverride>> {
|
||||||
|
const raw = asRecord(body);
|
||||||
|
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||||
|
const map = asRecord(raw.overrides);
|
||||||
|
if (!map) return { ok: false, error: "overrides must be a { modelId: { context?, maxTokens?, reasoning? } } map" };
|
||||||
|
const out: Record<string, CustomModelOverride> = {};
|
||||||
|
for (const [key, value] of Object.entries(map)) {
|
||||||
|
const id = key.trim();
|
||||||
|
if (!id) return { ok: false, error: "override keys must be non-empty model ids" };
|
||||||
|
const entry = asRecord(value);
|
||||||
|
if (!entry) return { ok: false, error: `override for ${id} must be an object` };
|
||||||
|
const pin: CustomModelOverride = {};
|
||||||
|
for (const field of ["context", "maxTokens"] as const) {
|
||||||
|
const v = entry[field];
|
||||||
|
if (v === undefined) continue;
|
||||||
|
if (typeof v !== "number" || !Number.isInteger(v) || v < CUSTOM_MODEL_PIN_FLOOR) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `${field} for ${id} must be a whole number of tokens at least ${CUSTOM_MODEL_PIN_FLOOR}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pin[field] = v;
|
||||||
|
}
|
||||||
|
if (entry.reasoning !== undefined) {
|
||||||
|
if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` };
|
||||||
|
pin.reasoning = entry.reasoning;
|
||||||
|
}
|
||||||
|
out[id] = pin;
|
||||||
|
}
|
||||||
|
return { ok: true, settings: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The section→parser map for PUT /api/settings/[section]. The custom endpoint
|
||||||
|
* is NOT a section — it connects via POST /api/custom-provider. */
|
||||||
export const SETTINGS_SECTIONS = {
|
export const SETTINGS_SECTIONS = {
|
||||||
general: parseGeneralSettingsBody,
|
general: parseGeneralSettingsBody,
|
||||||
chat: parseSettingsBody,
|
chat: parseSettingsBody,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { graphStore } from "../store/graph.ts";
|
|||||||
import { jobStore } from "../store/jobs.ts";
|
import { jobStore } from "../store/jobs.ts";
|
||||||
import { settingsStore } from "../store/settings.ts";
|
import { settingsStore } from "../store/settings.ts";
|
||||||
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
|
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
|
||||||
|
import { syncCustomProvider } from "./custom-provider.ts";
|
||||||
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
|
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
|
||||||
export const stores = {
|
export const stores = {
|
||||||
chatStore,
|
chatStore,
|
||||||
@@ -32,3 +33,7 @@ export type Stores = typeof stores;
|
|||||||
export function effectiveSettings(): EffectiveSettings {
|
export function effectiveSettings(): EffectiveSettings {
|
||||||
return resolveSettings(settingsStore.readSettings());
|
return resolveSettings(settingsStore.readSettings());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Boot: register the custom endpoint (when configured) into piModels(); the
|
||||||
|
// POST /api/custom-provider route re-syncs after every connect.
|
||||||
|
syncCustomProvider(effectiveSettings().customProvider);
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the custom OpenAI-compatible endpoint (custom-endpoint wave).
|
||||||
|
* Covers the pure core (normalizeBaseUrl, customProviderUsable, the sanitizer,
|
||||||
|
* resolution), the deep server module (provider build + live sync + model
|
||||||
|
* discovery incl. image-modality, reasoning detection and the per-model user
|
||||||
|
* pins), the pins' strict write-side parser, and the catalog federation row.
|
||||||
|
* No network: the discovery probe runs against a stubbed fetch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||||
|
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||||
|
CUSTOM_MODEL_PIN_FLOOR,
|
||||||
|
customProviderUsable,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
parseModelIds,
|
||||||
|
sanitizeCustomModelOverrides,
|
||||||
|
sanitizeUserSettings,
|
||||||
|
} from "../src/core/settings.ts";
|
||||||
|
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||||
|
import { parseCustomModelOverridesBody } from "../src/server/settings-body.ts";
|
||||||
|
import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts";
|
||||||
|
import {
|
||||||
|
buildOpenAiCompatibleProvider,
|
||||||
|
discoverModels,
|
||||||
|
mergeManualModels,
|
||||||
|
syncCustomProvider,
|
||||||
|
} from "../src/server/custom-provider.ts";
|
||||||
|
import { piModels } from "../src/agent/models.ts";
|
||||||
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
|
// ─── normalizeBaseUrl ────────────────────────────────────────────────────────
|
||||||
|
describe("normalizeBaseUrl", () => {
|
||||||
|
it("drops trailing slashes and keeps the path", () => {
|
||||||
|
assert.equal(normalizeBaseUrl("https://host:8000/v1/"), "https://host:8000/v1");
|
||||||
|
assert.equal(normalizeBaseUrl(" https://host/v1 "), "https://host/v1");
|
||||||
|
assert.equal(normalizeBaseUrl("http://localhost:11434"), "http://localhost:11434");
|
||||||
|
});
|
||||||
|
it("rejects non-http(s) schemes and junk with empty string", () => {
|
||||||
|
assert.equal(normalizeBaseUrl("file:///etc/passwd"), "");
|
||||||
|
assert.equal(normalizeBaseUrl("not a url"), "");
|
||||||
|
assert.equal(normalizeBaseUrl(""), "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── customProviderUsable ────────────────────────────────────────────────────
|
||||||
|
describe("customProviderUsable", () => {
|
||||||
|
it("is true exactly when a base URL is set (the key is optional)", () => {
|
||||||
|
assert.equal(customProviderUsable({ baseUrl: "https://h/v1" }), true);
|
||||||
|
assert.equal(customProviderUsable({ baseUrl: "" }), false);
|
||||||
|
assert.equal(customProviderUsable({}), false);
|
||||||
|
assert.equal(customProviderUsable(undefined), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── sanitizer ───────────────────────────────────────────────────────────────
|
||||||
|
describe("sanitizeUserSettings — customProvider", () => {
|
||||||
|
it("normalizes the base URL and cleans the model list (trim, dedupe, image flag)", () => {
|
||||||
|
const out = sanitizeUserSettings({
|
||||||
|
customProvider: {
|
||||||
|
baseUrl: "https://host:8000/v1/",
|
||||||
|
models: [
|
||||||
|
{ id: " llama-3 " }, // trims id; image absent
|
||||||
|
{ id: "gpt", image: true },
|
||||||
|
{ id: "gpt", image: false }, // dup id wins (last) → image dropped
|
||||||
|
{ id: "" }, // dropped: no usable id
|
||||||
|
"not-an-object", // dropped
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cp = out?.customProvider;
|
||||||
|
assert.equal(cp?.baseUrl, "https://host:8000/v1");
|
||||||
|
assert.deepEqual(cp?.models, [{ id: "llama-3" }, { id: "gpt" }]);
|
||||||
|
});
|
||||||
|
it("keeps image:true and drops a non-array models list / invalid base URL", () => {
|
||||||
|
const out = sanitizeUserSettings({ customProvider: { baseUrl: "ftp://x", models: "nope" } });
|
||||||
|
assert.equal(out?.customProvider?.baseUrl, undefined);
|
||||||
|
assert.equal(out?.customProvider?.models, undefined);
|
||||||
|
});
|
||||||
|
it("keeps reasoning:true and drops a non-true reasoning flag", () => {
|
||||||
|
const out = sanitizeUserSettings({
|
||||||
|
customProvider: {
|
||||||
|
baseUrl: "https://h/v1",
|
||||||
|
models: [{ id: "thinker", reasoning: true }, { id: "plain", reasoning: "yes" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(out?.customProvider?.models, [{ id: "thinker", reasoning: true }, { id: "plain" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── per-model pins: the read side + THE prune law ───────────────────────────
|
||||||
|
describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => {
|
||||||
|
it("keeps well-formed pins, clamps token counts up to the floor, keeps reasoning:false", () => {
|
||||||
|
const out = sanitizeUserSettings({
|
||||||
|
customProvider: {
|
||||||
|
baseUrl: "https://h/v1",
|
||||||
|
overrides: {
|
||||||
|
llama: { context: 131072, maxTokens: 8192, reasoning: true },
|
||||||
|
qwen: { context: 10 }, // under the floor → clamped up (clamp-never-reject on read)
|
||||||
|
quiet: { reasoning: false }, // a real pin, not an absent one
|
||||||
|
junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk
|
||||||
|
" ": { context: 4096 }, // no usable id
|
||||||
|
nope: "not-an-object",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(out?.customProvider?.overrides, {
|
||||||
|
llama: { context: 131072, maxTokens: 8192, reasoning: true },
|
||||||
|
qwen: { context: CUSTOM_MODEL_PIN_FLOOR },
|
||||||
|
quiet: { reasoning: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("prunes pins for models the endpoint no longer advertises — the single prune law", () => {
|
||||||
|
const pins = { a: { context: 32768 }, b: { context: 32768 } };
|
||||||
|
assert.deepEqual(Object.keys(sanitizeCustomModelOverrides(pins, ["a"])), ["a"]);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(sanitizeCustomModelOverrides(pins)),
|
||||||
|
["a", "b"],
|
||||||
|
"no liveIds → no prune (that is the routes' call, not the record sanitizer's)",
|
||||||
|
);
|
||||||
|
assert.deepEqual(sanitizeCustomModelOverrides("nope"), {}, "a non-object map resolves empty");
|
||||||
|
});
|
||||||
|
it("drops an invalid overrides section from the record entirely", () => {
|
||||||
|
const out = sanitizeUserSettings({ customProvider: { baseUrl: "https://h/v1", overrides: "nope" } });
|
||||||
|
assert.equal(out?.customProvider?.overrides, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── pins: the strict write side (PATCH) ──────────────────────────────────
|
||||||
|
describe("parseCustomModelOverridesBody — the strict write side", () => {
|
||||||
|
it("accepts whole counts at or above the floor and the tri-state reasoning pin", () => {
|
||||||
|
const r = parseCustomModelOverridesBody({
|
||||||
|
overrides: {
|
||||||
|
a: { context: CUSTOM_MODEL_PIN_FLOOR, reasoning: false },
|
||||||
|
b: { maxTokens: 8192 },
|
||||||
|
c: {}, // an empty pin is legal — the sanitizer drops it on write
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(r.ok);
|
||||||
|
assert.deepEqual(r.settings, {
|
||||||
|
a: { context: CUSTOM_MODEL_PIN_FLOOR, reasoning: false },
|
||||||
|
b: { maxTokens: 8192 },
|
||||||
|
c: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("rejects a count under the floor, naming the floor", () => {
|
||||||
|
const r = parseCustomModelOverridesBody({ overrides: { a: { context: CUSTOM_MODEL_PIN_FLOOR - 1 } } });
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
if (!r.ok) assert.ok(r.error.includes(String(CUSTOM_MODEL_PIN_FLOOR)), r.error);
|
||||||
|
});
|
||||||
|
it("rejects a fractional count, a non-boolean reasoning, and a missing overrides map", () => {
|
||||||
|
assert.equal(parseCustomModelOverridesBody({ overrides: { a: { context: 4096.5 } } }).ok, false);
|
||||||
|
assert.equal(parseCustomModelOverridesBody({ overrides: { a: { reasoning: "on" } } }).ok, false);
|
||||||
|
assert.equal(parseCustomModelOverridesBody({ overrides: { a: "nope" } }).ok, false);
|
||||||
|
assert.equal(parseCustomModelOverridesBody({}).ok, false);
|
||||||
|
assert.equal(parseCustomModelOverridesBody(null).ok, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolution ──────────────────────────────────────────────────────────
|
||||||
|
describe("resolveSettings — customProvider", () => {
|
||||||
|
it("resolves an absent section to concrete defaults", () => {
|
||||||
|
assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {}, manualModels: [] });
|
||||||
|
});
|
||||||
|
it("carries stored pins through to the concrete view", () => {
|
||||||
|
const resolved = resolveSettings({
|
||||||
|
version: 1,
|
||||||
|
customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], overrides: { a: { context: 32768 } } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolved.customProvider.overrides, { a: { context: 32768 } });
|
||||||
|
});
|
||||||
|
it("carries stored manual model ids through to the concrete view", () => {
|
||||||
|
const resolved = resolveSettings({
|
||||||
|
version: 1,
|
||||||
|
customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], manualModels: ["a"] },
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolved.customProvider.manualModels, ["a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("manual model ids", () => {
|
||||||
|
it("parseModelIds splits a typed list on commas and whitespace, trims and dedupes", () => {
|
||||||
|
assert.deepEqual(parseModelIds(" doubao-seed-2.1-pro, kimi-k3.1-pro\nqwen/qwen3-max ,, kimi-k3.1-pro "), [
|
||||||
|
"doubao-seed-2.1-pro",
|
||||||
|
"kimi-k3.1-pro",
|
||||||
|
"qwen/qwen3-max",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("parseModelIds accepts a string array and ignores non-strings and junk", () => {
|
||||||
|
assert.deepEqual(parseModelIds(["a", " b ", "", 3, null, "a"]), ["a", "b"]);
|
||||||
|
assert.deepEqual(parseModelIds(undefined), []);
|
||||||
|
assert.deepEqual(parseModelIds({ id: "a" }), []);
|
||||||
|
});
|
||||||
|
it("the sanitizer keeps a clean manual list and drops an empty one", () => {
|
||||||
|
const kept = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [" a ", "a", "b"] } });
|
||||||
|
assert.deepEqual(kept?.customProvider?.manualModels, ["a", "b"]);
|
||||||
|
const dropped = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [] } });
|
||||||
|
assert.equal(dropped?.customProvider?.manualModels, undefined);
|
||||||
|
});
|
||||||
|
it("mergeManualModels adds typed ids with defaults and never replaces discovered metadata", () => {
|
||||||
|
const merged = mergeManualModels([{ id: "b", image: true, context: 65536 }], ["c", "b", "a"]);
|
||||||
|
assert.deepEqual(merged, [{ id: "a" }, { id: "b", image: true, context: 65536 }, { id: "c" }]);
|
||||||
|
});
|
||||||
|
it("mergeManualModels alone builds the list when discovery found nothing", () => {
|
||||||
|
assert.deepEqual(mergeManualModels([], ["x"]), [{ id: "x" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── provider build + live sync ──────────────────────────────────────────────
|
||||||
|
describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => {
|
||||||
|
after(() => piModels().deleteProvider(CUSTOM_PROVIDER.id));
|
||||||
|
|
||||||
|
it("maps ids to defaults and marks image-capable models image-input", () => {
|
||||||
|
const provider = buildOpenAiCompatibleProvider({
|
||||||
|
baseUrl: "http://h/v1",
|
||||||
|
models: [{ id: "m1" }, { id: "m2", image: true }],
|
||||||
|
});
|
||||||
|
assert.equal(provider.id, CUSTOM_PROVIDER.id);
|
||||||
|
const [m1, m2] = provider.getModels();
|
||||||
|
assert.equal(m1!.name, "m1");
|
||||||
|
assert.equal(m1!.api, "openai-completions");
|
||||||
|
assert.equal(m1!.provider, CUSTOM_PROVIDER.id);
|
||||||
|
assert.equal(m1!.baseUrl, "http://h/v1");
|
||||||
|
assert.equal(m1!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW);
|
||||||
|
assert.equal(m1!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS);
|
||||||
|
assert.deepEqual(m1!.input, ["text"]); // text-only by default
|
||||||
|
assert.deepEqual(m2!.input, ["text", "image"]); // image-capable → Vision-eligible
|
||||||
|
assert.equal(m1!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW); // no endpoint value → default
|
||||||
|
assert.equal(m1!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries endpoint token limits when present", () => {
|
||||||
|
const provider = buildOpenAiCompatibleProvider({
|
||||||
|
baseUrl: "http://h/v1",
|
||||||
|
models: [{ id: "big", context: 196608, maxTokens: 393216 }],
|
||||||
|
});
|
||||||
|
const [big] = provider.getModels();
|
||||||
|
assert.equal(big!.contextWindow, 196608); // 192k from the endpoint
|
||||||
|
assert.equal(big!.maxTokens, 393216);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a user pin beats the endpoint, which beats the compiled default", () => {
|
||||||
|
const provider = buildOpenAiCompatibleProvider({
|
||||||
|
baseUrl: "http://h/v1",
|
||||||
|
models: [{ id: "pinned", context: 32768, maxTokens: 4096 }, { id: "detected", context: 65536 }, { id: "bare" }],
|
||||||
|
overrides: { pinned: { context: 131072, maxTokens: 16384 } },
|
||||||
|
});
|
||||||
|
const [pinned, detected, bare] = provider.getModels();
|
||||||
|
assert.equal(pinned!.contextWindow, 131072, "the pin wins over the endpoint's own number");
|
||||||
|
assert.equal(pinned!.maxTokens, 16384);
|
||||||
|
assert.equal(detected!.contextWindow, 65536, "no pin → the endpoint's value");
|
||||||
|
assert.equal(detected!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, "endpoint silent → the compiled default");
|
||||||
|
assert.equal(bare!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the thinking pin is tri-state: false beats a detected-true endpoint, true beats silence", () => {
|
||||||
|
const provider = buildOpenAiCompatibleProvider({
|
||||||
|
baseUrl: "http://h/v1",
|
||||||
|
models: [{ id: "liar", reasoning: true }, { id: "silent" }],
|
||||||
|
overrides: { liar: { reasoning: false }, silent: { reasoning: true } },
|
||||||
|
});
|
||||||
|
const [liar, silent] = provider.getModels();
|
||||||
|
assert.equal(liar!.reasoning, false, "a server that advertises reasoning but rejects it can be pinned off");
|
||||||
|
assert.deepEqual(getSupportedThinkingLevels(liar!), ["off"]);
|
||||||
|
assert.equal(silent!.reasoning, true, "LM Studio publishes nothing; the user says otherwise");
|
||||||
|
assert.deepEqual(getSupportedThinkingLevels(silent!), ["off", "minimal", "low", "medium", "high"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a reasoning-capable model the standard thinking levels (others stay \"off\")", () => {
|
||||||
|
const provider = buildOpenAiCompatibleProvider({
|
||||||
|
baseUrl: "http://h/v1",
|
||||||
|
models: [{ id: "thinker", reasoning: true }, { id: "plain" }],
|
||||||
|
});
|
||||||
|
const [thinker, plain] = provider.getModels();
|
||||||
|
assert.equal(thinker!.reasoning, true);
|
||||||
|
// No thinkingLevelMap → pi-ai's default set, same as any reasoning model.
|
||||||
|
assert.deepEqual(getSupportedThinkingLevels(thinker!), ["off", "minimal", "low", "medium", "high"]);
|
||||||
|
assert.equal(plain!.reasoning, false);
|
||||||
|
assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers on a usable sync and removes when the base URL clears", () => {
|
||||||
|
syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] });
|
||||||
|
assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered");
|
||||||
|
assert.ok(piModels().getModel(CUSTOM_PROVIDER.id, "live"), "model resolvable");
|
||||||
|
syncCustomProvider({ baseUrl: "", models: [{ id: "live" }] });
|
||||||
|
assert.equal(piModels().getProvider(CUSTOM_PROVIDER.id), undefined, "provider removed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── discovery (stubbed fetch) ───────────────────────────────────────────────
|
||||||
|
describe("discoverModels", () => {
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
after(() => {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubFetch(handler: (url: string) => { status: number; body: unknown }) {
|
||||||
|
globalThis.fetch = (async (input: unknown) => {
|
||||||
|
const res = handler(String(input));
|
||||||
|
return new Response(JSON.stringify(res.body), { status: res.status });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reads OpenAI {data:[{id}]} and Ollama {models:[{id}]} shapes", async () => {
|
||||||
|
stubFetch(() => ({ status: 200, body: { data: [{ id: "b" }, { id: "a" }] } }));
|
||||||
|
const openai = await discoverModels("http://h/v1");
|
||||||
|
assert.ok(openai.ok);
|
||||||
|
assert.deepEqual(openai.models, [{ id: "a" }, { id: "b" }]);
|
||||||
|
stubFetch(() => ({ status: 200, body: { models: [{ id: "x" }] } }));
|
||||||
|
const r = await discoverModels("http://h/v1");
|
||||||
|
assert.ok(r.ok && r.models.length === 1 && r.models[0]!.id === "x");
|
||||||
|
});
|
||||||
|
it("detects image capability from common modality fields", async () => {
|
||||||
|
stubFetch(() => ({
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: [
|
||||||
|
{ id: "vision-model", architecture: { input_modalities: ["text", "image"] } },
|
||||||
|
{ id: "cap-model", capabilities: ["completion", "vision"] },
|
||||||
|
{ id: "text-model", architecture: { input_modalities: ["text"] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const r = await discoverModels("http://h/v1");
|
||||||
|
assert.ok(r.ok);
|
||||||
|
const byId = Object.fromEntries(r.models.map((m) => [m.id, Boolean(m.image)]));
|
||||||
|
assert.equal(byId["vision-model"], true);
|
||||||
|
assert.equal(byId["cap-model"], true);
|
||||||
|
assert.equal(byId["text-model"], false);
|
||||||
|
});
|
||||||
|
it("detects token limits from common metadata fields", async () => {
|
||||||
|
stubFetch(() => ({
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: [
|
||||||
|
{ id: "big", max_input_tokens: 196608, max_output_tokens: 393216 },
|
||||||
|
{ id: "ctx", context_length: 32768 },
|
||||||
|
{ id: "plain" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const r = await discoverModels("http://h/v1");
|
||||||
|
assert.ok(r.ok);
|
||||||
|
const byId = Object.fromEntries(r.models.map((m) => [m.id, m]));
|
||||||
|
assert.equal(byId["big"]!.context, 196608);
|
||||||
|
assert.equal(byId["big"]!.maxTokens, 393216);
|
||||||
|
assert.equal(byId["ctx"]!.context, 32768);
|
||||||
|
assert.equal(byId["ctx"]!.maxTokens, undefined);
|
||||||
|
assert.equal(byId["plain"]!.context, undefined);
|
||||||
|
});
|
||||||
|
it("detects reasoning from endpoint metadata only — never from the model name", async () => {
|
||||||
|
stubFetch(() => ({
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: [
|
||||||
|
{ id: "vllm-reasoner", supported_parameters: ["temperature", "reasoning", "reasoning_effort"] },
|
||||||
|
{ id: "bool-reasoner", reasoning: true },
|
||||||
|
{ id: "thinking-flag", thinking: true },
|
||||||
|
{ id: "cap-reasoner", capabilities: ["completion", "reasoning"] },
|
||||||
|
{ id: "deepseek-r1-distill" }, // a reasoning-sounding NAME, no metadata → stays off
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const r = await discoverModels("http://h/v1");
|
||||||
|
assert.ok(r.ok);
|
||||||
|
const byId = Object.fromEntries(r.models.map((m) => [m.id, Boolean(m.reasoning)]));
|
||||||
|
assert.equal(byId["vllm-reasoner"], true);
|
||||||
|
assert.equal(byId["bool-reasoner"], true);
|
||||||
|
assert.equal(byId["thinking-flag"], true);
|
||||||
|
assert.equal(byId["cap-reasoner"], true);
|
||||||
|
assert.equal(byId["deepseek-r1-distill"], false, "name guessing would risk sending reasoning_effort to a server that rejects it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards the scheme before any fetch", async () => {
|
||||||
|
let called = false;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
called = true;
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
const r = await discoverModels("file:///etc/passwd");
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.equal(called, false, "no fetch for a non-http scheme");
|
||||||
|
});
|
||||||
|
it("reports a non-2xx and an unparseable shape as honest errors", async () => {
|
||||||
|
stubFetch(() => ({ status: 500, body: {} }));
|
||||||
|
assert.equal((await discoverModels("http://h/v1")).ok, false);
|
||||||
|
stubFetch(() => ({ status: 200, body: { unexpected: true } }));
|
||||||
|
assert.equal((await discoverModels("http://h/v1")).ok, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/custom-provider — the connect policy around manually typed model
|
||||||
|
* ids. Two rules earn a route test (the pure pieces live in
|
||||||
|
* custom-endpoint.test.ts):
|
||||||
|
* 1. Typed ids make a FAILED probe survivable, but never a rejected key.
|
||||||
|
* 2. A failed probe never shrinks the snapshot it already stored: that prune
|
||||||
|
* would take the model metadata and the user's pins with it.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { after, before, describe, it } from "node:test";
|
||||||
|
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-route-"));
|
||||||
|
process.env.LH_DATA_DIR = dir;
|
||||||
|
process.env.LH_AUTH_FILE = path.join(dir, "auth.json");
|
||||||
|
const SETTINGS_FILE = path.join(dir, "settings.json");
|
||||||
|
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
after(() => {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
delete process.env.LH_DATA_DIR;
|
||||||
|
delete process.env.LH_AUTH_FILE;
|
||||||
|
});
|
||||||
|
|
||||||
|
type Route = typeof import("../app/api/custom-provider/route.ts");
|
||||||
|
let route: Route;
|
||||||
|
before(async () => {
|
||||||
|
route = await import("../app/api/custom-provider/route.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
const BASE = "http://h/v1";
|
||||||
|
/** A connected endpoint with two discovered models, a pin on each, one typed id. */
|
||||||
|
const CONNECTED = {
|
||||||
|
version: 1,
|
||||||
|
customProvider: {
|
||||||
|
baseUrl: BASE,
|
||||||
|
models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }],
|
||||||
|
overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } },
|
||||||
|
manualModels: ["m1"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function seed(record: unknown): void {
|
||||||
|
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(record, null, "\t"), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stored(): { customProvider?: { models?: { id: string }[]; overrides?: unknown; manualModels?: string[] } } {
|
||||||
|
return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function answerWith(status: number, statusText: string, body?: unknown): void {
|
||||||
|
globalThis.fetch = (async () => (body === undefined ? new Response("nope", { status, statusText }) : Response.json(body))) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(payload: unknown): Promise<Response> {
|
||||||
|
return route.POST(
|
||||||
|
new Request("http://localhost/api/custom-provider", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/custom-provider — typed ids and a failed probe", () => {
|
||||||
|
it("a transient failure keeps the stored models, their metadata, and their pins", async () => {
|
||||||
|
seed(CONNECTED);
|
||||||
|
answerWith(503, "Service Unavailable");
|
||||||
|
|
||||||
|
const res = await post({ baseUrl: BASE, manualModels: "m1" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
|
||||||
|
const cp = stored().customProvider;
|
||||||
|
assert.deepEqual(cp?.models?.map((m) => m.id), ["disc-1", "disc-2", "m1"]);
|
||||||
|
assert.deepEqual(cp?.models?.[0], { id: "disc-1", context: 131072 }, "detected metadata survives");
|
||||||
|
assert.deepEqual(cp?.overrides, CONNECTED.customProvider.overrides, "the prune must not fire on a failed probe");
|
||||||
|
assert.deepEqual(cp?.manualModels, ["m1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rejected key fails the connect even when ids are typed", async () => {
|
||||||
|
seed(CONNECTED);
|
||||||
|
answerWith(401, "Unauthorized");
|
||||||
|
const before = stored();
|
||||||
|
|
||||||
|
const res = await post({ baseUrl: BASE, key: "sk-abcdefghijk", manualModels: "m1" });
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.deepEqual(stored(), before, "nothing is stored on an auth failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a new endpoint with no model list starts from the typed ids alone", async () => {
|
||||||
|
seed(CONNECTED);
|
||||||
|
answerWith(404, "Not Found");
|
||||||
|
|
||||||
|
const res = await post({ baseUrl: "http://other/v1", manualModels: "y, x" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
|
||||||
|
const cp = stored().customProvider;
|
||||||
|
assert.deepEqual(cp?.models?.map((m) => m.id), ["x", "y"], "the previous endpoint's models do not follow the URL");
|
||||||
|
assert.equal(cp?.overrides, undefined, "no pin survives a list the old pins do not belong to");
|
||||||
|
assert.deepEqual(cp?.manualModels, ["y", "x"], "the typed order is kept as typed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no model list and no typed ids still refuses, with the hint", async () => {
|
||||||
|
answerWith(404, "Not Found");
|
||||||
|
|
||||||
|
const res = await post({ baseUrl: BASE });
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
const body = (await res.json()) as { error: string };
|
||||||
|
assert.match(body.error, /404/);
|
||||||
|
assert.match(body.error, /add model IDs manually/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a working probe merges the typed ids and keeps discovered metadata", async () => {
|
||||||
|
seed(CONNECTED);
|
||||||
|
answerWith(200, "OK", { data: [{ id: "disc-1", context_length: 65536 }] });
|
||||||
|
|
||||||
|
const res = await post({ baseUrl: BASE, manualModels: "disc-1, m2" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
|
||||||
|
const cp = stored().customProvider;
|
||||||
|
assert.deepEqual(cp?.models?.map((m) => m.id), ["disc-1", "m2"]);
|
||||||
|
assert.deepEqual(cp?.models?.[0], { id: "disc-1", context: 65536 }, "discovery is not replaced by a bare typed id");
|
||||||
|
assert.deepEqual(cp?.overrides, { "disc-1": { context: 200000 } }, "pins follow the ids this connect offers");
|
||||||
|
assert.deepEqual(cp?.manualModels, ["disc-1", "m2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -236,7 +236,9 @@ describe("parseCompactionSettingsBody (owner round 14)", () => {
|
|||||||
describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
||||||
it("maps exactly the eight record sections", () => {
|
it("maps exactly the eight record sections", () => {
|
||||||
// compaction joined (owner round 14): its own section PUT — the
|
// compaction joined (owner round 14): its own section PUT — the
|
||||||
// threshold is engine behavior, not an agent-section key.
|
// threshold is engine behavior, not an agent-section key. The custom
|
||||||
|
// endpoint is NOT a section: it is one more provider in the Keys list,
|
||||||
|
// connected through POST /api/custom-provider.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(SETTINGS_SECTIONS),
|
Object.keys(SETTINGS_SECTIONS),
|
||||||
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"],
|
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"],
|
||||||
|
|||||||
@@ -172,10 +172,15 @@ describe("setup polish interactions", () => {
|
|||||||
assert.equal(closed, 0);
|
assert.equal(closed, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the empty vision list gives the approved next action without an em dash", async () => {
|
it("the vision slot filters out built-in text-only models (image-capable or custom only)", async () => {
|
||||||
await mount({ keyed: true });
|
await mount({ keyed: true });
|
||||||
await click(element('[data-testid="setup-trigger-vision"]'));
|
await click(element('[data-testid="setup-trigger-vision"]'));
|
||||||
assert.ok(element('[data-testid="setup-menu-vision"]').textContent?.includes("No image-capable models. Add a provider key with access to a vision model."));
|
// The text-only openrouter fixture is neither image-capable nor a custom-
|
||||||
|
// endpoint model, so the vision slot filters it out (built-ins must report
|
||||||
|
// image input; only custom models stay selectable when capability is unknown).
|
||||||
|
const menu = element('[data-testid="setup-menu-vision"]');
|
||||||
|
assert.ok(!menu.textContent?.includes("fixture-text"));
|
||||||
|
assert.ok(menu.textContent?.includes("No models match."));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("catalog failure uses the approved two-sentence error", async () => {
|
it("catalog failure uses the approved two-sentence error", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user