Skip to content

feat: connect local llama.cpp, vLLM, Ollama and LM Studio servers from Settings - #910

Open
Anatoly (Till) Lazarev (lazarevtill) wants to merge 2 commits into
JetBrains:mainfrom
lazarevtill:upstream/local-models
Open

Anatoly (Till) Lazarev (lazarevtill) wants to merge 2 commits into
JetBrains:mainfrom
lazarevtill:upstream/local-models

Conversation

@lazarevtill

@lazarevtill Anatoly (Till) Lazarev (lazarevtill) commented Oct 26, 2026 •

Copy link
Copy Markdown

Problem

ThinkRail can only use providers pi already knows how to sign in to. Running a model on your own hardware (llama.cpp, vLLM, Ollama, LM Studio, or any OpenAI-compatible gateway) means hand-editing ~/.pi/agent/models.json. pi's built-in /login llama.cpp extension isn't loaded in ThinkRail, so even llama.cpp has no in-app path. And hand-editing still leaves the parts that make a local server usable for agent work out of reach:

  • the context window the server actually runs with, so pi compacts before the server truncates;
  • an output budget that leaves room for the prompt (vLLM rejects prompt + max_tokens over --max-model-len);
  • local-server compat flags (no developer role, no store, max_tokens);
  • a way to tell whether the model can call tools at all, which every agent turn depends on.

Approach

A form over pi's models.json, not a second registry. Each server is a providers.local-<slug> entry with api: "openai-completions", and everything the feature saves lives in that one file. pi keeps serving, auth and catalog ownership, and the pi CLI sees the same models. Writes reuse the modelContext writer: JSONC edits that preserve comments and BOM, temp-file + rename, symlink-following. That writer is now a shared modelsFile module, and both adapters serialize on one write chain.

Only local-* providers are listed. Hand-written providers (a cloud gateway, say) are neither listed nor edited, so local compat flags never land on them and pi keeps serving them unchanged.

Detection reads what the server reports. GET <base>/models with a 5 s timeout, plus per-kind extras:

  • vLLM: max_model_len;
  • llama.cpp: /props for the per-slot n_ctx, chat_template_caps.supports_tools and the enable_thinking template;
  • Ollama: /api/show for num_ctx and the tools / thinking / vision capabilities;
  • LM Studio: /api/v0/models for the loaded context and tool_use.

Only the context a server runs with counts; training lengths are ignored. When a server reports none, the dialog fills an editable default and says so. A tools badge shows whether the server says a model takes tool calls.

The server kind is a form-only choice. It drives Detect, URL normalization (/v1) and the defaults, and isn't saved. An edit guesses it from the saved data (a model using the chat-template thinking format means llama.cpp/vLLM) and then the URL, so renaming a server never strips its thinking settings; an unchanged base URL is kept exactly as written, and switching to a kind that doesn't use the chat-template thinking format clears thinkingFormat / thinkingLevelMap. Compat on create mirrors pi's own llama.cpp provider; an edit keeps the provider's existing compat.

Keys. A typed probe key follows pi's $VAR / ${VAR} / $$ template rules but never runs a !command. A saved key is reused for Detect only for that endpoint's own unchanged URL, and is resolved by pi itself; a keyed probe refuses redirects. A save that moves an endpoint to another address asks for the key again instead of carrying it along. Keys never cross the wire (hasApiKey only). URLs carrying credentials or a query string are refused.

