diff --git a/docs/user/settings.md b/docs/user/settings.md
index 7b49e82..b1050a2 100644
--- a/docs/user/settings.md
+++ b/docs/user/settings.md
@@ -29,6 +29,13 @@ The selected model receives the prompt, attachments, and context Stem adds to th
turn. A cloud model receives that data on its provider’s service. A local model
sends it to the server address you configured.
+**Custom endpoint** is any other OpenAI- or Anthropic-compatible server (a vLLM
+box, a gateway, a proxy). After you add it, you can paste a Pi `models.json` or
+give a path to one. Stem copies that provider’s extras — thinking flags, max
+tokens, Qwen/GLM `thinkingFormat` — onto its own Custom endpoint. That does not
+replace Stem’s Pi, and Stem will not overwrite those extras until you replace
+them or disconnect.
+
**Web search** works with every model, not only cloud ones, and returns cited
sources. Under **Web search** you choose the backend that runs the search:
**Automatic** ends at one that needs no key, or pick a named one and paste its key.
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 3bb3357..7eb3006 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -69,6 +69,12 @@ const api: StemApi = {
ipcRenderer.invoke('providers:updateLocal', id, patch),
testLocalProvider: (id: LocalProviderId, baseUrl: string, apiKey?: string, api?: LocalProviderApi) =>
ipcRenderer.invoke('providers:testLocal', id, baseUrl, apiKey, api),
+ previewPiModels: (source: { json?: string; path?: string }) => ipcRenderer.invoke('providers:previewPiModels', source),
+ copyPiModels: (
+ source: { json?: string; path?: string },
+ providerId: string,
+ hints?: { baseUrl?: string; apiKey?: string; api?: LocalProviderApi }
+ ) => ipcRenderer.invoke('providers:copyPiModels', source, providerId, hints),
disconnectProvider: (providerId: string) => ipcRenderer.invoke('providers:disconnect', providerId),
checkAuth: (provider: string) => ipcRenderer.invoke('auth:check', provider),
completeOnboarding: () => ipcRenderer.invoke('auth:completeOnboarding'),
diff --git a/src/renderer/manage/tabs/settings/ModelsSettings.tsx b/src/renderer/manage/tabs/settings/ModelsSettings.tsx
index 5918a75..1f912b9 100644
--- a/src/renderer/manage/tabs/settings/ModelsSettings.tsx
+++ b/src/renderer/manage/tabs/settings/ModelsSettings.tsx
@@ -8,7 +8,8 @@ import type {
LocalProviderApi,
LocalProviderId,
LocalProvidersSettings,
- LocalProviderTestResult
+ LocalProviderTestResult,
+ PiModelsOverlayProvider
} from '../../../../shared/types';
import { API_KEY_PROVIDER_IDS, AUTH_PROVIDER_IDS, isLocalProviderId, providerName } from '../../../../shared/providers';
import { resolveBackgroundModel, resolveMemoryModel, resolveRoleEffort, resolveSkillsModel } from '../../../../shared/modelRoles';
@@ -684,6 +685,9 @@ function ProvidersSection({ deadProvider }: { deadProvider?: string | null }) {
// Add Server) so the steady state is a calm list of connected providers.
const [adding, setAdding] = useState(false);
const [mode, setMode] = useState<'account' | 'apikey' | 'local'>('account');
+ // Disconnect of a Custom endpoint that still has copied extras needs a second
+ // click: those extras live only in Stem, and a silent minus would drop them.
+ const [confirmDisconnect, setConfirmDisconnect] = useState(false);
// In-flight OAuth attempt (null = none). Mirrors the onboarding wizard's
// oauthWait/manualInput steps in miniature; completion resolves the
// providerLogin promise, so `done` events only clear transient state.
@@ -763,6 +767,7 @@ function ProvidersSection({ deadProvider }: { deadProvider?: string | null }) {
async function disconnect(id: string) {
setBusy(true);
setError(null);
+ setConfirmDisconnect(false);
try {
const res = await window.stem.disconnectProvider(id);
if (!res.ok) setError(res.error ?? 'Could not disconnect.');
@@ -802,7 +807,10 @@ function ProvidersSection({ deadProvider }: { deadProvider?: string | null }) {
setSelected(row.id)}
+ onClick={() => {
+ setConfirmDisconnect(false);
+ setSelected(row.id);
+ }}
>
{row.local ? : }
@@ -844,12 +852,54 @@ function ProvidersSection({ deadProvider }: { deadProvider?: string | null }) {
selected && void disconnect(selected)}
+ onClick={() => {
+ if (!selected) return;
+ if (selected === 'custom' && local?.custom.preserveModelsConfig && !confirmDisconnect) {
+ setConfirmDisconnect(true);
+ return;
+ }
+ void disconnect(selected);
+ }}
disabled={!selected || busy}
>
+ {confirmDisconnect && selected === 'custom' && (
+
+ This removes the copied extras from Stem. Your own files are not changed.{' '}
+ void disconnect('custom')}>
+ Disconnect
+
+ setConfirmDisconnect(false)}>
+ Cancel
+
+
+ )}
+
+ {selected === 'custom' && local?.custom.enabled && !adding && (
+ <>
+ Custom endpoint extras
+
+ {local.custom.preserveModelsConfig ? (
+
+ Stem keeps these extras on the Custom endpoint and will not overwrite them until you
+ replace them or disconnect.
+
+ ) : (
+
+ Paste a models.json or give a path to copy thinking and max-token extras onto this
+ endpoint. Stem's other providers stay as they are.
+
+ )}
+
+
+ >
+ )}
{adding && (
<>
@@ -1118,6 +1168,7 @@ function LocalServerAddForm({
const [testing, setTesting] = useState(false);
const [test, setTest] = useState(null);
const [saving, setSaving] = useState(false);
+ const [confirmEnable, setConfirmEnable] = useState(false);
// The probe runs for seconds against a URL the user is still typing into, so a
// late answer must prove it still belongs to this form before it may speak for
// it: a crossed result restores a cleared badge and fills one endpoint's model
@@ -1145,6 +1196,7 @@ function LocalServerAddForm({
setApi(id === 'custom' ? null : 'openai-completions');
setTest(null);
setTesting(false);
+ setConfirmEnable(false);
}
async function runTest() {
@@ -1195,6 +1247,10 @@ function LocalServerAddForm({
async function enable() {
// Nothing in flight may label the form once it has been submitted — and the
// discarded probe no longer owns the button it left in its testing state.
+ if (custom && settings.custom.preserveModelsConfig && !confirmEnable) {
+ setConfirmEnable(true);
+ return;
+ }
testGateRef.current.invalidate();
setTesting(false);
setSaving(true);
@@ -1210,12 +1266,18 @@ function LocalServerAddForm({
// Sent even when empty so re-adding a previously keyed endpoint without
// one clears the stored key instead of silently inheriting it.
apiKey: custom ? apiKey.trim() : '',
- models: custom ? modelList : []
+ models: custom ? modelList : [],
+ // Typed IDs replace a copied overlay: drop extras so sync writes `{ id }`
+ // stubs again instead of keeping the previous thinking/max-token flags.
+ ...(custom
+ ? { preserveModelsConfig: false, modelExtras: [], providerCompat: {}, providerHeaders: {} }
+ : {})
});
if (!res.ok) onError(res.error ?? 'Could not enable the server.');
else await onSaved();
} finally {
setSaving(false);
+ setConfirmEnable(false);
}
}
@@ -1246,7 +1308,9 @@ function LocalServerAddForm({
from your URL and lets the client add the versioned path itself. The key goes on the wire the way the
target API expects (Authorization: Bearer for OpenAI-flavored servers, X-Api-Key
for Anthropic). Test connection fills the model IDs in when the endpoint lists them; endpoints that serve
- no listing just need the IDs typed in.
+ no listing just need the IDs typed in. For a local vLLM (or similar) that does not advertise thinking
+ flags, paste a models.json overlay or a path to one — Stem copies those extras onto this endpoint and
+ does not replace Stem's Pi.
setModels(e.target.value)}
/>
+ Model extras
+
+ Optional. Paste a models.json or a path to copy thinking and max-token extras onto this
+ endpoint.
+
+
>
)}
@@ -1335,9 +1410,170 @@ function LocalServerAddForm({
disabled={saving || !baseUrl.trim() || (custom && modelList.length === 0) || (custom && api === null)}
onClick={() => void enable()}
>
- {saving ? 'Enabling…' : 'Enable'}
+ {saving ? 'Enabling…' : confirmEnable ? 'Replace extras and enable' : 'Enable'}
+
+
+ {confirmEnable && (
+
+ Enable with typed IDs replaces the copied extras on this endpoint. Your own files are not
+ changed.
+
+ )}
+
+ );
+}
+
+/**
+ * Paste JSON or a path to a Pi models.json; copy one provider's extras onto
+ * Stem's Custom endpoint. Does not replace Stem's Pi.
+ */
+function CustomOverlayFields({
+ locked,
+ onCopied,
+ onError,
+ hints
+}: {
+ locked: boolean;
+ onCopied: () => Promise;
+ onError: (message: string | null) => void;
+ hints?: { baseUrl?: string; apiKey?: string; api?: LocalProviderApi | null };
+}) {
+ const [json, setJson] = useState('');
+ const [path, setPath] = useState('');
+ const [providers, setProviders] = useState(null);
+ const [picked, setPicked] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [confirmReplace, setConfirmReplace] = useState(false);
+
+ function source(): { json?: string; path?: string } {
+ return json.trim() ? { json: json.trim() } : { path: path.trim() };
+ }
+
+ function resetPreview() {
+ setProviders(null);
+ setPicked('');
+ setConfirmReplace(false);
+ }
+
+ async function preview() {
+ setBusy(true);
+ onError(null);
+ resetPreview();
+ try {
+ const res = await window.stem.previewPiModels(source());
+ if (!res.ok) onError(res.error ?? 'Could not read that overlay.');
+ else {
+ setProviders(res.providers ?? []);
+ if (res.providers?.length === 1) setPicked(res.providers[0].id);
+ }
+ } catch {
+ onError('Could not read that overlay.');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function copy() {
+ if (!picked) return;
+ if (locked && !confirmReplace) {
+ setConfirmReplace(true);
+ return;
+ }
+ setBusy(true);
+ onError(null);
+ try {
+ const res = await window.stem.copyPiModels(source(), picked, {
+ ...(hints?.baseUrl?.trim() ? { baseUrl: hints.baseUrl.trim() } : {}),
+ ...(hints?.apiKey !== undefined ? { apiKey: hints.apiKey } : {}),
+ ...(hints?.api ? { api: hints.api } : {})
+ });
+ if (!res.ok) onError(res.error ?? 'Could not copy extras onto Custom.');
+ else await onCopied();
+ } catch {
+ onError('Could not copy extras onto Custom.');
+ } finally {
+ setBusy(false);
+ setConfirmReplace(false);
+ }
+ }
+
+ return (
+
);
}
diff --git a/src/server/ipc/auth.ts b/src/server/ipc/auth.ts
index c7f335e..7d42c14 100644
--- a/src/server/ipc/auth.ts
+++ b/src/server/ipc/auth.ts
@@ -2,6 +2,7 @@ import { registerServer } from './guard';
import type { IpcDeps } from './deps';
import { markOnboardingCompleted, readSettings, updateDefaultModel, updateLocalProvider } from '../workspace/settings';
import { probeLocalProvider, syncModelsConfig } from '../pi/models-config';
+import { CLEARED_CUSTOM_OVERLAY, overlayPatchFromSource, previewPiModelsSource } from '../pi/models-copy';
import { relayCallback } from '../pi/oauth-courier';
import { isLocalProviderId } from '../../shared/providers';
import type {
@@ -88,6 +89,32 @@ export function registerAuthIpc(deps: IpcDeps): void {
if (deps.e2e) return { ok: true, models: ['stem-e2e-model'] };
return probeLocalProvider(baseUrl, apiKey, api);
});
+ registerServer('providers:previewPiModels', async (_e, source: { json?: string; path?: string }) => {
+ if (deps.e2e) return { ok: true, providers: [{ id: 'vllm', modelIds: ['stem-e2e-model'] }] };
+ return previewPiModelsSource(source);
+ });
+ registerServer('providers:copyPiModels', async (_e, source: { json?: string; path?: string }, providerId: string, hints?: Partial<{ baseUrl: string; apiKey: string; api: LocalProviderApi }>) => {
+ if (deps.e2e) return { ok: true, status: await deps.runtime().login() };
+ try {
+ const stored = (await readSettings()).localProviders.custom;
+ const current = {
+ ...stored,
+ ...(hints?.baseUrl?.trim() ? { baseUrl: hints.baseUrl.trim() } : {}),
+ ...(hints?.apiKey !== undefined ? { apiKey: hints.apiKey } : {}),
+ ...(hints?.api ? { api: hints.api } : {})
+ };
+ const overlay = await overlayPatchFromSource(source, providerId, current);
+ if (!overlay.ok) return { ok: false, error: overlay.error };
+ const settings = await updateLocalProvider('custom', overlay.patch);
+ const cfg = settings.localProviders.custom;
+ await syncModelsConfig();
+ if (cfg.enabled) await deps.providerAuth()!.setApiKey('custom', cfg.apiKey?.trim() || 'local');
+ await deps.runtime().restart();
+ } catch (e) {
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
+ }
+ return { ok: true, status: await deps.onAuthenticated() };
+ });
registerServer('providers:updateLocal', async (_e, id: LocalProviderId, patch: Partial) => {
if (deps.e2e) return { ok: true, status: await deps.runtime().login() };
try {
@@ -116,7 +143,7 @@ export function registerAuthIpc(deps: IpcDeps): void {
await deps.providerAuth()!.removeProvider(providerId);
if (isLocalProviderId(providerId)) {
// Drop the endpoint's secret with it — re-adding asks for the key again.
- await updateLocalProvider(providerId, { enabled: false, apiKey: '', models: [] });
+ await updateLocalProvider(providerId, { enabled: false, apiKey: '', models: [], ...CLEARED_CUSTOM_OVERLAY });
await syncModelsConfig();
}
// The default model must not outlive the provider that served it: pi refuses
diff --git a/src/server/ipc/guard.ts b/src/server/ipc/guard.ts
index b43214b..0d6a061 100644
--- a/src/server/ipc/guard.ts
+++ b/src/server/ipc/guard.ts
@@ -65,6 +65,8 @@ const IPC_ARGS: Record = {
a.optional(a.nullish(a.string)),
a.optional(a.nullish(a.oneOf(['openai-completions', 'anthropic-messages'])))
],
+ 'providers:previewPiModels': [a.object],
+ 'providers:copyPiModels': [a.object, a.string, a.optional(a.object)],
'providers:updateLocal': [a.string, a.object],
'providers:disconnect': [a.string],
'backend:startTurn': [a.object],
diff --git a/src/server/pi/models-config.ts b/src/server/pi/models-config.ts
index 59125c7..025e368 100644
--- a/src/server/pi/models-config.ts
+++ b/src/server/pi/models-config.ts
@@ -210,7 +210,8 @@ function localProviderBlock(
baseUrl: string,
models: PiModelConfig[],
apiKey: string | undefined,
- api: LocalProviderApi
+ api: LocalProviderApi,
+ extras?: { compat?: Record; headers?: Record }
): PiProviderConfig {
const normalized = normalizeLocalBaseUrl(baseUrl);
// openai-completions: append /v1 so pi hits /v1/chat/completions (matches the
@@ -221,17 +222,20 @@ function localProviderBlock(
const finalBaseUrl = api === 'anthropic-messages' ? normalized : `${normalized}/v1`;
// Per-flavor compat: OpenAI-compat servers get the local-server defaults;
// anthropic-messages providers get an empty block so pi's Anthropic defaults
- // (eager tool streaming, cache retention, …) apply. Users needing proxy-
- // specific overrides can hand-edit models.json.
- const compat: Record =
+ // (eager tool streaming, cache retention, …) apply. A copied overlay may
+ // replace this wholesale (thinkingFormat, chatTemplateKwargs, …).
+ const defaultCompat: Record =
api === 'anthropic-messages' ? {} : { supportsDeveloperRole: false, supportsReasoningEffort: false };
+ const overlayCompat = extras?.compat && Object.keys(extras.compat).length ? extras.compat : undefined;
+ const headers = extras?.headers && Object.keys(extras.headers).length ? extras.headers : undefined;
return {
baseUrl: finalBaseUrl,
api,
// Keyless servers still need a non-empty key for pi to consider the models
// available (pi's own docs recommend a dummy value).
apiKey: apiKey?.trim() || 'local',
- compat,
+ compat: overlayCompat ?? defaultCompat,
+ ...(headers ? { headers } : {}),
models
};
}
@@ -348,7 +352,17 @@ export function syncModelsConfig(): Promise {
const before = JSON.stringify(config);
for (const id of LOCAL_PROVIDER_IDS) {
- const { enabled, baseUrl, apiKey, models: manual, api: rawApi } = settings[id];
+ const {
+ enabled,
+ baseUrl,
+ apiKey,
+ models: manual,
+ api: rawApi,
+ preserveModelsConfig,
+ modelExtras,
+ providerCompat,
+ providerHeaders
+ } = settings[id];
if (!enabled) {
delete config.providers[id];
continue;
@@ -356,6 +370,23 @@ export function syncModelsConfig(): Promise {
// Only `custom` may speak anthropic-messages; the coercion enforces this too,
// this is defense in depth against a hand-edited settings.json.
const api: LocalProviderApi = id === 'custom' && rawApi === 'anthropic-messages' ? 'anthropic-messages' : 'openai-completions';
+ const overlay =
+ id === 'custom' && preserveModelsConfig && modelExtras?.length
+ ? {
+ models: modelExtras
+ .map((m) => {
+ const rec = m as PiModelConfig;
+ return typeof rec.id === 'string' && rec.id.trim() ? rec : null;
+ })
+ .filter((m): m is PiModelConfig => !!m),
+ extras: { compat: providerCompat, headers: providerHeaders }
+ }
+ : null;
+ if (overlay?.models.length) {
+ // Copied extras are the catalog: don't probe, don't strip to `{ id }`.
+ config.providers[id] = localProviderBlock(baseUrl, overlay.models, apiKey, api, overlay.extras);
+ continue;
+ }
// Hand-entered ids are authoritative: an endpoint that names its models has
// opted out of discovery, so don't probe it (and don't let a listing endpoint
// it happens to serve override the user's choice).
diff --git a/src/server/pi/models-copy.ts b/src/server/pi/models-copy.ts
new file mode 100644
index 0000000..bce537e
--- /dev/null
+++ b/src/server/pi/models-copy.ts
@@ -0,0 +1,244 @@
+import { homedir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { readFile, stat } from 'node:fs/promises';
+import type {
+ LocalProviderApi,
+ LocalProviderSettings,
+ PiModelsOverlayPreview,
+ PiModelsOverlayProvider
+} from '../../shared/types';
+import { normalizeLocalBaseUrl } from './models-config';
+
+// Copy a Pi models.json overlay onto Stem's Custom endpoint. Stem's isolated
+// pi-home stays Stem's: this never points PI_CODING_AGENT_DIR at ~/.pi and never
+// replaces auth, Pi settings, or other provider blocks. It only extracts one
+// provider's extras (reasoning, thinkingFormat, maxTokens, …) so syncModelsConfig
+// can write them onto providers.custom.
+
+/** Settings patch that drops a copied overlay (disconnect / typed-ID Enable). */
+export const CLEARED_CUSTOM_OVERLAY: Pick<
+ LocalProviderSettings,
+ 'preserveModelsConfig' | 'modelExtras' | 'providerCompat' | 'providerHeaders'
+> = {
+ preserveModelsConfig: false,
+ modelExtras: [],
+ providerCompat: {},
+ providerHeaders: {}
+};
+
+interface PiProviderBlock {
+ baseUrl?: unknown;
+ api?: unknown;
+ apiKey?: unknown;
+ compat?: unknown;
+ headers?: unknown;
+ models?: unknown;
+}
+
+export interface CustomOverlayPatch {
+ enabled: true;
+ baseUrl: string;
+ api?: LocalProviderApi;
+ apiKey?: string;
+ models: string[];
+ preserveModelsConfig: true;
+ modelExtras: Record[];
+ providerCompat?: Record;
+ providerHeaders?: Record;
+}
+
+/**
+ * Parse a Pi models.json (or a single provider block) into the providers a
+ * Custom-endpoint copy can use. Never throws — failures come back as `{ ok:false }`.
+ */
+export function previewPiModelsJson(raw: string): PiModelsOverlayPreview {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return { ok: false, error: 'That is not valid JSON.' };
+ }
+ const providers = extractProviderMap(parsed);
+ if (!providers) {
+ return { ok: false, error: 'No providers with models were found in that JSON.' };
+ }
+ const list: PiModelsOverlayProvider[] = [];
+ for (const [id, block] of Object.entries(providers)) {
+ if (!id.trim()) continue;
+ const models = modelEntries(block);
+ if (!models.length) continue;
+ const baseUrl = typeof block.baseUrl === 'string' && block.baseUrl.trim() ? block.baseUrl.trim() : undefined;
+ list.push({ id, modelIds: models.map((m) => m.id), ...(baseUrl ? { baseUrl } : {}) });
+ }
+ if (!list.length) return { ok: false, error: 'No providers with models were found in that JSON.' };
+ return { ok: true, providers: list };
+}
+
+/**
+ * Read a paste or a path (file, Pi home, or `agent/` dir) and preview its
+ * providers. Paths never become Stem's Pi home — they are only a source to copy from.
+ */
+export async function previewPiModelsSource(source: { json?: string; path?: string }): Promise {
+ const loaded = await loadSource(source);
+ if (!loaded.ok) return loaded;
+ return previewPiModelsJson(loaded.raw);
+}
+
+/**
+ * Build the Custom-endpoint settings patch from one provider in the source.
+ * Other providers in the file are ignored.
+ */
+export async function overlayPatchFromSource(
+ source: { json?: string; path?: string },
+ providerId: string,
+ current: LocalProviderSettings
+): Promise<{ ok: true; patch: CustomOverlayPatch } | { ok: false; error: string }> {
+ const loaded = await loadSource(source);
+ if (!loaded.ok) return loaded;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(loaded.raw);
+ } catch {
+ return { ok: false, error: 'That is not valid JSON.' };
+ }
+ const providers = extractProviderMap(parsed);
+ if (!providers) return { ok: false, error: 'No providers with models were found in that JSON.' };
+ const wanted = providerId.trim();
+ const block = providers[wanted];
+ if (!block) return { ok: false, error: `There is no provider named "${wanted}" in that JSON.` };
+ const extras = modelEntries(block);
+ if (!extras.length) return { ok: false, error: `Provider "${wanted}" has no models.` };
+
+ const sourceUrl = typeof block.baseUrl === 'string' ? normalizeLocalBaseUrl(block.baseUrl) : '';
+ const baseUrl = sourceUrl || current.baseUrl.trim();
+ if (!baseUrl) return { ok: false, error: 'Set a URL on the Custom endpoint, or include baseUrl in the JSON.' };
+
+ const api: LocalProviderApi =
+ block.api === 'anthropic-messages' || block.api === 'openai-completions'
+ ? block.api
+ : current.api === 'anthropic-messages' || current.api === 'openai-completions'
+ ? current.api
+ : 'openai-completions';
+
+ const sourceKey = typeof block.apiKey === 'string' ? block.apiKey.trim() : '';
+ // `$ENV` / `!command` forms stay out of settings.json — Pi resolves those in
+ // models.json, and Stem's auth.json needs a non-empty literal. The Enable
+ // path still writes a placeholder when the form key is empty.
+ const literalKey = sourceKey && !sourceKey.startsWith('$') && !sourceKey.startsWith('!') ? sourceKey : '';
+ const apiKey = literalKey || current.apiKey?.trim() || '';
+
+ const providerCompat = asPlainObject(block.compat);
+ const providerHeaders = asStringRecord(block.headers);
+
+ return {
+ ok: true,
+ patch: {
+ enabled: true,
+ baseUrl,
+ api,
+ ...(apiKey ? { apiKey } : { apiKey: '' }),
+ models: extras.map((m) => m.id),
+ preserveModelsConfig: true,
+ modelExtras: extras,
+ ...(providerCompat ? { providerCompat } : { providerCompat: {} }),
+ ...(providerHeaders ? { providerHeaders } : { providerHeaders: {} })
+ }
+ };
+}
+
+async function loadSource(source: { json?: string; path?: string }): Promise<{ ok: true; raw: string } | { ok: false; error: string }> {
+ const json = source.json?.trim();
+ const path = source.path?.trim();
+ if (json && path) return { ok: false, error: 'Paste JSON or give a path, not both.' };
+ if (json) return { ok: true, raw: json };
+ if (!path) return { ok: false, error: 'Paste a models.json or give a path to one.' };
+ const resolved = await resolveModelsPath(path);
+ if (!resolved.ok) return resolved;
+ try {
+ return { ok: true, raw: await readFile(resolved.path, 'utf8') };
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ return { ok: false, error: `Could not read that file. ${msg}` };
+ }
+}
+
+/**
+ * Accept a models.json file, a Pi home (`…/agent/models.json`), or `~` paths.
+ * Does not write, and does not treat the path as Stem's pi-home.
+ */
+export async function resolveModelsPath(raw: string): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
+ const expanded = raw.trim().replace(/^~(?=$|[\\/])/, homedir());
+ if (!expanded) return { ok: false, error: 'Give a path to a models.json file.' };
+ const abs = resolve(expanded);
+ let st;
+ try {
+ st = await stat(abs);
+ } catch {
+ return { ok: false, error: 'That path does not exist.' };
+ }
+ if (st.isFile()) return { ok: true, path: abs };
+ if (!st.isDirectory()) return { ok: false, error: 'That path is not a models.json file.' };
+ const candidates = [join(abs, 'models.json'), join(abs, 'agent', 'models.json')];
+ for (const c of candidates) {
+ try {
+ const cs = await stat(c);
+ if (cs.isFile()) return { ok: true, path: c };
+ } catch {
+ // try the next candidate
+ }
+ }
+ return { ok: false, error: 'No models.json was found in that folder.' };
+}
+
+function extractProviderMap(parsed: unknown): Record | null {
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
+ const rec = parsed as Record;
+ if (rec.providers && typeof rec.providers === 'object' && !Array.isArray(rec.providers)) {
+ return rec.providers as Record;
+ }
+ // A single provider block pasted on its own (has models and/or baseUrl).
+ if (Array.isArray(rec.models) || typeof rec.baseUrl === 'string') {
+ return { custom: rec as PiProviderBlock };
+ }
+ // Bare providers map: { "vllm": { models: [...] }, … }.
+ const values = Object.values(rec);
+ if (
+ values.length &&
+ values.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('models' in v || 'baseUrl' in v))
+ ) {
+ return rec as Record;
+ }
+ return null;
+}
+
+function modelEntries(block: PiProviderBlock): Array & { id: string }> {
+ if (!Array.isArray(block.models)) return [];
+ const out: Array & { id: string }> = [];
+ for (const entry of block.models) {
+ if (typeof entry === 'string' && entry.trim()) {
+ out.push({ id: entry.trim() });
+ continue;
+ }
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
+ const rec = entry as Record;
+ const id = typeof rec.id === 'string' ? rec.id.trim() : '';
+ if (!id) continue;
+ out.push({ ...rec, id });
+ }
+ return out;
+}
+
+function asPlainObject(raw: unknown): Record | undefined {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
+ const rec = raw as Record;
+ return Object.keys(rec).length ? rec : undefined;
+}
+
+function asStringRecord(raw: unknown): Record | undefined {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
+ const out: Record = {};
+ for (const [k, v] of Object.entries(raw as Record)) {
+ if (k.trim() && typeof v === 'string') out[k] = v;
+ }
+ return Object.keys(out).length ? out : undefined;
+}
diff --git a/src/server/pi/runtime.ts b/src/server/pi/runtime.ts
index 4af3cc0..4dd4b42 100644
--- a/src/server/pi/runtime.ts
+++ b/src/server/pi/runtime.ts
@@ -3063,8 +3063,8 @@ export class PiRuntime extends EventEmitter implements ChatBackend {
/**
* Keep the local-provider catalog fresh: re-probe enabled Ollama/LM Studio
* servers at most every 30s so newly pulled models appear without an app
- * restart. (A provider whose model ids were typed by hand isn't probed — see
- * syncModelsConfig.) pi's RPC mode loads models.json once at spawn, so a change
+ * restart. (Hand-entered ids and a locked Custom extras overlay are not
+ * probed — see syncModelsConfig.) pi's RPC mode loads models.json once at spawn, so a change
* needs a process restart — done only when no turn is streaming; otherwise the
* next sync (or any restart) catches up.
*/
diff --git a/src/server/workspace/settings.ts b/src/server/workspace/settings.ts
index d936d0a..a27d6d2 100644
--- a/src/server/workspace/settings.ts
+++ b/src/server/workspace/settings.ts
@@ -165,6 +165,35 @@ function coerceEffort(raw: unknown): string | null {
return typeof raw === 'string' && EFFORT_LEVELS.includes(raw) ? raw : null;
}
+/** Pi model objects: keep entries that have a non-empty string `id`. */
+function coerceModelExtras(raw: unknown): Record[] {
+ if (!Array.isArray(raw)) return [];
+ const out: Record[] = [];
+ for (const entry of raw) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
+ const rec = entry as Record;
+ const id = typeof rec.id === 'string' ? rec.id.trim() : '';
+ if (!id) continue;
+ out.push({ ...rec, id });
+ }
+ return out;
+}
+
+function coercePlainObject(raw: unknown): Record | undefined {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
+ const rec = raw as Record;
+ return Object.keys(rec).length ? rec : undefined;
+}
+
+function coerceStringRecord(raw: unknown): Record | undefined {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
+ const out: Record = {};
+ for (const [k, v] of Object.entries(raw as Record)) {
+ if (typeof k === 'string' && k.trim() && typeof v === 'string') out[k] = v;
+ }
+ return Object.keys(out).length ? out : undefined;
+}
+
const RERANKER_MODES: readonly RerankerMode[] = ['off', 'local', 'remote'];
// Derived from the catalog, not written out by hand: a hand-kept copy silently
// rejected 'qwen3-reranker-0.6b' when it was added everywhere but here, and
@@ -406,12 +435,23 @@ function coerce(parsed: Partial | null): ServerSettings {
// openai-completions pick from an absent field (= not yet configured).
const api: LocalProviderApi | undefined =
id === 'custom' && (r.api === 'anthropic-messages' || r.api === 'openai-completions') ? r.api : undefined;
+ // Overlay extras are custom-only: a hand-edited ollama block cannot lock
+ // Stem's catalog rewrite. Empty arrays/objects drop so disconnect round-trips
+ // to the old shape.
+ const preserve = id === 'custom' && r.preserveModelsConfig === true;
+ const modelExtras = id === 'custom' ? coerceModelExtras(r.modelExtras) : [];
+ const providerCompat = id === 'custom' ? coercePlainObject(r.providerCompat) : undefined;
+ const providerHeaders = id === 'custom' ? coerceStringRecord(r.providerHeaders) : undefined;
return {
enabled: typeof r.enabled === 'boolean' ? r.enabled : def.enabled,
baseUrl: typeof r.baseUrl === 'string' && r.baseUrl.trim() ? r.baseUrl.trim() : def.baseUrl,
...(api ? { api } : {}),
...(apiKey ? { apiKey } : {}),
- ...(models.length ? { models } : {})
+ ...(models.length ? { models } : {}),
+ ...(preserve ? { preserveModelsConfig: true } : {}),
+ ...(modelExtras.length ? { modelExtras } : {}),
+ ...(providerCompat ? { providerCompat } : {}),
+ ...(providerHeaders ? { providerHeaders } : {})
};
};
const localProviders: LocalProvidersSettings = {
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 3246b21..f0db3b2 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -214,6 +214,50 @@ export interface LocalProviderSettings {
* explicitly; empty/absent means "ask the server".
*/
models?: string[];
+ /**
+ * Custom endpoint only. When true, Stem writes `modelExtras` / `providerCompat`
+ * into models.json instead of `{ id }` stubs, and will not drop those extras
+ * on a catalog sync. Cleared on disconnect or when the typed-ID Enable path
+ * replaces the overlay.
+ */
+ preserveModelsConfig?: boolean;
+ /**
+ * Custom endpoint only. Full Pi model objects (id plus reasoning, maxTokens,
+ * thinkingLevelMap, per-model compat, …) copied from a models.json overlay.
+ * Authoritative for the custom catalog when `preserveModelsConfig` is set.
+ */
+ modelExtras?: Record[];
+ /**
+ * Custom endpoint only. Provider-level `compat` copied from the overlay
+ * (thinkingFormat, supportsReasoningEffort, chatTemplateKwargs, …).
+ */
+ providerCompat?: Record;
+ /**
+ * Custom endpoint only. Optional extra HTTP headers from the overlay, written
+ * onto the custom provider block as Pi `headers`.
+ */
+ providerHeaders?: Record;
+}
+
+/** One provider listed from a pasted or linked Pi models.json overlay. */
+export interface PiModelsOverlayProvider {
+ id: string;
+ modelIds: string[];
+ baseUrl?: string;
+}
+
+/** Preview of a Pi models.json (paste or path) before copying onto Custom. */
+export interface PiModelsOverlayPreview {
+ ok: boolean;
+ providers?: PiModelsOverlayProvider[];
+ error?: string;
+}
+
+/** Result of copying a Pi overlay onto Stem's Custom endpoint. */
+export interface PiModelsOverlayCopyResult {
+ ok: boolean;
+ error?: string;
+ status?: RuntimeStatus;
}
export type LocalProvidersSettings = Record;
@@ -2437,6 +2481,20 @@ export interface StemApi {
apiKey?: string,
api?: LocalProviderApi
): Promise;
+ /**
+ * List providers in a pasted Pi models.json or a path to one, so Settings can
+ * copy extras onto Custom. Does not write Stem's Pi home.
+ */
+ previewPiModels(source: { json?: string; path?: string }): Promise;
+ /**
+ * Copy one provider from that overlay onto Stem's Custom endpoint (reasoning,
+ * thinkingFormat, maxTokens, …). Stem's Pi home stays Stem's.
+ */
+ copyPiModels(
+ source: { json?: string; path?: string },
+ providerId: string,
+ hints?: { baseUrl?: string; apiKey?: string; api?: LocalProviderApi }
+ ): Promise;
/** Remove a provider's credentials (or disable a local provider) and refresh the backend. */
disconnectProvider(providerId: string): Promise;
/**
diff --git a/tests/unit/models-config.test.ts b/tests/unit/models-config.test.ts
index 5eb1b28..1df2da8 100644
--- a/tests/unit/models-config.test.ts
+++ b/tests/unit/models-config.test.ts
@@ -408,7 +408,68 @@ describe('syncModelsConfig', () => {
});
});
- it('probes a keyless custom endpoint that names no models', async () => {
+ it('writes full model extras and overlay compat when custom is locked', async () => {
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ providers: {
+ xai: { baseUrl: 'https://leftover.example/v1', models: [{ id: 'grok' }] }
+ }
+ })
+ );
+ const fetchMock = vi.fn(async () => new Response('nope', { status: 404 }));
+ vi.stubGlobal('fetch', fetchMock);
+ use({
+ custom: {
+ enabled: true,
+ baseUrl: 'http://vllm:8000',
+ api: 'openai-completions',
+ preserveModelsConfig: true,
+ models: ['qwen3'],
+ modelExtras: [
+ {
+ id: 'qwen3',
+ reasoning: true,
+ maxTokens: 32768,
+ contextWindow: 131072,
+ compat: { thinkingFormat: 'qwen-chat-template' }
+ }
+ ],
+ providerCompat: {
+ supportsDeveloperRole: false,
+ supportsReasoningEffort: false,
+ thinkingFormat: 'qwen-chat-template'
+ },
+ providerHeaders: { 'x-extra': 'yes' }
+ }
+ });
+ expect(await syncModelsConfig()).toBe(true);
+ expect(fetchMock).not.toHaveBeenCalled();
+ const cfg = readConfig();
+ expect(cfg.providers.xai.models).toEqual([{ id: 'grok' }]);
+ expect(cfg.providers.custom).toEqual({
+ baseUrl: 'http://vllm:8000/v1',
+ api: 'openai-completions',
+ apiKey: 'local',
+ compat: {
+ supportsDeveloperRole: false,
+ supportsReasoningEffort: false,
+ thinkingFormat: 'qwen-chat-template'
+ },
+ headers: { 'x-extra': 'yes' },
+ models: [
+ {
+ id: 'qwen3',
+ reasoning: true,
+ maxTokens: 32768,
+ contextWindow: 131072,
+ compat: { thinkingFormat: 'qwen-chat-template' }
+ }
+ ]
+ });
+ });
+
+ it('still writes id-only custom models when extras are not locked', async () => {
stubModels(['discovered']);
use({ custom: { enabled: true, baseUrl: 'http://box:8000' } });
await syncModelsConfig();
diff --git a/tests/unit/models-copy.test.ts b/tests/unit/models-copy.test.ts
new file mode 100644
index 0000000..8885ea1
--- /dev/null
+++ b/tests/unit/models-copy.test.ts
@@ -0,0 +1,145 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+ overlayPatchFromSource,
+ previewPiModelsJson,
+ previewPiModelsSource,
+ resolveModelsPath
+} from '../../src/server/pi/models-copy';
+import type { LocalProviderSettings } from '../../src/shared/types';
+
+const emptyCustom: LocalProviderSettings = { enabled: false, baseUrl: '' };
+
+const vllmBlock = {
+ baseUrl: 'http://localhost:8000/v1',
+ api: 'openai-completions',
+ apiKey: 'local',
+ compat: {
+ supportsDeveloperRole: false,
+ supportsReasoningEffort: false,
+ thinkingFormat: 'qwen-chat-template'
+ },
+ models: [
+ {
+ id: 'qwen3-32b',
+ reasoning: true,
+ maxTokens: 32768,
+ contextWindow: 131072,
+ compat: { thinkingFormat: 'qwen-chat-template' }
+ },
+ { id: 'glm-4.5', reasoning: true, maxTokens: 16384, compat: { thinkingFormat: 'zai' } }
+ ]
+};
+
+describe('previewPiModelsJson', () => {
+ it('lists providers from a full models.json', () => {
+ const res = previewPiModelsJson(JSON.stringify({ providers: { vllm: vllmBlock, ollama: { models: [{ id: 'llama' }] } } }));
+ expect(res.ok).toBe(true);
+ expect(res.providers).toEqual([
+ { id: 'vllm', modelIds: ['qwen3-32b', 'glm-4.5'], baseUrl: 'http://localhost:8000/v1' },
+ { id: 'ollama', modelIds: ['llama'] }
+ ]);
+ });
+
+ it('treats a single provider block as custom', () => {
+ const res = previewPiModelsJson(JSON.stringify(vllmBlock));
+ expect(res.ok).toBe(true);
+ expect(res.providers).toEqual([
+ { id: 'custom', modelIds: ['qwen3-32b', 'glm-4.5'], baseUrl: 'http://localhost:8000/v1' }
+ ]);
+ });
+
+ it('rejects corrupt JSON without writing', () => {
+ expect(previewPiModelsJson('{not json')).toEqual({ ok: false, error: 'That is not valid JSON.' });
+ });
+
+ it('rejects JSON with no models', () => {
+ expect(previewPiModelsJson(JSON.stringify({ providers: { empty: { baseUrl: 'http://x' } } })).ok).toBe(false);
+ });
+});
+
+describe('overlayPatchFromSource', () => {
+ it('copies a vllm provider onto custom extras and leaves source fields intact', async () => {
+ const res = await overlayPatchFromSource(
+ { json: JSON.stringify({ providers: { vllm: vllmBlock } }) },
+ 'vllm',
+ emptyCustom
+ );
+ expect(res.ok).toBe(true);
+ if (!res.ok) return;
+ expect(res.patch.enabled).toBe(true);
+ expect(res.patch.baseUrl).toBe('http://localhost:8000');
+ expect(res.patch.api).toBe('openai-completions');
+ expect(res.patch.models).toEqual(['qwen3-32b', 'glm-4.5']);
+ expect(res.patch.preserveModelsConfig).toBe(true);
+ expect(res.patch.modelExtras[0]).toMatchObject({
+ id: 'qwen3-32b',
+ reasoning: true,
+ maxTokens: 32768,
+ compat: { thinkingFormat: 'qwen-chat-template' }
+ });
+ expect(res.patch.providerCompat).toEqual(vllmBlock.compat);
+ });
+
+ it('keeps a $ENV apiKey out of settings and uses the form URL when the overlay has none', async () => {
+ const res = await overlayPatchFromSource(
+ {
+ json: JSON.stringify({
+ providers: {
+ vllm: { models: [{ id: 'q' }], apiKey: '$MY_KEY' }
+ }
+ })
+ },
+ 'vllm',
+ { enabled: false, baseUrl: 'http://box:9000', apiKey: 'from-form' }
+ );
+ expect(res.ok).toBe(true);
+ if (!res.ok) return;
+ expect(res.patch.baseUrl).toBe('http://box:9000');
+ expect(res.patch.apiKey).toBe('from-form');
+ });
+
+ it('errors when the named provider is missing', async () => {
+ const res = await overlayPatchFromSource(
+ { json: JSON.stringify({ providers: { vllm: vllmBlock } }) },
+ 'other',
+ emptyCustom
+ );
+ expect(res.ok).toBe(false);
+ });
+});
+
+describe('previewPiModelsSource path', () => {
+ let dir: string;
+ afterEach(() => {
+ if (dir) rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('reads a models.json file and an agent/ folder', async () => {
+ dir = mkdtempSync(join(tmpdir(), 'stem-pi-overlay-'));
+ const file = join(dir, 'models.json');
+ writeFileSync(file, JSON.stringify({ providers: { vllm: vllmBlock } }));
+ const fromFile = await previewPiModelsSource({ path: file });
+ expect(fromFile.ok).toBe(true);
+ expect(fromFile.providers?.[0].id).toBe('vllm');
+
+ const agent = join(dir, 'agent');
+ mkdirSync(agent);
+ writeFileSync(join(agent, 'models.json'), JSON.stringify({ providers: { glm: { models: [{ id: 'glm' }] } } }));
+ const fromHome = await previewPiModelsSource({ path: dir });
+ // dir has models.json at the root, so that wins over agent/
+ expect(fromHome.providers?.[0].id).toBe('vllm');
+
+ const fromAgent = await resolveModelsPath(agent);
+ expect(fromAgent.ok).toBe(true);
+ if (fromAgent.ok) expect(fromAgent.path).toBe(join(agent, 'models.json'));
+ });
+
+ it('fails on a missing path without writing', async () => {
+ const res = await previewPiModelsSource({ path: join(tmpdir(), 'does-not-exist-stem-overlay.json') });
+ expect(res.ok).toBe(false);
+ expect(res.error).toMatch(/does not exist/i);
+ });
+});
diff --git a/tests/unit/settings.test.ts b/tests/unit/settings.test.ts
index 20bfbfd..50aa2f8 100644
--- a/tests/unit/settings.test.ts
+++ b/tests/unit/settings.test.ts
@@ -387,6 +387,56 @@ describe('local provider settings', () => {
});
});
+ it('round-trips copied extras on custom and strips them from ollama', async () => {
+ await updateLocalProvider('custom', {
+ enabled: true,
+ baseUrl: 'http://vllm:8000',
+ api: 'openai-completions',
+ preserveModelsConfig: true,
+ models: ['qwen3'],
+ modelExtras: [
+ { id: 'qwen3', reasoning: true, maxTokens: 32768, compat: { thinkingFormat: 'qwen-chat-template' } }
+ ],
+ providerCompat: { supportsReasoningEffort: false, thinkingFormat: 'qwen-chat-template' },
+ providerHeaders: { 'x-test': '1' }
+ });
+ expect((await readSettings()).localProviders.custom).toEqual({
+ enabled: true,
+ baseUrl: 'http://vllm:8000',
+ api: 'openai-completions',
+ models: ['qwen3'],
+ preserveModelsConfig: true,
+ modelExtras: [
+ { id: 'qwen3', reasoning: true, maxTokens: 32768, compat: { thinkingFormat: 'qwen-chat-template' } }
+ ],
+ providerCompat: { supportsReasoningEffort: false, thinkingFormat: 'qwen-chat-template' },
+ providerHeaders: { 'x-test': '1' }
+ });
+ await updateLocalProvider('custom', {
+ preserveModelsConfig: false,
+ modelExtras: [],
+ providerCompat: {},
+ providerHeaders: {}
+ });
+ expect((await readSettings()).localProviders.custom).toEqual({
+ enabled: true,
+ baseUrl: 'http://vllm:8000',
+ api: 'openai-completions',
+ models: ['qwen3']
+ });
+ await updateLocalProvider('ollama', {
+ enabled: true,
+ baseUrl: 'http://localhost:11434',
+ preserveModelsConfig: true,
+ modelExtras: [{ id: 'nope' }],
+ providerCompat: { thinkingFormat: 'qwen' }
+ } as never);
+ expect((await readSettings()).localProviders.ollama).toEqual({
+ enabled: true,
+ baseUrl: 'http://localhost:11434'
+ });
+ });
+
it('strips the api field for ollama/lmstudio (hand-edited settings.json cannot force it)', async () => {
// Only `custom` may opt into anthropic-messages; a hand-edited settings.json
// trying to set it on ollama must round-trip without the field.
diff --git a/tests/unit/transport-http.test.ts b/tests/unit/transport-http.test.ts
index 32d47f7..2b5d51f 100644
--- a/tests/unit/transport-http.test.ts
+++ b/tests/unit/transport-http.test.ts
@@ -595,6 +595,10 @@ async function collectBlocks(res: Response, count: number): Promise {
event: lines.find((line) => line.startsWith('event: '))?.slice(7) ?? null,
data: JSON.parse(data) as Block['data']
});
+ // One socket read can carry several frames (pushTo then push in the
+ // same tick). Stop at `count` so a coalesced broadcast is not treated
+ // as part of the addressed-frame collection.
+ if (blocks.length >= count) break;
}
split = buffer.indexOf('\n\n');
}