refactor: trim custom-endpoint comments and share synthetic provider const
Review pass: shorten verbose doc comments across the custom-endpoint code, merge duplicate imports, share one synthetic provider entry between the keys section and setup dialog, collapse the duplicated connect-editor block, revert an unrelated settings-route drive-by.
This commit is contained in:
@@ -1,19 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* The custom OpenAI-compatible endpoint's connect surface (custom-endpoint
|
* The custom endpoint's connect surface (one provider in the Keys list).
|
||||||
* wave). It is ONE provider in the Keys list, connected exactly like the
|
* GET → non-secret config { baseUrl, models } + keyPresent (masked).
|
||||||
* built-ins are connected by a key — the only extra input is a base URL.
|
* POST → connect: validate + normalize the base URL, probe /models (fail
|
||||||
*
|
* fast), store the key, persist { baseUrl, models }, re-sync the
|
||||||
* GET → the resolved non-secret config { baseUrl, models } + whether a key
|
* provider into piModels(). Discovery lives here because connecting
|
||||||
* is present (masked status only; the key is a secret in the auth
|
* and discovering are one user action.
|
||||||
* store and never enters settings.json).
|
|
||||||
* POST → connect. Body { baseUrl, key? }: validate + normalize the base URL,
|
|
||||||
* PROBE the endpoint's /models (fail fast — a broken URL never
|
|
||||||
* persists), then store the key (empty string clears it, absent
|
|
||||||
* leaves it), persist { baseUrl, models } to the settings record, and
|
|
||||||
* re-register the provider into piModels() with no restart.
|
|
||||||
*
|
|
||||||
* Discovery lives here (not a separate route) because connecting and discovering
|
|
||||||
* are one user action: paste URL + key, get a working provider.
|
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
deleteKey,
|
deleteKey,
|
||||||
@@ -43,13 +34,11 @@ export async function POST(request: Request) {
|
|||||||
const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : "");
|
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 });
|
if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 });
|
||||||
|
|
||||||
// A provided key is trimmed; an empty string means "clear it", an absent
|
// Empty key string clears it, absent leaves it stored; only a real key rides the probe.
|
||||||
// field means "leave whatever is stored". Only a real key rides the probe.
|
|
||||||
const keyProvided = typeof body?.key === "string";
|
const keyProvided = typeof body?.key === "string";
|
||||||
const key = keyProvided ? (body!.key as string).trim() : undefined;
|
const key = keyProvided ? (body!.key as string).trim() : undefined;
|
||||||
|
|
||||||
// Probe first: a URL that doesn't answer with a model list never persists,
|
// Probe first: a URL that doesn't answer with a model list never persists.
|
||||||
// so a typo can't leave a half-configured provider behind.
|
|
||||||
const found = await discoverModels(base, key ? key : undefined);
|
const found = await discoverModels(base, key ? key : undefined);
|
||||||
if (!found.ok) return Response.json({ error: found.error }, { status: 502 });
|
if (!found.ok) return Response.json({ error: found.error }, { status: 502 });
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
|
|||||||
// Read-modify-write ONE section of the record; everything else rides
|
// Read-modify-write ONE section of the record; everything else rides
|
||||||
// along untouched (other sections, unknown keys — sanitize preserves).
|
// along untouched (other sections, unknown keys — sanitize preserves).
|
||||||
const next = { ...record, [section]: sectionOut };
|
const next = { ...record, [section]: sectionOut };
|
||||||
const saved = writeSettingsRecord(next);
|
writeSettingsRecord(next);
|
||||||
return Response.json({ settings: saved });
|
return Response.json({ settings: next });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,10 +58,8 @@ interface CustomStatus {
|
|||||||
* 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 as it appears in the Add-provider dropdown — a synthetic
|
/** The custom endpoint's synthetic Add-provider entry (offered only while unconfigured). */
|
||||||
* entry (it is not one of pi-ai's 33), offered only while unconfigured; once
|
export const CUSTOM_PROVIDER_INFO: ProviderKeyInfo = {
|
||||||
* connected it graduates to its own row like any stored provider. */
|
|
||||||
const CUSTOM_PROVIDER_INFO: ProviderKeyInfo = {
|
|
||||||
id: CUSTOM_PROVIDER.id,
|
id: CUSTOM_PROVIDER.id,
|
||||||
envVar: CUSTOM_PROVIDER.envVar,
|
envVar: CUSTOM_PROVIDER.envVar,
|
||||||
label: CUSTOM_PROVIDER.label,
|
label: CUSTOM_PROVIDER.label,
|
||||||
@@ -489,10 +487,10 @@ export function KeysSection({
|
|||||||
onToggle={() => setAddingCustom((v) => !v)}
|
onToggle={() => setAddingCustom((v) => !v)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{custom?.baseUrl && addingCustom && (
|
{addingCustom && (
|
||||||
<CustomEndpointEditor
|
<CustomEndpointEditor
|
||||||
baseUrl0={custom.baseUrl}
|
baseUrl0={custom?.baseUrl ?? ""}
|
||||||
keyPresent={custom.keyPresent}
|
keyPresent={custom?.keyPresent ?? false}
|
||||||
onSaved={async () => {
|
onSaved={async () => {
|
||||||
setAddingCustom(false);
|
setAddingCustom(false);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -516,18 +514,6 @@ export function KeysSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!custom?.baseUrl && addingCustom && (
|
|
||||||
<CustomEndpointEditor
|
|
||||||
baseUrl0=""
|
|
||||||
keyPresent={false}
|
|
||||||
onSaved={async () => {
|
|
||||||
setAddingCustom(false);
|
|
||||||
await reload();
|
|
||||||
notifySettingsSaved("keys");
|
|
||||||
}}
|
|
||||||
onCancel={() => setAddingCustom(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{adding && (
|
{adding && (
|
||||||
<div className="flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid={`key-${adding.id}-editor`}>
|
<div className="flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid={`key-${adding.id}-editor`}>
|
||||||
<span className="shrink-0 font-ui text-xs text-foreground">{adding.label}</span>
|
<span className="shrink-0 font-ui text-xs text-foreground">{adding.label}</span>
|
||||||
@@ -637,9 +623,7 @@ function AddProviderEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The custom endpoint's status row in the provider list — one more provider,
|
/** The custom endpoint's status row (shown once configured); clicking re-opens the editor. */
|
||||||
* shown only once it's configured (exactly like a built-in appears only once
|
|
||||||
* its key is stored). Clicking re-opens the connect editor. */
|
|
||||||
function CustomEndpointRow({
|
function CustomEndpointRow({
|
||||||
status,
|
status,
|
||||||
expanded,
|
expanded,
|
||||||
@@ -669,12 +653,8 @@ function CustomEndpointRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The custom endpoint's connect editor — base URL + optional key, exactly the
|
/** The custom endpoint's connect editor: base URL + key → POST /api/custom-provider
|
||||||
* built-ins' key-paste flow plus the one extra URL field. Connect POSTs to
|
* (validate + discover + register). Image capability is detected automatically. */
|
||||||
* /api/custom-provider (validate + discover + register); on success the parent
|
|
||||||
* reloads so the row appears and the catalog widens with no restart. Image
|
|
||||||
* capability is detected automatically from the endpoint — there is nothing to
|
|
||||||
* tick here. Keyless local servers (Ollama / LM Studio) connect with no key. */
|
|
||||||
function CustomEndpointEditor({
|
function CustomEndpointEditor({
|
||||||
baseUrl0,
|
baseUrl0,
|
||||||
keyPresent,
|
keyPresent,
|
||||||
@@ -691,9 +671,8 @@ function CustomEndpointEditor({
|
|||||||
const [keyTouched, setKeyTouched] = useState(false);
|
const [keyTouched, setKeyTouched] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// A key is required to enable chatting (the send gate needs a provider key);
|
// A key is required to enable chatting; local servers accept any value. A
|
||||||
// local servers accept any value. When one is already stored, leaving the
|
// stored key is kept when the field is left blank.
|
||||||
// field blank keeps it.
|
|
||||||
const needsKey = !keyPresent;
|
const needsKey = !keyPresent;
|
||||||
const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim()));
|
const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim()));
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { CUSTOM_PROVIDER, 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):
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -65,11 +65,6 @@ const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
|
|||||||
};
|
};
|
||||||
const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
|
const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
|
||||||
|
|
||||||
/** The custom endpoint's synthetic picker entry — one more provider in the
|
|
||||||
* Stage 1 roster, connected by a base URL + key (POST
|
|
||||||
* /api/custom-provider). Not part of the /api/keys plain-key roster. */
|
|
||||||
const CUSTOM_PICKER: ProviderKeyInfo = { id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, label: CUSTOM_PROVIDER.label };
|
|
||||||
|
|
||||||
/** The GET /api/models response fields the flow consumes. */
|
/** The GET /api/models response fields the flow consumes. */
|
||||||
interface ModelsBody {
|
interface ModelsBody {
|
||||||
models: ModelCatalogEntry[];
|
models: ModelCatalogEntry[];
|
||||||
@@ -159,9 +154,8 @@ 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>;
|
||||||
/** The chosen provider is the custom endpoint: a base URL field joins the
|
/** Chosen provider is the custom endpoint: a base URL field joins the key
|
||||||
* key field (a key is required — local servers accept any value). All three
|
* field. All three optional so plain-key callers (and tests) stay unchanged. */
|
||||||
* optional so the plain-key callers (and tests) stay unchanged. */
|
|
||||||
isCustom?: boolean;
|
isCustom?: boolean;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
onBaseUrl?: (v: string) => void;
|
onBaseUrl?: (v: string) => void;
|
||||||
@@ -408,8 +402,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
if (!keyValue.trim()) return;
|
if (!keyValue.trim()) return;
|
||||||
setKeySaving(true);
|
setKeySaving(true);
|
||||||
setKeyError(null);
|
setKeyError(null);
|
||||||
// The custom endpoint connects through its own route (base URL + key +
|
// Custom endpoint connects through its own route; every other provider is a plain key PUT.
|
||||||
// discovery in one action); every other provider is a plain key PUT.
|
|
||||||
const res = isCustom
|
const res = isCustom
|
||||||
? await fetch("/api/custom-provider", {
|
? await fetch("/api/custom-provider", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -460,7 +453,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keysBody = <SetupKeysBody
|
const keysBody = <SetupKeysBody
|
||||||
providers={keyProviders ? [...keyProviders, CUSTOM_PICKER] : undefined}
|
providers={keyProviders ? [...keyProviders, CUSTOM_PROVIDER_INFO] : undefined}
|
||||||
provider={keyProvider}
|
provider={keyProvider}
|
||||||
value={keyValue}
|
value={keyValue}
|
||||||
saving={keySaving}
|
saving={keySaving}
|
||||||
|
|||||||
+5
-10
@@ -123,16 +123,11 @@ export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE SINGLE CUSTOM ENDPOINT's identity (custom-endpoint wave): a
|
* THE single custom endpoint's identity: a user-configured OpenAI-compatible
|
||||||
* user-configured OpenAI-compatible server registered into pi-ai at runtime
|
* server registered into pi-ai at runtime (server/custom-provider.ts). NOT part
|
||||||
* (server/custom-provider.ts). Deliberately NOT part of AUTH_PROVIDER_KEYS —
|
* of AUTH_PROVIDER_KEYS (that table is test-pinned to pi-ai's built-ins and is
|
||||||
* that table is test-pinned to pi-ai's BUILT-IN providers
|
* env-var-keyed); this one is keyed by a user-supplied base URL, and its key
|
||||||
* (tests/auth-provider-keys.test.ts) and every row is env-var-keyed; the
|
* rides this reserved env var (optional — keyless local servers allowed).
|
||||||
* custom endpoint is keyed by a user-supplied BASE URL instead, and its API
|
|
||||||
* key rides this one reserved env var (OPTIONAL — keyless local servers such
|
|
||||||
* as Ollama / LM Studio are allowed). The Custom endpoint Settings section is
|
|
||||||
* its one home; the key is stored in auth.json under `envVar` like any other
|
|
||||||
* provider key (masked, env-reflected, no restart).
|
|
||||||
*/
|
*/
|
||||||
export const CUSTOM_PROVIDER = {
|
export const CUSTOM_PROVIDER = {
|
||||||
id: "custom",
|
id: "custom",
|
||||||
|
|||||||
@@ -96,10 +96,7 @@ 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>;
|
||||||
/** The single custom OpenAI-compatible endpoint (custom-endpoint wave):
|
/** Custom endpoint, resolved concrete: baseUrl defaults "", models default []. */
|
||||||
* resolved concrete — baseUrl defaults "", models default []. Per-model
|
|
||||||
* defaults (context window, cost) apply at provider-build time in the
|
|
||||||
* server (custom-provider.ts), not here. */
|
|
||||||
customProvider: { baseUrl: string; models: CustomModel[] };
|
customProvider: { baseUrl: string; models: CustomModel[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-46
@@ -206,29 +206,19 @@ export interface ModelsSettings {
|
|||||||
vision?: ModelSlot;
|
vision?: ModelSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Custom OpenAI-compatible endpoint (custom-endpoint wave) ────────────────
|
// ─── Custom OpenAI-compatible endpoint ───────────────────────────────────────
|
||||||
// ONE user-configured endpoint speaking the OpenAI /chat/completions API
|
// ONE user-configured OpenAI-compatible server (vLLM / Ollama / a proxy). It is
|
||||||
// (self-hosted vLLM / Ollama / llama.cpp, a corporate proxy). It is NOT a
|
// one more provider in the Keys list, keyed by a base URL instead of an env var.
|
||||||
// special concept: it is one more provider in the Keys list, connected by a
|
// NON-SECRET config only (base URL + discovered model ids); the API key is a
|
||||||
// base URL + (optional) key exactly as the built-ins are connected by a key.
|
// secret in the auth store, never here. Registered into pi-ai as a normal
|
||||||
// NON-SECRET config only — base URL + the model ids discovered from it. The
|
// provider (server/custom-provider.ts).
|
||||||
// API key is a SECRET and lives in the auth store under CUSTOM_PROVIDER.envVar
|
|
||||||
// (core/auth-keys.ts), never in this record, exactly like every other key. The
|
|
||||||
// endpoint is registered into pi-ai as a normal provider
|
|
||||||
// (server/custom-provider.ts) so its models fill the same slots/roles.
|
|
||||||
|
|
||||||
/** Default facts for a custom model. The endpoint's /models reports ids (and
|
/** Defaults for a custom model — self-hosted endpoints are free. */
|
||||||
* sometimes modality); these fill the rest: self-hosted endpoints are free. */
|
|
||||||
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
|
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
|
||||||
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
|
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
|
||||||
|
|
||||||
/** One discovered custom model. `image` marks it as accepting image input so
|
/** One discovered custom model. `image`/`context`/`maxTokens` are detected from
|
||||||
* it is selectable for the Vision slot (PDFs, image tasks). Discovery sets it
|
* the endpoint's /models metadata; anything it omits falls back to a default. */
|
||||||
* from the endpoint's /models metadata when reported; the Keys row exposes a
|
|
||||||
* checkbox for endpoints that don't report it. `context` / `maxTokens` carry
|
|
||||||
* the endpoint's reported token limits (max_input_tokens / max_output_tokens).
|
|
||||||
* Anything the endpoint omits (cost, and any unreported window) lands on the
|
|
||||||
* CUSTOM_MODEL_DEFAULT_* values at provider-build time. */
|
|
||||||
export interface CustomModel {
|
export interface CustomModel {
|
||||||
id: string;
|
id: string;
|
||||||
/** Accepts image input (Vision-capable). Default false (text-only). */
|
/** Accepts image input (Vision-capable). Default false (text-only). */
|
||||||
@@ -239,26 +229,16 @@ export interface CustomModel {
|
|||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The single custom endpoint's stored config (delta section). `models` are
|
/** The single custom endpoint's stored config (delta section). */
|
||||||
* discovered from the endpoint's /models on connect; the only user-editable
|
|
||||||
* fact per model is the `image` capability toggle. */
|
|
||||||
export interface CustomProviderSettings {
|
export interface CustomProviderSettings {
|
||||||
/** Base URL of the OpenAI-compatible server, NORMALIZED (see
|
/** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */
|
||||||
* normalizeBaseUrl): no trailing slash, absolute http(s). Empty/absent =
|
|
||||||
* not configured. */
|
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** Models discovered from the endpoint (refreshed on connect), each with
|
/** Models discovered from the endpoint (refreshed on connect). */
|
||||||
* its image-capability flag. */
|
|
||||||
models?: CustomModel[];
|
models?: CustomModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** THE one place the base-URL canonical form is defined (single source — the
|
/** Canonical base-URL form (single source): trims, requires absolute http(s),
|
||||||
* sanitizer stores the normalized value, so no consumer re-derives it and
|
* drops trailing slashes. Returns "" for anything invalid ("not configured"). */
|
||||||
* the trailing-slash edge case is defined out of existence). Trims; parses
|
|
||||||
* as an absolute URL (scheme + host lowercased by the parser); drops any
|
|
||||||
* trailing slashes (the OpenAI SDK appends /chat/completions itself).
|
|
||||||
* Returns "" for anything that is not a valid absolute http(s) URL — callers
|
|
||||||
* treat "" as "not configured". */
|
|
||||||
export function normalizeBaseUrl(raw: string): string {
|
export function normalizeBaseUrl(raw: string): string {
|
||||||
const value = raw.trim();
|
const value = raw.trim();
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
@@ -272,11 +252,8 @@ export function normalizeBaseUrl(raw: string): string {
|
|||||||
return url.toString().replace(/\/+$/, "");
|
return url.toString().replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** THE single source of "is the custom endpoint usable?" (custom-endpoint
|
/** THE single source of "is the custom endpoint usable?": a non-empty base URL.
|
||||||
* wave): a non-empty base URL. The API key is OPTIONAL (keyless local
|
* Key presence is deliberately excluded (keyless local servers are allowed). */
|
||||||
* servers), so key presence is deliberately NOT part of this — the picker
|
|
||||||
* federation, the sync, and the roster all consult this one predicate so the
|
|
||||||
* rule can never drift between them. */
|
|
||||||
export function customProviderUsable(
|
export function customProviderUsable(
|
||||||
cp: CustomProviderSettings | undefined,
|
cp: CustomProviderSettings | undefined,
|
||||||
): cp is CustomProviderSettings & { baseUrl: string } {
|
): cp is CustomProviderSettings & { baseUrl: string } {
|
||||||
@@ -448,8 +425,7 @@ 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 single custom OpenAI-compatible endpoint (custom-endpoint wave).
|
/** The custom OpenAI-compatible endpoint (non-secret config only). */
|
||||||
* Non-secret config only; the API key lives in the auth store. */
|
|
||||||
customProvider?: CustomProviderSettings;
|
customProvider?: CustomProviderSettings;
|
||||||
appearance?: AppearanceSettings;
|
appearance?: AppearanceSettings;
|
||||||
graph?: GraphSettings;
|
graph?: GraphSettings;
|
||||||
@@ -577,16 +553,14 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The customProvider section: a normalized base URL (invalid → dropped, i.e.
|
|
||||||
* treated as unset) and the discovered models (each `{ id, image? }`; ids
|
|
||||||
* trimmed, empties and duplicate ids dropped, `image` kept only when true;
|
|
||||||
* a non-array drops the whole list). Clamp-never-reject, like every
|
|
||||||
* sanitizer here. */
|
|
||||||
/** A positive integer, or undefined — keeps only meaningful token counts. */
|
/** A positive integer, or undefined — keeps only meaningful token counts. */
|
||||||
function positiveInt(value: unknown): number | undefined {
|
function positiveInt(value: unknown): number | undefined {
|
||||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings {
|
||||||
const out: CustomProviderSettings = {};
|
const out: CustomProviderSettings = {};
|
||||||
if (typeof raw.baseUrl === "string") {
|
if (typeof raw.baseUrl === "string") {
|
||||||
|
|||||||
@@ -1,30 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* THE CUSTOM OPENAI-COMPATIBLE ENDPOINT (custom-endpoint wave): the deep
|
* The custom OpenAI-compatible endpoint: turns the user's non-secret config
|
||||||
* module that turns the user's non-secret config (base URL + discovered model
|
* (base URL + discovered models) into a pi-ai provider registered in
|
||||||
* ids, core/settings.ts `customProvider`) into a real pi-ai provider registered
|
* `piModels()`, so its models fill the same slots as any built-in. Keyless
|
||||||
* in the process-wide `piModels()` collection — so its models fill the same
|
* local servers work via a placeholder key resolved at request time.
|
||||||
* strong/cheap/vision slots and background roles as any built-in.
|
|
||||||
*
|
|
||||||
* WHY A MODULE OWNS THIS (information hiding): the fact "a custom endpoint is
|
|
||||||
* an OpenAI-completions provider at a user URL with an optional key" would
|
|
||||||
* otherwise leak into the catalog, the keys seam, and the settings route. It
|
|
||||||
* lives here instead; those surfaces consult the pure `customProviderUsable`
|
|
||||||
* predicate (core) and call `syncCustomProvider`. The single-endpoint scope is
|
|
||||||
* special-purpose, but the builder is written general (`buildOpenAiCompatible
|
|
||||||
* Provider`) so a future multi-endpoint wave reuses it unchanged.
|
|
||||||
*
|
|
||||||
* LIFECYCLE (no restart, mirroring the keys seam): `syncCustomProvider` is
|
|
||||||
* idempotent and cheap — called at boot (stores.ts, the composition root) and
|
|
||||||
* after every connect (POST /api/custom-provider). It replaces
|
|
||||||
* or removes the provider in `piModels()` by id. The base URL is fixed at
|
|
||||||
* provider construction, so a base-URL change is a rebuild — which is exactly
|
|
||||||
* what a re-sync does.
|
|
||||||
*
|
|
||||||
* AUTH: pi-ai resolves a provider's key at request time. We read the reserved
|
|
||||||
* env var through the keys seam (`keyValue` — env-wins, file fallback, honors
|
|
||||||
* the fresh-user switch), and fall back to a placeholder when none is set so
|
|
||||||
* KEYLESS local servers (Ollama / LM Studio / llama.cpp) work. The placeholder
|
|
||||||
* is an internal detail of `resolve()` — no caller ever sees it.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createProvider, type ApiKeyAuth, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
|
import { createProvider, type ApiKeyAuth, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
|
||||||
@@ -41,35 +19,24 @@ import {
|
|||||||
} from "../core/settings.ts";
|
} from "../core/settings.ts";
|
||||||
import { keyValue } from "./keys.ts";
|
import { keyValue } from "./keys.ts";
|
||||||
|
|
||||||
/** Placeholder bearer sent to keyless endpoints. Many local OpenAI-compatible
|
/** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */
|
||||||
* servers ignore the Authorization header entirely; the OpenAI client still
|
|
||||||
* requires *a* key, so we hand it this. Never shown to the user. */
|
|
||||||
const KEYLESS_PLACEHOLDER = "not-needed";
|
const KEYLESS_PLACEHOLDER = "not-needed";
|
||||||
|
|
||||||
/** Narrow an unknown to a plain object (not null, not an array). */
|
/** Narrow an unknown to a plain object (not null, not an array). */
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
/** The custom endpoint's auth: resolve the reserved key through the keys
|
/** Auth: the reserved key via the keys seam, else the keyless placeholder. */
|
||||||
* seam, else the keyless placeholder. `resolve` is the only method pi-ai
|
|
||||||
* needs to treat the provider as configured. */
|
|
||||||
const customEndpointAuth: ApiKeyAuth = {
|
const customEndpointAuth: ApiKeyAuth = {
|
||||||
name: `${CUSTOM_PROVIDER.label} API key`,
|
name: `${CUSTOM_PROVIDER.label} API key`,
|
||||||
resolve: async () => {
|
resolve: async () => {
|
||||||
const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim();
|
const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim();
|
||||||
// Keyless local servers ignore the header; the OpenAI client still
|
|
||||||
// needs *a* key, so hand it the placeholder.
|
|
||||||
return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" };
|
return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Map one discovered custom model to a pi-ai chat model entry. The endpoint's
|
/** Map a discovered model to a pi-ai entry: image→input, context/maxTokens→
|
||||||
* /models reports ids plus whatever metadata it exposes; `image` sets `input`
|
* limits (else CUSTOM_MODEL_DEFAULT_*). baseUrl is stamped per-model. */
|
||||||
* so a Vision-capable model is selectable for the Vision slot (PDFs, images),
|
|
||||||
* and `context` / `maxTokens` carry the endpoint's reported token limits.
|
|
||||||
* Anything the endpoint omits falls back to the CUSTOM_MODEL_DEFAULT_* values
|
|
||||||
* (self-hosted = free, modest window). `baseUrl` is stamped per-model (pi reads
|
|
||||||
* it from the model, not the provider). */
|
|
||||||
function toPiModel(model: CustomModel, baseUrl: string): Model<Api> {
|
function toPiModel(model: CustomModel, baseUrl: string): Model<Api> {
|
||||||
return {
|
return {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
@@ -86,8 +53,7 @@ function toPiModel(model: CustomModel, baseUrl: string): Model<Api> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a general OpenAI-compatible provider from parts (single-use today,
|
/** Build an OpenAI-compatible provider from parts. baseUrl must be normalized. */
|
||||||
* general by design). `baseUrl` must already be normalized. */
|
|
||||||
export function buildOpenAiCompatibleProvider(input: {
|
export function buildOpenAiCompatibleProvider(input: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
models: readonly CustomModel[];
|
models: readonly CustomModel[];
|
||||||
@@ -102,21 +68,14 @@ export function buildOpenAiCompatibleProvider(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Re-sync the provider into piModels() (idempotent): usable → rebuild;
|
||||||
* Re-sync the custom provider into `piModels()` from the resolved section
|
* not usable → remove. Never throws. */
|
||||||
* (idempotent). Usable → (re)build and `setProvider` (replaces by id, so a
|
|
||||||
* base-URL/model change lands with no restart). Not usable → `deleteProvider`
|
|
||||||
* so it stops federating and any slot pinning one of its models falls back to
|
|
||||||
* the honest models_not_configured refusal. Never throws: a bad config simply
|
|
||||||
* leaves the provider absent.
|
|
||||||
*/
|
|
||||||
export function syncCustomProvider(cp: CustomProviderSettings | undefined): void {
|
export function syncCustomProvider(cp: CustomProviderSettings | undefined): void {
|
||||||
const models = piModels();
|
const models = piModels();
|
||||||
if (!customProviderUsable(cp)) {
|
if (!customProviderUsable(cp)) {
|
||||||
models.deleteProvider(CUSTOM_PROVIDER.id);
|
models.deleteProvider(CUSTOM_PROVIDER.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// `customProviderUsable` narrowed cp to a defined section with a baseUrl.
|
|
||||||
models.setProvider(
|
models.setProvider(
|
||||||
buildOpenAiCompatibleProvider({ baseUrl: normalizeBaseUrl(cp.baseUrl), models: cp.models ?? [] }),
|
buildOpenAiCompatibleProvider({ baseUrl: normalizeBaseUrl(cp.baseUrl), models: cp.models ?? [] }),
|
||||||
);
|
);
|
||||||
@@ -125,14 +84,9 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void
|
|||||||
/** Outcome of a model-discovery probe. */
|
/** Outcome of a model-discovery probe. */
|
||||||
export type DiscoverResult = { ok: true; models: CustomModel[] } | { ok: false; error: string };
|
export type DiscoverResult = { ok: true; models: CustomModel[] } | { ok: false; error: string };
|
||||||
|
|
||||||
/**
|
/** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https
|
||||||
* Probe an endpoint's `GET {baseUrl}/models` (the OpenAI list-models route)
|
* only); keyless endpoints answer without a bearer. Non-2xx or unparseable
|
||||||
* and return the model ids. The scheme is validated via normalizeBaseUrl
|
* body → honest error, never a silent empty list. */
|
||||||
* (http/https only — the SSRF guard: this is a local app, but a user paste of
|
|
||||||
* `file://` or a stray string must never reach fetch). The key rides as a
|
|
||||||
* bearer when one is configured; keyless endpoints answer without it. A
|
|
||||||
* non-2xx or unparseable body is an honest error, never a silent empty list.
|
|
||||||
*/
|
|
||||||
export async function discoverModels(baseUrl: string, apiKey?: string): Promise<DiscoverResult> {
|
export async function discoverModels(baseUrl: string, apiKey?: string): Promise<DiscoverResult> {
|
||||||
const base = normalizeBaseUrl(baseUrl);
|
const base = normalizeBaseUrl(baseUrl);
|
||||||
if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" };
|
if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" };
|
||||||
@@ -157,9 +111,8 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The model rows in a list response, or null when the shape is unrecognized.
|
/** Model rows from a list response ({data:[…]} | {models:[…]} | bare array),
|
||||||
* Accepts the OpenAI `{data:[…]}`, the Ollama-style `{models:[…]}`, or a
|
* or null if unrecognized. */
|
||||||
* bare array. */
|
|
||||||
function modelRows(body: unknown): unknown[] | null {
|
function modelRows(body: unknown): unknown[] | null {
|
||||||
if (Array.isArray(body)) return body;
|
if (Array.isArray(body)) return body;
|
||||||
if (isRecord(body)) {
|
if (isRecord(body)) {
|
||||||
@@ -169,12 +122,8 @@ function modelRows(body: unknown): unknown[] | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does a /models row advertise image input? Endpoints are inconsistent, so
|
/** Does a /models row advertise image input? Scans the common modality/
|
||||||
* scan the common modality/capability fields (OpenRouter's
|
* capability fields; absent or unrecognized → false (text-only). */
|
||||||
* `architecture.input_modalities`, plus `input_modalities` / `modalities` /
|
|
||||||
* `supported_modalities` / `capabilities`) for a value mentioning image or
|
|
||||||
* vision. Absent/unrecognized -> false (text-only); the Keys row exposes a
|
|
||||||
* manual toggle for endpoints that report nothing. */
|
|
||||||
function detectImage(row: Record<string, unknown>): boolean {
|
function detectImage(row: Record<string, unknown>): boolean {
|
||||||
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
|
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -190,9 +139,8 @@ function detectImage(row: Record<string, unknown>): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First positive integer among `keys` on a /models row, else undefined.
|
/** First positive integer among `keys` on a row (endpoints name limits
|
||||||
* Endpoints name the token limits inconsistently, so the caller passes an
|
* inconsistently). */
|
||||||
* ordered list of candidate field names. */
|
|
||||||
function firstPositive(row: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
function firstPositive(row: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = row[key];
|
const value = row[key];
|
||||||
@@ -205,10 +153,8 @@ function firstPositive(row: Record<string, unknown>, keys: readonly string[]): n
|
|||||||
const CONTEXT_KEYS = ["max_input_tokens", "context_length", "context_window", "max_context", "n_ctx", "max_seq_len", "max_position_embeddings"] as const;
|
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;
|
const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
|
||||||
|
|
||||||
/** Pull models out of a list response (entries are `{id, ...}` objects or bare
|
/** Pull models from a list response (objects with `id` or bare strings), each
|
||||||
* strings), each carrying its detected image capability and token limits.
|
* with detected image + token limits. Null if the shape is unrecognized. */
|
||||||
* Returns null when the shape is unrecognized - the caller reports an honest
|
|
||||||
* error rather than a misleading empty "no models". */
|
|
||||||
function extractModels(body: unknown): CustomModel[] | null {
|
function extractModels(body: unknown): CustomModel[] | null {
|
||||||
const rows = modelRows(body);
|
const rows = modelRows(body);
|
||||||
if (rows === null) return null;
|
if (rows === null) return null;
|
||||||
|
|||||||
+2
-3
@@ -45,7 +45,6 @@ export * from "./observability.ts";
|
|||||||
export * from "./model-catalog.ts";
|
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";
|
||||||
// The custom OpenAI-compatible endpoint (custom-endpoint wave): the deep
|
// The custom endpoint module (registers into piModels() + probes for models).
|
||||||
// module that registers the user's endpoint into piModels() and probes it for
|
// No import side effects; the boot sync lives in stores.ts.
|
||||||
// models. Importing it has no side effects; the boot sync lives in stores.ts.
|
|
||||||
export * from "./custom-provider.ts";
|
export * from "./custom-provider.ts";
|
||||||
|
|||||||
+2
-4
@@ -73,10 +73,8 @@ function boot(): AuthBoot {
|
|||||||
return g[BOOT_KEY];
|
return g[BOOT_KEY];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is `id` a WRITABLE key name — curated catalog, model-provider env
|
/** Is `id` a WRITABLE key name — curated catalog, model-provider env var, or
|
||||||
* var (provider-keys interlude), or the custom endpoint's reserved key
|
* the custom endpoint's reserved key (writable through the same seam). */
|
||||||
* (custom-endpoint wave: writable through the same seam, generic shape
|
|
||||||
* guard, but NOT part of the test-pinned AUTH_PROVIDER_KEYS table). */
|
|
||||||
export function isWritableAuthKey(id: string): boolean {
|
export function isWritableAuthKey(id: string): boolean {
|
||||||
return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar;
|
return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -47,10 +47,15 @@ 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 { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
import {
|
||||||
import { customProviderUsable } from "../core/settings.ts";
|
BACKGROUND_MODEL_ROLES,
|
||||||
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
|
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;
|
||||||
@@ -144,12 +149,8 @@ export function catalogProviders(): CatalogProvider[] {
|
|||||||
})).concat([
|
})).concat([
|
||||||
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
||||||
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
||||||
// The custom endpoint (custom-endpoint wave): a row only when usable (a
|
// The custom endpoint: a row only when usable (base URL set). keyPresent
|
||||||
// base URL is set). Its key is OPTIONAL (keyless local servers), so
|
// reflects usability, not key presence (keyless local servers are allowed).
|
||||||
// keyPresent here reflects USABILITY, not key presence — the picker
|
|
||||||
// federates exactly the usable providers. `customProviderUsable` is the
|
|
||||||
// single source of that rule, so this row and the sync in custom-provider.ts
|
|
||||||
// can never disagree.
|
|
||||||
...(customProviderUsable(effectiveSettings().customProvider)
|
...(customProviderUsable(effectiveSettings().customProvider)
|
||||||
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }]
|
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -386,10 +386,8 @@ 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 section→parser map for PUT /api/settings/[section]. The custom endpoint
|
||||||
* carries the special prompts sub-resource). The custom endpoint is NOT a
|
* is NOT a section — it connects via POST /api/custom-provider. */
|
||||||
* section here — it is one more provider in the Keys list, connected through
|
|
||||||
* POST /api/custom-provider (base URL + key + discovery in one action). */
|
|
||||||
export const SETTINGS_SECTIONS = {
|
export const SETTINGS_SECTIONS = {
|
||||||
general: parseGeneralSettingsBody,
|
general: parseGeneralSettingsBody,
|
||||||
chat: parseSettingsBody,
|
chat: parseSettingsBody,
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ export function effectiveSettings(): EffectiveSettings {
|
|||||||
return resolveSettings(settingsStore.readSettings());
|
return resolveSettings(settingsStore.readSettings());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot: register the custom OpenAI-compatible endpoint (when configured) into
|
// Boot: register the custom endpoint (when configured) into piModels(); the
|
||||||
// the process-wide piModels() so its models federate with no restart — the
|
|
||||||
// exact lifecycle the keys seam runs for keys (initAuthEnv at import). The
|
|
||||||
// POST /api/custom-provider route re-syncs after every connect.
|
// POST /api/custom-provider route re-syncs after every connect.
|
||||||
syncCustomProvider(effectiveSettings().customProvider);
|
syncCustomProvider(effectiveSettings().customProvider);
|
||||||
|
|||||||
Reference in New Issue
Block a user