Request concurrency, timeouts and retries are left to the servers (llama.cpp slots, Ollama's queue, vLLM batching) and pi's own provider retry settings.

Trade-offs:

  • Only openai-completions is supported. Every local server targeted here speaks it.
  • Local servers don't appear in the Providers "Connected" list; they live in their own group.

Changes

  • Contracts (protocol v99): localModels.list / probe / save / remove; types LocalModelEndpoint (with chatTemplateThinking for the edit-time kind guess), LocalModelEndpointDraft, LocalModelProbe; validators for context (1K–10M) and max output (≥256 and ≤ context).
  • Server: agent/modelsFile (extracted shared IO) and agent/localModels; setModelContextPublisher → setModelsFilePublisher. A save clears the fields the form manages from that model's modelOverrides, so a stale override can't shadow it.
  • Web: LocalModelsSettings under Settings → Providers: an endpoint list with Edit and Remove, and an Add/Edit dialog with server kind, name, URL, key (with "remove saved key" on edit), Detect models, and per-model context / max output / thinking. Re-detecting keeps edited values. English and zh-CN strings.
  • Docs: server/agent, server/host, contracts and web/panels SPECs, the README capability list, and a pi-compatibility.md entry for the mirrored key-template rules.
  • Tests: unit tests for URL normalization and refusal, key interpolation and saved-key reuse, probe parsing per kind (runtime vs. training context), save/edit/remove round trips (comments, BOM and other config kept), local-* scoping (including a hand-written provider's key never reaching Detect), unchanged URLs on edit, kind changes, override clearing, key removal, a key not following a changed address, redirect refusal and validation; web unit tests for the edit-time kind guess; e2e/settings-local-models.spec.ts drives a fake vLLM server through detect (including an unreported context that stays flagged across a re-detect) → re-detect → save → edit → chat in a workspace → remove.

Screenshots

Settings → Providers, same scenario (fresh e2e lane) and viewport (1280×1000):

Before After
before after

Add dialog after Detect models against a vLLM-compatible server:

add dialog

The saved server in the Local models group:

saved server

Related issues

Closes #436. It covers adding, editing and removing a self-hosted endpoint from Settings without touching the filesystem, selecting its models in a chat, and endpoint probing (listed in the issue as a follow-up). Gaps against the issue as written:

  • only the openai-completions API is offered;
  • providers already hand-written in models.json aren't imported into the list (pi still serves them);
  • endpoints show in their own Local models group rather than as Connected provider cards.

Checklist

  • Fast gates pass: bun run lint, bun run typecheck, every check:*, and bun run test on 8c04e3b (rebased on main at df7cb6d, protocol v99). The one bun run test failure, "a read-only models.json is refused", also fails on main here (root ignores chmod 0o400).
  • E2E suite passes for app-affecting changes (bun run e2e): 650 passed on 8c04e3b. Of the 7 failures, extension-dialogs:178 and model-picker:53 pass on re-run; settings-model-context:321 (root ignores chmod) and four topbar-review tests (local git push is blocked) also fail on main in my sandbox. No agent behaviour changed, so e2e:full was not run.
  • Before/after screenshots are included for frontend changes, or marked not applicable
  • Relevant SPEC.md / top-level specs updated to reflect any boundary, contract, or behavior change
  • I have read the Contributing guide and agree to the Code of Conduct

🤖 Generated with Claude Code

https://claude.ai/code/session_01FX43RF3RbesYkzMAXQdHf4

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The approach is solid: pi's models.json as the single source of truth, a sidecar only for what pi can't hold, and limits applied once at the runtime dispatch level so every caller is covered.

I'd like a few things addressed before merge, mostly around timeouts, the probe key and write consistency. Details are inline, marked Must fix / Should fix / Nit.

setTests((current) => ({ ...current, [key]: { running: true } }));
const settle = (next: TestState) => setTests((current) => ({ ...current, [key]: next }));
await getTransport()
.request("localModels.test", { id: endpoint.id, modelId: model.id })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must fix: Test times out in the UI after 60 s. localModels.test uses the transport's default 60 s timeout, while testLocalModel on the server is budgeted for max(180 s, timeoutSeconds) × (retries + 1). A model that cold-loads for ~90 s (common with llama.cpp / Ollama) is reported as failed, while the server keeps running the check and holding a gate slot. Please pass a timeoutMs that covers the server budget.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d631bd7. Test now passes timeoutMs: localModelTestBudgetMs(endpoint) + 15 s to localModels.test. The budget helper lives in contracts, so the client and host use the same number and the client always outlasts the host. In 7b0d8ea, Test also returns skipped right away (shown as "busy") when every slot is in use, instead of queueing behind a running turn.

export function limitedFetch(providerId: string, base: Fetch = globalThis.fetch): Fetch {
const gate = gateFor(providerId);
const wrapped = (async (input: Parameters<Fetch>[0], init?: Parameters<Fetch>[1]) => {
const release = await gate.acquire(init?.signal);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must fix: queue wait counts against the request timeout. The gate waits inside the fetch handed to the OpenAI SDK, and the SDK arms its timeout before calling fetch. With Parallel = 1 and Timeout = 60 s, a compaction or subagent request queued behind a 2-minute generation is aborted while still in gate.acquire, then retried by retryProviderRequest, which puts it back at the end of the queue. The server was never slow, but the request fails. The slot should be acquired outside the timed section, or the timeout should start after acquire.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d631bd7. With a parallel limit, the timeout moved into the limited fetch and starts only after a slot is acquired. The SDK's own timeout is set to the maximum timer, so queue time never counts and a queued request is never aborted and re-queued by a retry. 7b0d8ea adds an idle timer between streamed chunks, so a server that stops mid-stream frees its slot. Tests: "queue wait does not count against an endpoint's request timeout" and "a queued request can be aborted and a stalled or cancelled body frees its slot".

Comment on lines +253 to +256
const rawKey =
request.apiKey === undefined && request.endpointId
? readEndpointKey(request.endpointId)
: request.apiKey;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must fix: Detect sends the saved key to an edited URL. When editing an endpoint without re-entering the key, the UI sends { endpointId } (LocalModelsSettings.tsx:267) and this attaches that provider's saved key to whatever baseUrl the request carries. Changing the URL (or a typo) and clicking Detect sends the real bearer key to the new host. readEndpointKey also reads any models.json provider, not only local endpoints. Suggest reusing the saved key only when the normalized baseUrl matches the saved one, and only for providers returned by readLocalEndpoints.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d631bd7. The saved key is reused only when endpointId names a listed local endpoint and the normalized URL equals its saved baseUrl; any other URL is probed without a key. 7b0d8ea resolves that key through pi (runtime.getAuth) with a closed error that never echoes the key, and refuses URLs carrying user:pass@ or a query string. Tests: "a saved key is reused only for the endpoint's own unchanged URL", "a saved key pi cannot resolve fails Detect without exposing the key".

Comment on lines +453 to +458
if (next !== source) writeModelsFile(path, next);
result.settings();
if (next !== source) {
await runtime.refresh({ allowNetwork: false });
publishModelsFileChange();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must fix: models.json and the sidecar are written non-atomically. models.json is written first, then result.settings() writes the sidecar. If the sidecar write throws, runtime.refresh and publishModelsFileChange are skipped even though the provider is already on disk: the runtime is stale, other clients aren't notified, and a retry creates a duplicate (local-x-2). Either write the sidecar first, or make sure refresh/publish still run (e.g. try/finally) once models.json has changed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d631bd7. The sidecar is written first and models.json second. If the models.json write fails, the previous sidecar entry is restored, so a retry can't add a duplicate provider. 7b0d8ea also refuses to overwrite a sidecar that exists but isn't valid JSON. Tests: "a failed settings write leaves models.json untouched, so a retry adds no duplicate", "an unreadable settings file is never overwritten".

const previous = known.get(model.id);
const contextWindow =
model.contextWindow ?? previous?.contextWindow ?? LOCAL_MODEL_CONTEXT_LIMITS.fallback;
const keepEdits = previous !== undefined && !previous.detected;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: re-detect discards edited context / max output. keepEdits is true only when the previous row was not detected. After the first Detect, rows the server reported have detected: true, so a second Detect resets the user's edited values. The panels SPEC says edited context and selection survive a re-detect.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d631bd7. Each row carries an edited flag, set on any context or max-output edit and on rows loaded from a saved endpoint, and re-detect keeps edited values. The e2e spec now edits max output to 8192, re-detects, and checks the value stays.

function editDraft(endpoint: LocalModelEndpoint): Draft {
return {
id: endpoint.id,
kind: endpoint.kind ?? guessKind(endpoint.baseUrl),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: editing a hand-written provider can rewrite its base URL. For a provider with kind: null, the kind is guessed from the port, and on save normalizeLocalBaseUrl appends /v1 for any kind other than openai-compatible. Opening Edit on http://gpu:8000/openai just to change the parallel limit saves http://gpu:8000/openai/v1. Defaulting unknown entries to openai-compatible, or not re-normalizing an unchanged URL, would avoid this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed differently in 7b0d8ea. The list now shows only providers ThinkRail created (they have a sidecar entry with their kind), so a hand-written provider is no longer listed, editable or removable here, and pi keeps serving it untouched. Our own endpoints open on their saved kind, and an unchanged baseUrl is kept exactly as written. Test: "hand-written providers stay out of the list and cannot be edited or removed".

Comment on lines +393 to +413
if (thinking) {
next.thinkingLevelMap = {
off: "off",
minimal: null,
low: null,
medium: "medium",
high: null,
xhigh: null,
};
next.compat = {
...(isObject(previous?.compat) ? previous.compat : {}),
thinkingFormat: "qwen-chat-template",
};
} else if (!model.reasoning) {
delete next.thinkingLevelMap;
if (isObject(next.compat)) {
const { thinkingFormat: _dropped, ...rest } = next.compat;
if (Object.keys(rest).length > 0) next.compat = rest;
else delete next.compat;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: stale thinking config after changing the server kind. With reasoning on and a kind outside the chat-template kinds (Ollama, LM Studio, OpenAI-compatible), neither branch runs, so a previously written thinkingLevelMap and compat.thinkingFormat: "qwen-chat-template" survive. Switching an endpoint from llama.cpp to Ollama keeps the llama.cpp-specific thinking params.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d631bd7. For a kind outside llama.cpp/vLLM, or with reasoning off, the save drops the thinkingFormat: "qwen-chat-template" and level map it wrote, and keeps other compat keys. Test: "changing a thinking endpoint's kind drops the chat-template thinking settings".

Comment on lines +84 to +88
export function resolveProbeKey(apiKey: string | undefined): string | undefined {
const key = apiKey?.trim();
if (!key || key === NO_KEY || key.startsWith("!")) return undefined;
const env = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/.exec(key);
if (env?.[1]) return process.env[env[1]] || undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: probe key resolution differs from pi. This handles only a whole-string $VAR / ${VAR}; pi's resolveConfigValue also interpolates templates and unescapes $$. Keys like ${PREFIX}-${TOKEN} work in chat but fail Detect with 401. Could this reuse pi's resolver (skipping !command)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d631bd7. pi doesn't export resolveConfigValue, so resolveProbeKey mirrors its template rules: $VAR / ${VAR} anywhere in the value, $$ and $! escapes, and a missing variable means no key. One intended difference: Detect never runs a typed !command. 7b0d8ea records this copy in pi-compatibility.md with a removal gate (a public pi export with a no-command mode). Test: "probe keys interpolate env references like pi and never run commands".


/** Applies the endpoint's saved limit, timeout and retries; saved values win over pi's global settings. */
function withEndpointOptions<T extends RequestOptions>(providerId: string, options: T): T {
const settings = readLocalEndpointSettings()[providerId];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this does a sync statSync read of the sidecar on every stream/complete call, for every provider including cloud ones, and Gate.drain re-reads it per queued entry. An in-memory snapshot updated by writeLocalEndpointSettings would keep this off the hot path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d631bd7. The sidecar is read once into an in-memory snapshot that its own writer replaces, so the per-request lookup never touches the disk.

Comment on lines +139 to +143
/**
* Routes every model request of `runtime` through the per-endpoint options saved for local servers —
* the parallel-request limit (a per-request `fetch`), the request timeout and provider retries. Every
* pi caller — agent turns, subagents, compaction and one-shot completions — dispatches through these.
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: per AGENTS.md, comments should be near-zero with rationale in the SPEC. This block (which also sits above DISPATCH rather than installRequestLimits) and the multi-line JSDoc in localModels.ts, localModelSettings.ts and modelsFile.ts duplicate what's already in agent/SPEC.md.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d736164. The narrative comments are gone from the local-model modules; the rationale lives in the server/agent SPEC.md.

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — please address the blocking inline findings.

slots: positiveInt(props.total_slots),
models: models.map((model) => ({
...model,
contextWindow: model.contextWindow ?? context,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking:
Problem: Detection treats training context limits as the server's running context.
Failure scenario: With llama.cpp reporting meta.n_ctx_train: 131072 and /props reporting per-slot n_ctx: 8192, parseModelList already supplies a non-null context, so this line discards 8192. Ollama enrichment has the same problem: it uses model_info.*.context_length even when the model's parameters specify num_ctx 8192. Both probes reproduce a detected context of 131072. Saving those defaults lets pi retain prompts beyond the server's limit, causing truncation or rejection.
Suggested fix: Prefer reported runtime limits over training metadata; leave the context unknown/editable when the actual runtime limit cannot be established.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e17b4ab. Detection now uses only the context the server runs with: llama.cpp /props per-slot n_ctx (or meta.n_ctx / a router's -c when present), Ollama num_ctx from /api/show parameters, LM Studio loaded_context_length, and vLLM max_model_len. Training lengths (n_ctx_train, *.context_length, max_context_length) are ignored. When nothing is reported, the field holds an editable default and the dialog says the server didn't report it. Unit tests cover both of your scenarios (131072 training vs. 8192 runtime) plus LM Studio, and the e2e spec checks the note.

Comment thread packages/server/src/agent/localModels.ts
Comment thread packages/server/src/agent/localModels.ts
@rsolmano

Copy link
Copy Markdown
Collaborator

Thanks for the thorough work here. We'd like to take this in a simpler form. Keeping the endpoint's whole config in pi's models.json is the right call. The part we'd rather not carry is the request-limits layer.

Proposal: narrow this PR to a form over models.json.

Keep:

  • the Add/Edit form: kind, URL, key, model list;
  • Detect models (/models plus the per-kind extras /props, /api/show, /api/v0/models), including the tools badge;
  • the per-kind compat defaults, context window, max output and thinking;
  • the shared modelsFile writer extraction.

Drop:

  • local-models.json, localModelSettings and requestLimits / installRequestLimits, together with the parallel-requests, timeout and retries fields, the contract validators and the locale strings;
  • the Test button. Detect already reports tool support, which is the signal that matters most.

Why drop the limits:

  • Ollama (OLLAMA_NUM_PARALLEL / OLLAMA_MAX_QUEUE) and llama.cpp (slots) queue excess requests themselves, and vLLM batches them.
  • pi's SDK defaults (10 min timeout, 2 retries on 429/5xx) already cover slow loads and 503-while-loading.
  • Wrapping the dispatch methods of pi's runtime is a host workaround around pi. It would need a pi-compatibility.md entry with a removal gate. It's also where most of the open findings come from: the queue wait counted against the timeout, the two-file write, saved settings overriding options the caller passed explicitly (e.g. the cache warmer's maxRetries: 0), and retries multiplying with pi's own turn and compaction retries.
  • If a real need shows up later, the right home is upstream in pi, e.g. a per-provider field in models.json.

Still needed in the narrowed version:

  • Key leak on Detect: reuse the saved key only when the normalized baseUrl matches the saved one, and only for endpoints this feature owns. See the existing inline comment.
  • Scope the list to owned entries: treat only local-* providers as editable "Local models". Show other hand-written openai-completions providers read-only, or not at all. Today, editing one merges local compat flags (supportsStore: false, no developer role, max_tokens) into a cloud gateway and can append /v1 to its URL.
  • Server kind without the sidecar: the kind becomes a form-only choice that drives Detect and the defaults. Don't re-normalize a baseUrl that didn't change, and clear thinkingLevelMap / thinkingFormat when the new kind doesn't use the chat-template format.
  • Re-detect keeps the user's edits to context and max output, as the panels SPEC says.
  • Probe key resolution: match pi's resolver (templates, $$), skipping !command.
  • Near-zero comments: keep the rationale in agent/SPEC.md only.
  • Docs: trim the SPEC, README and contracts changes to match the smaller surface.

With the limits and the sidecar gone, the timeout-in-queue, non-atomic-write and Test-timeout findings no longer apply, and no pi-compatibility.md entry is needed. Happy to review the slimmed-down version.

@lazarevtill
Anatoly (Till) Lazarev (lazarevtill) force-pushed the upstream/local-models branch 2 times, most recently from e17b4ab to 5109a87 Compare October 27, 2026 14:27
@lazarevtill

Anatoly (Till) Lazarev (lazarevtill) commented Oct 27, 2026 •

Copy link
Copy Markdown
Author

Thanks, makes sense. I've split it: this PR is now the form over models.json you described, squashed into one commit on current main (857e782, rebased again after #869 and the Japanese catalog). Since main now uses v93, the methods are at protocol v94. The request limits and Test stay in my fork, where I run them on top of this branch.

Dropped from this PR:

  • local-models.json, localModelSettings and requestLimits / installRequestLimits, with the parallel requests, timeout and retries fields, their validators and strings;
  • localModels.test and the Test button;
  • the request-limits entry in pi-compatibility.md.

Kept: the Add/Edit form, Detect with the per-kind extras and the tools badge, the per-kind compat defaults, context / max output / thinking, and the modelsFile extraction.

Your "still needed" list:

  • Key on Detect: the saved key is reused only for a listed local-* endpoint whose saved baseUrl, normalized for the probe's kind, equals the probed URL. It's resolved through pi's getAuth; any other URL is probed without a key.
  • Owned entries only: only local-* providers are listed. Hand-written providers aren't shown or edited, so local compat never lands on a cloud gateway.
  • Kind without the sidecar: the kind is form-only. An edit guesses it from the URL (/v1 on a server's default port, otherwise OpenAI-compatible). An unchanged baseUrl is kept as written whatever kind is picked, and thinkingLevelMap / thinkingFormat are cleared when the new kind doesn't use the chat-template format.
  • Re-detect keeps edited context and max output.
  • Probe key follows pi's resolveConfigValue rules (templates, $$, $!) and never runs !command. I checked it against pi 1.4.
  • Comments: near-zero; the rationale is in agent/SPEC.md.
  • Docs: SPECs, README and contracts trimmed to the smaller surface.

One thing I kept: a short pi-compatibility.md entry for the probe-key rules, because they copy pi's resolveConfigValue, which pi 1.4 still doesn't export. Happy to drop it if you'd rather not track that there.

Two fixes from the bot review also stay. Detection uses only the context the server runs with, not training lengths. A save clears the fields it manages from that model's modelOverrides, so a stale override can't shadow it.

For context on why I keep the limits in my fork: llama.cpp splits -c across -np slots, so on a single-GPU box I'd rather keep one full-context slot and queue chats, subagents and compaction on the client. I agree the right home for that is pi, as a per-provider field in models.json, and I'll raise it there. The threads about timeouts, the queue, the two-file write and the Test timeout no longer apply to this PR.

@lnsane

Copy link
Copy Markdown

Can you speed up this process? It's not working.

…servers from Settings

Add a Local models group to Settings -> Providers that edits pi's
models.json directly: an Add/Edit form (server kind, name, URL, key,
per-model context, max output and thinking) and Detect models, which
reads /models plus the per-kind extras (/props, /api/show,
/api/v0/models) for the running context and a tools badge.

- Only local-* providers are listed; hand-written ones stay untouched
- The server kind is form-only; an unchanged base URL is kept as written
- Detect reuses a saved key only for the endpoint's own unchanged URL
- Extract the shared models.json writer into modelsFile
- Protocol v99: localModels.list / probe / save / remove

Closes JetBrains#436

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX43RF3RbesYkzMAXQdHf4
…, tighten Detect

- Report chatTemplateThinking per endpoint so the edit dialog guesses the
  form-only kind from saved data; renaming a llama.cpp/vLLM server on a
  non-default port no longer strips its thinking settings
- Refuse a save that moves an endpoint to another origin while keeping its
  saved key; refuse redirects on keyed probes; name non-JSON replies
- Prefer llama.cpp's per-slot /props context over the listing and router -c
- Validate model flags and the endpoint id; trim a trailing dash from slugs
- Keep the unreported-context note across re-detects until that field is
  edited; keep an unchanged saved URL as written after Detect
- Label the remove-key checkbox on its own, use a fieldset for the server
  kind, keep Detect from reusing a key marked for removal
- Drop the removed sidecar's leftover test and SPEC line; correct SPEC details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX43RF3RbesYkzMAXQdHf4
@lazarevtill

Copy link
Copy Markdown
Author

Olga Lavrichenko (@OLavrik) thanks for rebasing this. main moved again (protocol 98), so I rebased your commit onto it: local models are now v99. While doing that I re-reviewed the whole change and pushed one follow-up commit (8c04e3b) on top:

  • Edit no longer strips thinking. Since the kind isn't saved, an edit used to guess it from the port alone. Renaming a llama.cpp/vLLM server on a non-default port therefore dropped its qwen-chat-template thinking settings, and a generic server on :8000 could gain them. The list now reports chatTemplateThinking, and the dialog guesses from that before the URL. Web unit tests cover the guess.
  • Keys:
    • A save that moves an endpoint to another address asks for the key again instead of carrying the saved one along.
    • A keyed Detect refuses redirects.
    • Detect no longer reuses a key that's marked for removal.
  • Detect:
    • llama.cpp's per-slot /props context wins over the listing's n_ctx and the router's -c.
    • A non-JSON reply is reported as such instead of "connection failed".
  • Smaller fixes:
    • The model flags and endpoint id are validated.
    • The "context not reported" note survives a re-detect.
    • The remove-key checkbox has its own label, and the server kind is a fieldset.
    • The leftover sidecar test line is dropped, and the SPEC details are corrected.

The rebase also caught two version leftovers: the SPECs and the commit message said v94 while the code was at v95.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a custom AI endpoint (models.json provider) form to Providers settings

4 participants