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 }) {
+ {confirmDisconnect && selected === 'custom' && ( +

+ This removes the copied extras from Stem. Your own files are not changed.{' '} + + +

+ )} + + {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.