Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions src/renderer/manage/tabs/settings/ModelsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1735,11 +1735,8 @@ function LocalServerAddForm({
const [apiKey, setApiKey] = useState('');
const [models, setModels] = useState('');
const [customName, setCustomName] = useState('');
// API flavor for the Custom endpoint. Ollama/LM Studio ignore it — always
// openai-completions. `null` = auto-detect (default): the Test probe
// classifies which chat route the endpoint exposes and snaps the dropdown to
// that value. Enable requires a concrete flavor; the button stays disabled
// until Test succeeds or the user picks one explicitly.
// Custom endpoints need a concrete flavor; ambiguous detection leaves Enable disabled.
// Ollama/LM Studio always use openai-completions.
const [api, setApi] = useState<LocalProviderApi | null>(null);
const [testing, setTesting] = useState(false);
const [test, setTest] = useState<LocalProviderTestResult | null>(null);
Expand Down Expand Up @@ -1809,10 +1806,7 @@ function LocalServerAddForm({

async function runTest() {
const request = testGateRef.current.begin();
// Auto-detect on Custom sends `undefined` so main runs the OPTIONS probe on
// both chat routes; a concrete pick from the dropdown short-circuits that.
// Ollama/LM Studio always name their flavor: they can't be anything but
// OpenAI-compatible, so sending it spares them two pointless round-trips.
// Explicit flavors bypass classification, including for Ollama/LM Studio.
const probeApi: LocalProviderApi | undefined =
server === 'custom' ? (api ?? undefined) : 'openai-completions';
const tested = localProbeTarget(server, baseUrl, apiKey, probeApi ?? 'openai-completions');
Expand Down Expand Up @@ -1885,7 +1879,11 @@ function LocalServerAddForm({
}

const flavorLabel = (a: LocalProviderApi): string =>
a === 'anthropic-messages' ? 'Anthropic Messages' : 'OpenAI Chat Completions';
a === 'anthropic-messages'
? 'Anthropic Messages'
: a === 'openai-responses'
? 'OpenAI Responses'
: 'OpenAI Chat Completions';
const testLabel = test
? test.ok
? `${test.models?.length ?? 0} model${(test.models?.length ?? 0) === 1 ? '' : 's'} found` +
Expand All @@ -1905,11 +1903,14 @@ function LocalServerAddForm({
knows. <strong>Ollama</strong> and <strong>LM Studio</strong> run on this machine and report their own models
— LM Studio loads one on first use, so its first reply can take a while. <strong>Custom endpoint</strong> is
any other URL: a gateway, a proxy, a server elsewhere on your network. Leave the flavor on{' '}
<em>Auto-detect</em> and Test connection classifies which chat route the endpoint exposes
(<code>/v1/chat/completions</code> vs <code>/v1/messages</code>) before listing its models — or pick a
flavor by hand if the server is picky about that OPTIONS probe. Stem strips a trailing <code>/v1</code>
from your URL and lets the client add the versioned path itself. The key goes on the wire the way the
target API expects (<code>Authorization: Bearer</code> for OpenAI-flavored servers, <code>X-Api-Key</code>
<em>Auto-detect</em> and Test connection checks which routes answer (
<code>/v1/chat/completions</code>, <code>/v1/responses</code>, or <code>/v1/messages</code>) before listing
models. If multiple routes answer, choose the API flavor your model supports explicitly; route
availability does not prove model compatibility. You can also pick a flavor by hand if the server
refuses the OPTIONS probe. A successful test verifies model listing, not a real model turn. Stem strips a
trailing <code>/v1</code> from your URL and lets the client add the versioned path itself. The key goes on
the wire the way the target API expects (<code>Authorization: Bearer</code> for OpenAI-flavored servers,{' '}
<code>X-Api-Key</code>
for Anthropic). Leave the model IDs empty and Stem uses whatever the endpoint lists, re-checked every
half minute like Ollama and LM Studio — the way to add a second Ollama running on another machine.
Type IDs to pin a fixed set instead; endpoints that serve no listing need that.
Expand Down Expand Up @@ -1981,6 +1982,7 @@ function LocalServerAddForm({
>
<option value="">Auto-detect (Test to classify)</option>
<option value="openai-completions">OpenAI Chat Completions</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic-messages">Anthropic Messages</option>
</select>
<input
Expand Down
2 changes: 1 addition & 1 deletion src/server/ipc/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const IPC_ARGS: Record<string, ArgSpec[]> = {
a.string,
a.string,
a.optional(a.nullish(a.string)),
a.optional(a.nullish(a.oneOf(['openai-completions', 'anthropic-messages']))),
a.optional(a.nullish(a.oneOf(['openai-completions', 'openai-responses', 'anthropic-messages']))),
// A paired computer to probe from; null/absent = from the server.
a.optional(a.nullish(a.string))
],
Expand Down
99 changes: 51 additions & 48 deletions src/server/pi/models-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ const DEVICE_PROBE_TIMEOUT_MS = 10_000;
* trailing /v1 (users paste both forms; the API-specific client appends the
* versioned path itself).
*
* Both flavors strip trailing /v1: for openai-completions Stem appends /v1
* before writing (pi then hits /v1/chat/completions), and for anthropic-messages
* pi hands the URL to @anthropic-ai/sdk which appends /v1/messages verbatim.
* Leaving /v1 in either case would double-version the request.
* All flavors strip trailing /v1. Stem re-appends it for OpenAI Chat
* Completions and Responses; for anthropic-messages pi hands the root to
* @anthropic-ai/sdk, which appends /v1/messages verbatim. Leaving /v1 in the
* Anthropic case would double-version the request.
*/
export function normalizeLocalBaseUrl(raw: string): string {
let url = raw.trim().replace(/\/+$/, '');
Expand Down Expand Up @@ -110,11 +110,10 @@ async function isToolCapable(base: string, id: string, timeoutMs: number): Promi
}

/**
* Which chat/messages routes this endpoint exposes. Called by auto-detect to
* pick between openai-completions and anthropic-messages: both flavors serve
* `GET /v1/models` under the same route, so the listing can't tell them apart
* — the real signal is which of `/v1/chat/completions` (OpenAI) and
* `/v1/messages` (Anthropic) the proxy actually implements.
* Which completion/response/messages routes this endpoint exposes. Called by
* auto-detect to pick an API flavor: all flavors serve `GET /v1/models` under
* the same route, so the listing can't tell them apart — the real signal is
* which request route the proxy implements.
*
* Uses OPTIONS to keep the probe cost-free (no completion tokens spent). A
* 404 = route absent; anything else (200/400/401/405/…) = route present and
Expand All @@ -125,29 +124,30 @@ async function isToolCapable(base: string, id: string, timeoutMs: number): Promi
* very thing being classified, and a header the server doesn't recognize is
* ignored rather than rejected.
*/
async function classifyChatRoutes(
async function classifyApiRoutes(
base: string,
apiKey: string | undefined,
timeoutMs: number
): Promise<{ oc: boolean; am: boolean }> {
): Promise<{ oc: boolean; or: boolean; am: boolean }> {
const key = apiKey?.trim();
const headers = { ...probeHeaders('openai-completions', key), ...probeHeaders('anthropic-messages', key) };
const options = { method: 'OPTIONS', headers, signal: AbortSignal.timeout(timeoutMs) };
const [oc, am] = await Promise.allSettled([
const [oc, or, am] = await Promise.allSettled([
fetch(`${base}/v1/chat/completions`, options),
fetch(`${base}/v1/responses`, options),
fetch(`${base}/v1/messages`, options)
]);
return {
oc: oc.status === 'fulfilled' && oc.value.status !== 404,
or: or.status === 'fulfilled' && or.value.status !== 404,
am: am.status === 'fulfilled' && am.value.status !== 404
};
}

/**
* Single-flavor probe: hits GET {base}/v1/models under the flavor's protocol
* expectations (Ollama capability filter for openai-completions, no filter for
* anthropic-messages). Success carries the `api` back so an auto-detect caller
* can attribute the answer.
* expectations (Ollama capability filter only for openai-completions). Success
* carries the `api` back so an auto-detect caller can attribute the answer.
*/
async function probeOneFlavor(
baseUrl: string,
Expand All @@ -170,10 +170,10 @@ async function probeOneFlavor(
.map((m) => (typeof m.id === 'string' ? m.id.trim() : ''))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
// Ollama is keyless and OpenAI-flavored, so any of {bearer key, anthropic
// flavor} means this isn't Ollama — skip the per-model capability probe.
// Ollama is keyless and Chat Completions-flavored, so a bearer key or any
// other API flavor means this isn't Ollama — skip its capability probe.
const capable =
key || api === 'anthropic-messages'
key || api !== 'openai-completions'
? all.map(() => true)
: await Promise.all(all.map((id) => isToolCapable(base, id, timeoutMs)));
const models = all.filter((_, i) => capable[i]);
Expand All @@ -194,15 +194,13 @@ async function probeOneFlavor(
* returned as-is; on success the result carries `api` back so a caller can
* confirm.
*
* `api` omitted (auto-detect): first classify which chat route the endpoint
* exposes (OPTIONS on /v1/chat/completions and /v1/messages — non-404 wins),
* then probe GET /v1/models under that flavor's downstream expectations.
* openai-completions wins ties (a proxy that serves both routes is a matter of
* downstream preference; the current default matches Stem's pre-existing
* behavior). If neither chat route resolves, fall back to a plain
* openai-completions probe of /v1/models — some servers only expose the
* listing and refuse OPTIONS entirely; the user can still enable and test in
* a real turn.
* `api` omitted (auto-detect): first classify which API route the endpoint
* exposes (OPTIONS on /v1/chat/completions, /v1/responses, and /v1/messages —
* non-404 counts as present), then probe GET /v1/models if exactly one flavor
* is detected. Multiple routes require an explicit flavor selection: route
* presence cannot establish which protocol a particular model supports.
* If no route resolves, retain the pre-existing openai-completions listing
* fallback; the user can select another flavor and test in a real turn.
*
* Never throws — failures come back as `{ ok:false, error }`.
*/
Expand All @@ -215,17 +213,19 @@ export async function probeLocalProvider(
if (api) return probeOneFlavor(baseUrl, apiKey, api, timeoutMs);
const base = normalizeLocalBaseUrl(baseUrl);
if (!/^https?:\/\//i.test(base)) return { ok: false, error: 'Enter a URL like http://localhost:11434.' };
const routes = await classifyChatRoutes(base, apiKey, timeoutMs);
// openai-completions wins ties: pre-existing default and more common.
const detected: LocalProviderApi | null = routes.oc
? 'openai-completions'
: routes.am
? 'anthropic-messages'
: null;
if (detected) return probeOneFlavor(baseUrl, apiKey, detected, timeoutMs);
// Neither OPTIONS resolved (server rejects OPTIONS, or both 404). Fall back
// to a plain openai-completions listing probe — same behavior as pre-flavor.
return probeOneFlavor(baseUrl, apiKey, 'openai-completions', timeoutMs);
const routes = await classifyApiRoutes(base, apiKey, timeoutMs);
const detected: LocalProviderApi[] = [];
if (routes.oc) detected.push('openai-completions');
if (routes.or) detected.push('openai-responses');
if (routes.am) detected.push('anthropic-messages');
if (detected.length > 1) {
return {
ok: false,
error: `The endpoint exposes multiple API routes (${detected.join(', ')}). Select an API flavor explicitly and test again.`
};
}
// Keep the legacy listing fallback when no route can be classified.
return probeOneFlavor(baseUrl, apiKey, detected[0] ?? 'openai-completions', timeoutMs);
}

/**
Expand Down Expand Up @@ -278,18 +278,17 @@ function localProviderBlock(
modelOverrides: Record<string, ModelOverride>
): PiProviderConfig {
const normalized = normalizeLocalBaseUrl(baseUrl);
// openai-completions: append /v1 so pi hits /v1/chat/completions (matches the
// Ollama/LM Studio flow that always did it).
// OpenAI flavors: append /v1 so pi hits /v1/chat/completions or /v1/responses
// (matches the Ollama/LM Studio flow that always did it).
// anthropic-messages: leave the normalized root as-is — pi's Anthropic client
// (@anthropic-ai/sdk) appends /v1/messages verbatim to whatever baseURL it
// receives. Appending /v1 here would produce /v1/v1/messages at request time.
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.
// Chat Completions servers get conservative local-server defaults. Responses
// and Anthropic providers use pi's native protocol defaults; proxy-specific
// changes can still be supplied through per-model compat overrides.
const compat: Record<string, unknown> =
api === 'anthropic-messages' ? {} : { supportsDeveloperRole: false, supportsReasoningEffort: false };
api === 'openai-completions' ? { supportsDeveloperRole: false, supportsReasoningEffort: false } : {};
return {
baseUrl: finalBaseUrl,
api,
Expand Down Expand Up @@ -484,9 +483,13 @@ export function syncModelsConfig(): Promise<boolean> {
const parsed = parseModelOverrides(settings[id].modelOverrides);
if (!parsed.ok) log('pi', `ignoring invalid model overrides for ${id}`, { errors: parsed.errors });
const overrides = parsed.ok ? parsed.value : {};
// Only custom endpoints may speak anthropic-messages; the coercion enforces this too,
// this is defense in depth against a hand-edited settings.json.
const api: LocalProviderApi = isCustomProviderId(id) && rawApi === 'anthropic-messages' ? 'anthropic-messages' : 'openai-completions';
// Only custom endpoints may select Responses or Anthropic Messages; the
// coercion enforces this too, and this is defense in depth against a
// hand-edited settings.json.
const api: LocalProviderApi =
isCustomProviderId(id) && (rawApi === 'openai-responses' || rawApi === 'anthropic-messages')
? rawApi
: 'openai-completions';
// 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).
Expand Down
15 changes: 9 additions & 6 deletions src/server/workspace/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,14 +627,17 @@ function coerce(parsed: Partial<ServerSettings> | null): ServerSettings {
const models = Array.isArray(r.models)
? r.models.filter((m): m is string => typeof m === 'string' && !!m.trim()).map((m) => m.trim())
: [];
// API flavor: only custom endpoints may opt into anthropic-messages. Ollama/LM Studio
// are always openai-completions — a hand-edited settings.json cannot switch
// them; the field would be ignored downstream anyway. Persist the flavor
// verbatim (both `openai-completions` and `anthropic-messages`) so a saved
// settings.json is self-describing and future debugging can tell an explicit
// API flavor: only custom endpoints may opt into Responses or Anthropic
// Messages. Ollama/LM Studio are always openai-completions — a hand-edited
// settings.json cannot switch them; the field would be ignored downstream
// anyway. Persist the flavor verbatim so a saved settings.json is
// self-describing and future debugging can tell an explicit
// openai-completions pick from an absent field (= not yet configured).
const api: LocalProviderApi | undefined =
custom && (r.api === 'anthropic-messages' || r.api === 'openai-completions') ? r.api : undefined;
custom &&
(r.api === 'anthropic-messages' || r.api === 'openai-responses' || r.api === 'openai-completions')
? r.api
: undefined;
// Per-model overrides: custom endpoints only, coerced only as far as their shape —
// an entry that fails the deeper guard (pi/model-overrides.ts) is KEPT here
// and dropped at sync time instead. Losing the text on read is the exact
Expand Down
8 changes: 4 additions & 4 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,11 +265,11 @@ export type LocalProviderId = BuiltInLocalProviderId | CustomProviderId;

/**
* Which API flavor the custom endpoint speaks. Ollama and LM Studio are always
* OpenAI-compatible; only custom endpoints may set this to `anthropic-messages` (for
* Anthropic's Messages API or a proxy that speaks it). Default (absent) is
* `openai-completions` — matches the pre-existing single-flavor behavior.
* OpenAI-compatible; custom endpoints may instead select OpenAI's Responses API
* or Anthropic's Messages API (or a proxy speaking either protocol). Default
* (absent) is `openai-completions` — the pre-existing single-flavor behavior.
*/
export type LocalProviderApi = 'openai-completions' | 'anthropic-messages';
export type LocalProviderApi = 'openai-completions' | 'openai-responses' | 'anthropic-messages';

/**
* What one model of a custom endpoint needs said about it that its `/v1/models`
Expand Down
Loading
Loading