Merge PR #62 contribution for donor-led integration

This commit is contained in:
amosblomqvist
2026-10-29 02:25:19 +02:00
20 changed files with 1690 additions and 72 deletions
+13
View File
@@ -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" },
] 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
* writability check — see isProviderEnvVar below; also drives which
* PRESENT provider vars surface as status rows). */
+11
View File
@@ -37,6 +37,8 @@ import {
type GeneralSettings,
type DictationSettings,
type CompactionSettings,
type CustomModel,
type CustomModelOverride,
type GraphSettings,
type ModelSlot,
type ModelSlotName,
@@ -95,6 +97,9 @@ export interface EffectiveSettings {
/** Compaction (owner round 14): resolved concrete — absent record
* section resolves to the factory 150k. */
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
@@ -221,6 +226,12 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
compaction: {
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 ?? [],
},
};
}
+167
View File
@@ -208,6 +208,96 @@ export interface ModelsSettings {
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
* 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
@@ -375,6 +465,8 @@ export interface UserSettings {
agent?: AgentSettings;
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
models?: ModelsSettings;
/** The custom OpenAI-compatible endpoint (non-secret config only). */
customProvider?: CustomProviderSettings;
appearance?: AppearanceSettings;
graph?: GraphSettings;
compaction?: CompactionSettings;
@@ -501,6 +593,78 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
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 {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
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>);
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)) {
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
+231
View File
@@ -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));
}
+3
View File
@@ -46,3 +46,6 @@ export * from "./model-catalog.ts";
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
export { sessionCost } from "./pricing.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
View File
@@ -43,6 +43,7 @@ import {
AUTH_KEYS,
AUTH_PROVIDER_KEYS,
AUTH_PROVIDER_ENV_VARS,
CUSTOM_PROVIDER,
type AuthKeyInfo,
type AuthKeyStatus,
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.
import { authEnvRecord as boot } from "../core/auth-env.ts";
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
* var (provider-keys interlude: PUT/DELETE accept both). */
/** Is `id` a WRITABLE key name — curated catalog, model-provider env var, or
* the custom endpoint's reserved key (writable through the same seam). */
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
@@ -209,7 +210,10 @@ export function providerKeyPresent(provider: string): boolean {
* reports shadowedByEnv so the page can explain it).
*/
export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } {
const shape = isProviderEnvVar(id) ? 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 };
const trimmed = value.trim();
authStore.setAuthVar(id, trimmed);
+14 -2
View File
@@ -49,10 +49,17 @@ import type {
SlotPins,
} from "../core/contracts/models.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 { 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). */
export type ModelDto = ModelCatalogEntry;
@@ -149,6 +156,11 @@ export function catalogProviders(): CatalogProvider[] {
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
...customProviderRows(),
// The custom endpoint: a row only when usable (base URL set). keyPresent
// reflects usability, not key presence (keyless local servers are allowed).
...(customProviderUsable(effectiveSettings().customProvider)
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }]
: []),
]).sort((a, b) => a.id.localeCompare(b.id));
}
+43 -3
View File
@@ -18,13 +18,14 @@ import type {
AppearanceSettings,
BackgroundModelRole,
CompactionSettings,
CustomModelOverride,
GeneralSettings,
DictationSettings,
GraphSettings,
ModelsSettings,
RoleModelOverride,
} 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 { isDictationLanguage } from "../core/dictation.ts";
@@ -395,8 +396,47 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<Di
return { ok: true, settings: { language: raw.language } };
}
/** The section→parser map for PUT /api/settings/[section] (the route also
* carries the special prompts sub-resource). */
/** The custom endpoint's per-model pins (PATCH /api/custom-provider): the
* 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 = {
general: parseGeneralSettingsBody,
chat: parseSettingsBody,
+5
View File
@@ -11,6 +11,7 @@ import { graphStore } from "../store/graph.ts";
import { jobStore } from "../store/jobs.ts";
import { settingsStore } from "../store/settings.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. */
export const stores = {
chatStore,
@@ -32,3 +33,7 @@ export type Stores = typeof stores;
export function effectiveSettings(): EffectiveSettings {
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);