From 455f8578c31991d7a3536842fccb9e87c2eab563 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 31 Aug 2026 13:29:15 -0700 Subject: [PATCH 1/3] Add first-class Ollama provider --- docs/IMPLEMENTATION.md | 17 +++ docs/PRODUCT.md | 4 +- docs/TUI.md | 19 ++- .../src/providers.test.ts | 9 ++ .../first-class-providers/src/providers.ts | 9 ++ packages/first-class-providers/src/types.ts | 2 +- src/config.test.ts | 17 ++- src/config/index.ts | 5 +- src/provider/models-endpoint.ts | 4 +- src/provider/ollama.test.ts | 80 ++++++++++++ src/provider/ollama.ts | 90 +++++++++++++ src/subagent/run-source.test.ts | 25 ++++ src/tui/provider-connect.ts | 4 + src/tui/provider-setup-submit.test.ts | 43 +++++++ src/tui/provider-setup-submit.ts | 18 ++- src/tui/provider-setup.test.ts | 113 ++++++++++++++++- src/tui/provider-setup.ts | 119 +++++++++++++++++- 17 files changed, 557 insertions(+), 21 deletions(-) create mode 100644 src/provider/ollama.test.ts create mode 100644 src/provider/ollama.ts create mode 100644 src/subagent/run-source.test.ts diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 92e118bfd..8cd03cb32 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -282,6 +282,23 @@ OpenAI-compatible `baseURL` values are normalized during provider resolution. A `--config` composes with, rather than replaces, the home-level OAuth profile catalog: codex/xai credentials live in `~/.corbits/codex-auth.json` and `xai-auth.json`, entirely separate from settings.json, and are merged into the resolved provider catalog on every run regardless of `--config` (CL-6973). A `--config` file that names a `codex/*` or `xai/*` provider by ID does not by itself grant that provider's credentials — those come from the OAuth store whenever a matching profile exists there, independent of which settings file supplied the provider definitions. The only way to fully exclude the home OAuth catalog is the programmatic `globalSettingsPath` option to `loadConfig`, used by tests for full isolation; it is not exposed as a CLI flag. +### Ollama Provider Setup + +Ollama is a first-class provider with no API-key field. Its setup form accepts an +editable server root and defaults it to `http://localhost:11434`. Corbits +projects that root to the OpenAI-compatible base URL `/v1`; it does not +ask the user to enter `/v1` themselves. Model discovery sends +`GET /v1/models` and uses the returned catalog rather than a static model +list. + +Discovery failures preserve three separate states. A request that cannot reach +Ollama is expected and nonfatal, leaving setup available to edit the root or +retry after starting the server. A reachable server whose catalog is empty +instructs the user to pull at least one model before retrying. A reachable +response that cannot be parsed as the model catalog is reported as malformed, +not as an unavailable or empty server. Corbits documents only those runtime +prerequisites; Ollama installation remains outside this flow. + ### Profiles (`src/config/profiles.ts`) Profiles supply per-project or named-profile overrides for `model` and `systemPromptExtensions` (the only allowed keys; any other key is rejected on load). diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index fe9aec1ca..6c49868e2 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -107,7 +107,9 @@ The TUI has an extensible slash-command framework. Built-ins include `/help` (sh **Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. `/review` is how to review a branch; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit review/build/critique loop; `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). There is no first-party dispatch skill — Skywalker orchestrates natively. `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `typescript`, and `opsh` stay `use_skill` only (`user-invocable: false`). Draper and emil are not slashes; they remain closed directors via `task(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `use_skill`. Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. -Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. +Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. + +Ollama is a first-class, keyless local provider: users can run Corbits against local models without creating a cloud account or storing an API key. Local availability is treated gracefully during setup — an Ollama server that is not running yet is an expected, nonfatal state, with clear retry and server-address editing paths. Once Ollama is reachable, Corbits distinguishes a server with no models from an invalid response so the user gets the relevant recovery guidance. The only prerequisite guidance Corbits gives is to start Ollama and pull at least one model; installation remains Ollama's concern. `/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-only (Recent / Favorites / connected models); add-provider, tiers, and profiles remain reachable from the same surface without in-list “connect →” rows. A switch applies to the running session immediately (no restart): inference, permission identity, grant persistence identity, and advertised tool schemas cut over together, and the choice can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials). diff --git a/docs/TUI.md b/docs/TUI.md index 55af3bbd2..6c721b538 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -413,10 +413,11 @@ kind-level "already connected" filtering hid the connect path the moment the first profile existed. **Alt+A** now opens `add_provider` (`src/tui/overlays.ts:openAddProviderOverlay`), a separate `PrimaryOverlayKind` listing every first-class provider kind from `providerChoices()` — OAuth, -API-key, and Custom alike — each annotated with its live connected-account -count and none of them filtered out. Custom uses the full manual form (name, -base URL, key, model); first-class kinds keep their auth-only or browser -login paths. Esc returns to the model list through the same +API-key, keyless local, and Custom alike — each annotated with its live +connected-account count and none of them filtered out. Custom uses the full +manual form (name, base URL, key, model); OAuth and API-key kinds keep their +auth-only or browser login paths, while Ollama has a keyless local setup path. +Esc returns to the model list through the same `openModels()` entry point the picker itself uses. Picking a row runs the existing inline connect flow (`provider-connect.ts`); first-class kinds (OAuth and API-key) both ask for an instance/account name before auth so multiple @@ -424,6 +425,16 @@ instances coexist as `kind/slug` catalog rows, and reusing a name confirms before re-auth or re-key. On success the picker reopens focused on the new account's default model instead of the top of the list. +Ollama setup is keyless and starts with an editable server root, defaulting to +`http://localhost:11434`. Continuing discovers models dynamically from that +server rather than presenting a fixed catalog. If the server is unreachable, +the setup stays open and treats that as an expected local-availability state: +the user can start Ollama, edit the root, or retry. A reachable server with no +models instead explains that at least one model must be pulled before retrying; +a reachable response with an invalid shape is reported separately as malformed, +not collapsed into either an empty catalog or a connection failure. These are +recovery instructions, not an Ollama installation tutorial. + Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and the satellite pickers used for session resume and session-mode selection (`src/tui/list-modal.ts:runListModal`) deliberately do not enable DEC diff --git a/packages/first-class-providers/src/providers.test.ts b/packages/first-class-providers/src/providers.test.ts index 3e4a11083..fea24de06 100644 --- a/packages/first-class-providers/src/providers.test.ts +++ b/packages/first-class-providers/src/providers.test.ts @@ -16,6 +16,7 @@ describe("FIRST_CLASS_PROVIDERS", () => { "zai", "anthropic", "google", + "ollama", "custom", ]); }); @@ -65,12 +66,20 @@ describe("FIRST_CLASS_PROVIDERS", () => { for (const def of FIRST_CLASS_PROVIDERS) { if (def.auth !== "api-key") continue; expect(def.baseURL?.length ?? 0).toBeGreaterThan(0); + if (def.id === "ollama") continue; expect((def.models ?? []).length).toBeGreaterThan(0); expect(def.defaultModel?.length ?? 0).toBeGreaterThan(0); expect(def.models).toContain(def.defaultModel); } }); + test("Ollama is explicitly keyless and ships its root URL with no static model fallback", () => { + const ollama = firstClassProviderById("ollama"); + expect(ollama?.auth).toBe("keyless"); + expect(ollama?.baseURL).toBe("http://localhost:11434"); + expect(ollama?.models).toEqual([]); + }); + test("Z.AI Coding Plan uses coding paas base URL", () => { const zai = firstClassProviderById("zai"); expect(zai?.label).toBe("Z.AI Coding Plan"); diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts index b106c5f44..eb56b8d16 100644 --- a/packages/first-class-providers/src/providers.ts +++ b/packages/first-class-providers/src/providers.ts @@ -104,6 +104,15 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ defaultModel: "gemini-2.5-pro", authHint: "Paste your Google AI Studio API key", }, + { + id: "ollama", + label: "Ollama", + auth: "keyless", + baseURL: "http://localhost:11434", + models: [], + defaultModel: "", + authHint: "Local provider — Ollama must be running", + }, { id: "custom", label: "Custom", diff --git a/packages/first-class-providers/src/types.ts b/packages/first-class-providers/src/types.ts index e8b810bd4..56ec92a1f 100644 --- a/packages/first-class-providers/src/types.ts +++ b/packages/first-class-providers/src/types.ts @@ -1,4 +1,4 @@ -export type FirstClassAuthKind = "oauth" | "api-key" | "chooser" | "custom"; +export type FirstClassAuthKind = "oauth" | "api-key" | "keyless" | "chooser" | "custom"; export type FirstClassOAuthProvider = "codex" | "xai"; diff --git a/src/config.test.ts b/src/config.test.ts index 633911bb3..e11f3f825 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1132,11 +1132,22 @@ describe("buildOpenAISource", () => { }); }); + test("projects an Ollama root URL to the OpenAI-compatible /v1 endpoint", () => { + const source = buildOpenAISource({ + id: "ollama/default", + baseURL: "http://localhost:11434", + model: "qwen3", + }); + + expect(source.provider).toBe("openai-compatible"); + expect(source.baseURL).toBe("http://localhost:11434/v1"); + }); + test("substitutes a placeholder apiKey when none is provided (keyless)", () => { const source = buildOpenAISource({ - id: "ollama", - baseURL: "http://localhost:11434/v1", - model: "llama3", + id: "local", + baseURL: "http://localhost:8080/v1", + model: "local-model", }); expect(source.apiKey).toBe(KEYLESS_API_KEY); }); diff --git a/src/config/index.ts b/src/config/index.ts index cff1ebd77..453e79513 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -38,6 +38,7 @@ import { GROK_USER_ID_OPTION, } from "../provider/grok-responses-adapter.js"; import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js"; +import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js"; import { OPENAI_RESPONSES_PROVIDER, OPENAI_SESSION_ID_OPTION, @@ -183,7 +184,9 @@ export function buildOpenAISource(fields: { return { id: fields.id, provider: "openai-compatible", - baseURL: normalizeOpenAICompatibleBaseURL(fields.baseURL), + baseURL: isOllamaProviderId(fields.id) + ? ollamaOpenAIBaseURL(fields.baseURL) + : normalizeOpenAICompatibleBaseURL(fields.baseURL), apiKey: fields.apiKey !== undefined && fields.apiKey.length > 0 ? fields.apiKey : KEYLESS_API_KEY, model: fields.model, diff --git a/src/provider/models-endpoint.ts b/src/provider/models-endpoint.ts index 86f4fb284..617128f61 100644 --- a/src/provider/models-endpoint.ts +++ b/src/provider/models-endpoint.ts @@ -13,10 +13,12 @@ export async function requestModelsEndpoint(args: { baseURL: string; headers?: Record; timeoutMs?: number; + signal?: AbortSignal; }): Promise { + const timeout = AbortSignal.timeout(args.timeoutMs ?? DEFAULT_MODELS_REQUEST_TIMEOUT_MS); return fetch(modelsEndpointURL(args.baseURL), { method: "GET", headers: args.headers ?? {}, - signal: AbortSignal.timeout(args.timeoutMs ?? DEFAULT_MODELS_REQUEST_TIMEOUT_MS), + signal: args.signal === undefined ? timeout : AbortSignal.any([args.signal, timeout]), }); } diff --git a/src/provider/ollama.test.ts b/src/provider/ollama.test.ts new file mode 100644 index 000000000..cb14faa98 --- /dev/null +++ b/src/provider/ollama.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + discoverOllamaModels, + isOllamaProviderId, + ollamaOpenAIBaseURL, + type OllamaDiscoveryState, +} from "./ollama.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("Ollama provider identity", () => { + test("recognizes the reserved family without matching unrelated keyless providers", () => { + expect(isOllamaProviderId("ollama")).toBe(true); + expect(isOllamaProviderId("ollama/workstation")).toBe(true); + expect(isOllamaProviderId("local")).toBe(false); + expect(isOllamaProviderId("ollama-cloud")).toBe(false); + }); +}); + +describe("ollamaOpenAIBaseURL", () => { + test("projects a root URL to exactly one /v1", () => { + expect(ollamaOpenAIBaseURL("http://localhost:11434")).toBe("http://localhost:11434/v1"); + expect(ollamaOpenAIBaseURL("http://localhost:11434/")).toBe("http://localhost:11434/v1"); + }); + + test("rejects non-root paths instead of ambiguously appending /v1", () => { + expect(() => ollamaOpenAIBaseURL("http://localhost:11434/v1")).toThrow( + "expected a server root without a path", + ); + expect(() => ollamaOpenAIBaseURL("http://localhost:11434/team")).toThrow( + "expected a server root without a path", + ); + }); +}); + +describe("discoverOllamaModels", () => { + test("requests the OpenAI models endpoint and validates model ids", async () => { + const fetchMock = async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("http://localhost:11434/v1/models"); + expect(init?.method).toBe("GET"); + return Response.json({ data: [{ id: "qwen3" }, { id: "deepseek-r1" }] }); + }; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(discoverOllamaModels({ rootURL: "http://localhost:11434/" })).resolves.toEqual({ + status: "models", + models: ["qwen3", "deepseek-r1"], + }); + }); + + test("distinguishes empty, unavailable, HTTP, and malformed responses", async () => { + const cases: { + response: () => Promise; + expected: OllamaDiscoveryState["status"]; + }[] = [ + { response: async () => Response.json({ data: [] }), expected: "empty" }, + { response: async () => new Response("no", { status: 503 }), expected: "unavailable" }, + { response: async () => Response.json({ models: [] }), expected: "malformed" }, + ]; + + for (const item of cases) { + globalThis.fetch = item.response as unknown as typeof fetch; + expect((await discoverOllamaModels({ rootURL: "http://localhost:11434" })).status).toBe( + item.expected, + ); + } + + globalThis.fetch = (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + expect((await discoverOllamaModels({ rootURL: "http://localhost:11434" })).status).toBe( + "unavailable", + ); + }); +}); diff --git a/src/provider/ollama.ts b/src/provider/ollama.ts new file mode 100644 index 000000000..19c49aa33 --- /dev/null +++ b/src/provider/ollama.ts @@ -0,0 +1,90 @@ +import { type } from "arktype"; + +import { requestModelsEndpoint } from "./models-endpoint.js"; + +export const OLLAMA_PROVIDER_ID = "ollama"; +export const OLLAMA_DEFAULT_ROOT_URL = "http://localhost:11434"; + +export function isOllamaProviderId(providerId: string): boolean { + return providerId === OLLAMA_PROVIDER_ID || providerId.startsWith(`${OLLAMA_PROVIDER_ID}/`); +} + +/** Validate and normalize the server root persisted by Ollama setup. */ +export function normalizeOllamaRootURL(rootURL: string): string { + const parsed = new URL(rootURL.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Invalid Ollama URL "${rootURL}": expected http or https.`); + } + if (parsed.pathname !== "/") { + throw new Error(`Invalid Ollama URL "${rootURL}": expected a server root without a path.`); + } + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); +} + +/** Project an editable Ollama root URL to its OpenAI-compatible API endpoint. */ +export function ollamaOpenAIBaseURL(rootURL: string): string { + return `${normalizeOllamaRootURL(rootURL)}/v1`; +} + +const OllamaModelsResponse = type({ + data: type({ id: "string" }).array(), +}); + +export type OllamaDiscoveryState = + | { readonly status: "models"; readonly models: readonly string[] } + | { readonly status: "empty" } + | { readonly status: "unavailable"; readonly message: string } + | { readonly status: "malformed"; readonly message: string }; + +/** Discover installed Ollama models without leaking transport or parsing failures. */ +export async function discoverOllamaModels(args: { + rootURL: string; + timeoutMs?: number; + signal?: AbortSignal; +}): Promise { + let baseURL: string; + try { + baseURL = ollamaOpenAIBaseURL(args.rootURL); + } catch (error) { + return { + status: "malformed", + message: error instanceof Error ? error.message : String(error), + }; + } + + let response: Response; + try { + response = await requestModelsEndpoint({ + baseURL, + ...(args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}), + ...(args.signal !== undefined ? { signal: args.signal } : {}), + }); + } catch (error) { + return { + status: "unavailable", + message: error instanceof Error ? error.message : String(error), + }; + } + + if (!response.ok) { + return { status: "unavailable", message: `Ollama returned HTTP ${String(response.status)}` }; + } + + let raw: unknown; + try { + raw = await response.json(); + } catch (error) { + return { + status: "malformed", + message: error instanceof Error ? error.message : "Ollama returned invalid JSON", + }; + } + const parsed = OllamaModelsResponse(raw); + if (parsed instanceof type.errors) { + return { status: "malformed", message: parsed.summary }; + } + const models = [...new Set(parsed.data.map(({ id }) => id.trim()).filter((id) => id.length > 0))]; + return models.length > 0 ? { status: "models", models } : { status: "empty" }; +} diff --git a/src/subagent/run-source.test.ts b/src/subagent/run-source.test.ts new file mode 100644 index 000000000..290bf977e --- /dev/null +++ b/src/subagent/run-source.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; + +import { KEYLESS_API_KEY } from "../config/index.js"; +import { buildSubAgentPrimarySource } from "./run.js"; + +describe("buildSubAgentPrimarySource", () => { + test("projects an Ollama root into the subagent OpenAI-compatible source", () => { + const bundle = buildSubAgentPrimarySource({ + providerName: "ollama/default", + baseURL: "http://localhost:11434", + keyless: true, + model: "qwen3", + }); + + expect(bundle.defaultSource).toBe("ollama/default"); + expect(bundle.sources).toHaveLength(1); + expect(bundle.sources[0]).toMatchObject({ + id: "ollama/default", + provider: "openai-compatible", + baseURL: "http://localhost:11434/v1", + apiKey: KEYLESS_API_KEY, + model: "qwen3", + }); + }); +}); diff --git a/src/tui/provider-connect.ts b/src/tui/provider-connect.ts index 2cda8b5a9..37e1e493d 100644 --- a/src/tui/provider-connect.ts +++ b/src/tui/provider-connect.ts @@ -17,6 +17,7 @@ export interface ConnectProviderInput { readonly existing: Settings | null; readonly createRenderer?: ProviderSetupConfig["createRenderer"]; readonly startLogin?: ProviderSetupConfig["startLogin"]; + readonly discoverOllamaModels?: ProviderSetupConfig["discoverOllamaModels"]; } export interface ConnectProviderResult { @@ -47,6 +48,9 @@ export async function connectProviderInline( existingProviderNames: Object.keys(input.existing?.providers ?? {}), ...(input.createRenderer !== undefined ? { createRenderer: input.createRenderer } : {}), ...(input.startLogin !== undefined ? { startLogin: input.startLogin } : {}), + ...(input.discoverOllamaModels !== undefined + ? { discoverOllamaModels: input.discoverOllamaModels } + : {}), onSubmit: async (values, setPhase, opts) => { // Persistence and validation (empty-key rejection, connection test, // unverified marking) live in the one funnel every provider-setup exit diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index 56cc9070b..ed92eef41 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -11,6 +11,7 @@ import { withMockedModule } from "../../tests/helpers/mock-module.js"; // (ok / blocked / unavailable) without a live call. let scopeCheckResult: OAuthScopeCheckResult = { status: "ok" }; const scopeCheckCalls: unknown[][] = []; +const connectionChecks: unknown[] = []; await withMockedModule( import.meta.resolve("../auth/oauth-scope-check.js"), (real: typeof import("../auth/oauth-scope-check.js")) => ({ @@ -21,6 +22,16 @@ await withMockedModule( }, }), ); +await withMockedModule( + import.meta.resolve("../provider/validate-connection.js"), + (real: typeof import("../provider/validate-connection.js")) => ({ + ...real, + validateProviderConnection: async (args: unknown) => { + connectionChecks.push(args); + return { ok: true as const }; + }, + }), +); const { buildProviderSubmitHandler } = await import("./provider-setup-submit.js"); const { loadLocalSettings, loadSettings, localSettingsPath, resolveLocalSettingsPath } = @@ -97,6 +108,38 @@ describe("buildProviderSubmitHandler", () => { }); }); + test("never validates or persists a stale API key for Ollama", async () => { + await withTempDir(async (dir) => { + connectionChecks.length = 0; + const path = join(dir, "settings.json"); + const submit = buildProviderSubmitHandler(path, null, localSettingsPath(dir)); + const values: ProviderFormValues = { + name: "ollama/default", + baseURL: "http://remote:11434/", + apiKey: "sk-stale-secret", + model: "qwen3", + oauthProfile: "default", + }; + const preset = { + id: "ollama", + models: [], + anthropic: false, + opencodeGo: false, + }; + + await submit(values, noopSetPhase, { skipValidation: false, preset }); + + expect(connectionChecks).toEqual([{ baseURL: "http://remote:11434/v1", apiKey: undefined }]); + const provider = (await loadSettings(path))?.providers["ollama/default"]; + expect(provider).toMatchObject({ + baseURL: "http://remote:11434", + keyless: true, + models: ["qwen3"], + }); + expect(provider?.apiKey).toBeUndefined(); + }); + }); + test("marks a save-anyway submit as unverified", async () => { await withTempDir(async (dir) => { const path = join(dir, "settings.json"); diff --git a/src/tui/provider-setup-submit.ts b/src/tui/provider-setup-submit.ts index d82f964c8..7e624d568 100644 --- a/src/tui/provider-setup-submit.ts +++ b/src/tui/provider-setup-submit.ts @@ -9,6 +9,11 @@ import { saveLocalSettings, type Settings, } from "../config/settings.js"; +import { + isOllamaProviderId, + normalizeOllamaRootURL, + ollamaOpenAIBaseURL, +} from "../provider/ollama.js"; import { validateProviderConnection } from "../provider/validate-connection.js"; import type { ProviderSetupSubmit } from "./provider-setup.js"; @@ -51,6 +56,9 @@ export function buildProviderSubmitHandler( const trimmedBaseURL = baseURL.trim(); const trimmedKey = apiKey.trim(); const selectedModel = model.trim(); + const isOllama = preset !== undefined && isOllamaProviderId(preset.id); + const effectiveApiKey = isOllama || trimmedKey.length === 0 ? undefined : trimmedKey; + const persistedBaseURL = isOllama ? normalizeOllamaRootURL(trimmedBaseURL) : trimmedBaseURL; // OAuth credentials stay staged until setup validation authorizes durable // persistence. Definitive API-scope or credential failures block the save; @@ -89,7 +97,7 @@ export function buildProviderSubmitHandler( // Reject an empty key here rather than silently downgrading it to // `keyless: true` and letting resolveProvider skip the missing-key check // entirely. - if (preset !== undefined && trimmedKey.length === 0) { + if (preset !== undefined && !isOllama && trimmedKey.length === 0) { throw new Error(`${providerName || preset.id} requires an API key.`); } @@ -100,8 +108,8 @@ export function buildProviderSubmitHandler( // /models with a bearer token, which that surface always rejects. if (!skipValidation && preset?.anthropic !== true) { const check = await validateProviderConnection({ - baseURL: trimmedBaseURL, - apiKey: trimmedKey.length > 0 ? trimmedKey : undefined, + baseURL: isOllama ? ollamaOpenAIBaseURL(persistedBaseURL) : persistedBaseURL, + apiKey: effectiveApiKey, }); if (!check.ok) { throw new Error(check.error); @@ -117,10 +125,10 @@ export function buildProviderSubmitHandler( ? [...preset.models] : [selectedModel]; const newProvider = { - baseURL: trimmedBaseURL, + baseURL: persistedBaseURL, models, defaultModel: selectedModel, - ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : { keyless: true }), + ...(effectiveApiKey !== undefined ? { apiKey: effectiveApiKey } : { keyless: true }), ...(preset?.anthropic === true ? { anthropic: true } : {}), ...(preset?.opencodeGo === true ? { opencodeGo: true } : {}), // "Save anyway" (Ctrl+S) persists a credential the connection test diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index 650980c54..f4ecbd2d1 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -81,6 +81,13 @@ afterEach(() => { }); describe("provider setup pure helpers", () => { + test("offers Ollama as a keyless provider with an editable root URL", () => { + const ollama = providerChoiceById("ollama"); + expect(ollama).toBeDefined(); + expect(ollama?.baseURL).toBe("http://localhost:11434"); + expect(stepsFor(ollama ?? null)).toEqual(["provider", "name", "baseURL", "model"]); + }); + test("only the API key may be left blank", () => { expect(stepReady("name", "")).toBe(false); expect(stepReady("name", " ")).toBe(false); @@ -175,7 +182,7 @@ describe("provider setup pure helpers", () => { for (const choice of choices) { if (choice.custom) continue; expect(choice.baseURL.length).toBeGreaterThan(0); - expect(choice.defaultModel.length).toBeGreaterThan(0); + if (choice.id !== "ollama") expect(choice.defaultModel.length).toBeGreaterThan(0); } expect(providerChoiceRows(choices)[0]?.label).toContain("OpenAI"); }); @@ -442,6 +449,110 @@ async function nameOAuthAccount(harness: Harness, name?: string): Promise await flush(harness); } +describe("runProviderSetup Ollama discovery", () => { + test("clears an API key when switching from OpenAI to Ollama", async () => { + const seen: ProviderFormValues[] = []; + const harness = await createHarness({ width: 80, height: 30 }); + const done = runProviderSetup({ + onSubmit: async (values) => { + seen.push({ ...values }); + }, + showTelemetryNotice: false, + createRenderer: async () => harness.renderer, + discoverOllamaModels: async () => ({ status: "models", models: ["qwen3"] }), + }); + await harness.renderOnce(); + + await pickRow(harness, PROVIDER_IDS, "openai"); + await flush(harness); + harness.pressKey("Enter"); + await harness.renderOnce(); + type(harness, "sk-stale-secret"); + harness.pressKey("Enter"); + await harness.renderOnce(); + await pressEscape(harness); + await pressEscape(harness); + await pressEscape(harness); + + const openAIIndex = PROVIDER_IDS.indexOf("openai"); + const ollamaIndex = PROVIDER_IDS.indexOf("ollama"); + const key = ollamaIndex < openAIIndex ? "ARROW_UP" : "ARROW_DOWN"; + for (let i = 0; i < Math.abs(ollamaIndex - openAIIndex); i++) harness.pressKey(key); + harness.pressKey("Enter"); + await flush(harness); + harness.pressKey("Enter"); + await harness.renderOnce(); + harness.pressKey("Enter"); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); + + expect(await done).toBe(true); + expect(seen[0]?.apiKey).toBe(""); + }); + + test("distinguishes empty, unavailable, and malformed states and ignores stale retries", async () => { + const pending: (( + state: + | { status: "empty" } + | { status: "unavailable"; message: string } + | { status: "malformed"; message: string } + | { status: "models"; models: string[] }, + ) => void)[] = []; + const harness = await createHarness({ width: 80, height: 30 }); + const done = runProviderSetup({ + onSubmit: () => Promise.resolve(), + showTelemetryNotice: false, + initialProviderId: "ollama", + createRenderer: async () => harness.renderer, + discoverOllamaModels: async () => + new Promise((resolve) => { + pending.push(resolve); + }), + }); + await flush(harness); + + harness.pressKey("Enter"); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); + pending[0]?.({ status: "empty" }); + await flush(harness); + expect(harness.captureCharFrame()).toContain("Ollama is running, but no models are installed"); + + harness.pressKey("Enter"); + await flush(harness); + pending[1]?.({ status: "unavailable", message: "connection refused" }); + await flush(harness); + expect(harness.captureCharFrame()).toContain("Ollama is not running"); + + harness.pressKey("Enter"); + await flush(harness); + pending[2]?.({ status: "malformed", message: "data must be an array" }); + await flush(harness); + expect(harness.captureCharFrame()).toContain("Ollama returned an invalid models response"); + expect(harness.captureCharFrame()).not.toContain("Ollama is not running"); + + harness.pressKey("Enter"); + await flush(harness); + await pressEscape(harness); + for (let i = 0; i < 80; i++) harness.pressKey("Backspace"); + type(harness, "http://remote:11434"); + harness.pressKey("Enter"); + await flush(harness); + + pending[4]?.({ status: "models", models: ["current-model"] }); + await flush(harness); + pending[3]?.({ status: "models", models: ["stale-model"] }); + await flush(harness); + expect(harness.captureCharFrame()).toContain("current-model"); + expect(harness.captureCharFrame()).not.toContain("stale-model"); + + harness.pressKey("Ctrl+C"); + expect(await done).toBe(false); + }); +}); + describe("runProviderSetup renderer ownership", () => { test("does not destroy a caller-supplied renderer on cancel", async () => { const { done, harness } = await mountSetup(); diff --git a/src/tui/provider-setup.ts b/src/tui/provider-setup.ts index b6273233a..6c4eddf73 100644 --- a/src/tui/provider-setup.ts +++ b/src/tui/provider-setup.ts @@ -33,6 +33,11 @@ import type { AuthProfile } from "../auth/oauth/store.js"; import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "../auth/xai/constants.js"; import type { XaiTokens } from "../auth/xai/store.js"; import { PRODUCT_NAME } from "../branding.js"; +import { + discoverOllamaModels as discoverOllamaModelsRequest, + isOllamaProviderId, + type OllamaDiscoveryState, +} from "../provider/ollama.js"; import { codexProviderName } from "../config/codex-providers.js"; import { xaiProviderName } from "../config/xai-providers.js"; import { TELEMETRY_NOTICE } from "../telemetry/index.js"; @@ -90,6 +95,9 @@ export type SetupStep = "provider" | "name" | "baseURL" | "apiKey" | "model" | " /** Known-provider path: pick, name the instance, paste key, pick model. */ export const PRESET_STEPS: readonly SetupStep[] = ["provider", "name", "apiKey", "model"]; +/** Ollama is keyless and keeps its editable root URL visible before discovery. */ +export const OLLAMA_STEPS: readonly SetupStep[] = ["provider", "name", "baseURL", "model"]; + /** * Subscription path: pick, name the account (a suggested slug is prefilled; * reusing an existing name asks for confirmation before re-authorizing it), @@ -118,7 +126,7 @@ const STEP_LABELS: Record = { const STEP_PROMPTS: Record = { provider: "pick the provider you have a key or subscription for", name: "name this provider — you will see it in /model", - baseURL: "paste the api base url, including /v1 if it needs one", + baseURL: "paste the provider url — Ollama uses the server root; others may include /v1", apiKey: "paste the api key — leave blank for a keyless local endpoint", model: "pick the model to start with", login: "authorize in the browser — this window waits for you", @@ -296,7 +304,7 @@ const CUSTOM_CHOICE: ProviderChoice = { }; function choiceFromDef(def: FirstClassProviderDef): ProviderChoice | null { - if (def.auth !== "api-key") return null; + if (def.auth !== "api-key" && def.auth !== "keyless") return null; if (def.baseURL === undefined || def.models === undefined) return null; const defaultModel = def.defaultModel ?? def.models[0]; if (defaultModel === undefined) return null; @@ -481,6 +489,7 @@ export function modelFromRowId(providerId: string, rowId: string): string { export function stepsFor(choice: ProviderChoice | null): readonly SetupStep[] { if (choice === null) return PRESET_STEPS; if (choice.custom) return CUSTOM_STEPS; + if (isOllamaProviderId(choice.id)) return OLLAMA_STEPS; return choice.oauth !== null ? OAUTH_STEPS : PRESET_STEPS; } @@ -719,6 +728,8 @@ export interface ProviderSetupConfig { readonly listOAuthProfiles?: OAuthProfileLister; /** Sign-in deadline override, in milliseconds. */ readonly loginTimeoutMs?: number; + /** Ollama discovery override for deterministic setup tests. */ + readonly discoverOllamaModels?: typeof discoverOllamaModelsRequest; /** * Skip the provider pick-list and start directly on that provider's first * form step (account name for multi-instance kinds, or the custom name @@ -820,6 +831,11 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise c.id === config.initialProviderId); if (preselected !== undefined) { @@ -855,9 +871,14 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise stepsFor(choice); const currentStep = (): SetupStep => steps()[stepIndex] ?? ("provider" as SetupStep); + const isOllamaModelStep = (): boolean => + currentStep() === "model" && choice !== null && isOllamaProviderId(choice.id); const isListStep = (): boolean => { const step = currentStep(); if (step === "provider") return true; + if (isOllamaModelStep()) { + return typeof ollamaDiscovery === "object" && ollamaDiscovery.status === "models"; + } return step === "model" && choice !== null && !choice.custom && !typedModel; }; // The "name" step means two different things depending on the path: a @@ -1120,6 +1141,35 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise { + if (!submitting && isOllamaModelStep() && ollamaDiscovery !== "idle") { + if (ollamaDiscovery === "loading") { + const ramp = rampFor({ phase: "working", nowMs: Date.now() }); + statusLine.content = rampLine(ramp, "checking installed Ollama models"); + statusLine.fg = ramp.fg; + guidance.content = "esc to edit the Ollama URL"; + return; + } + if (ollamaDiscovery.status !== "models") { + const empty = ollamaDiscovery.status === "empty"; + const malformed = ollamaDiscovery.status === "malformed"; + const ramp = rampFor({ phase: "blocked", nowMs: 0 }); + statusLine.content = rampLine( + ramp, + empty + ? "Ollama is running, but no models are installed" + : malformed + ? "Ollama returned an invalid models response" + : "Ollama is not running", + ); + statusLine.fg = ramp.fg; + guidance.content = empty + ? "pull a model, then press enter to retry · esc to edit url" + : malformed + ? "check the Ollama URL, then press enter to retry · esc to edit url" + : "press enter to retry · esc to edit url"; + return; + } + } if (!submitting && isAccountNameStep()) { if (oauthProfileError !== null) { const ramp = rampFor({ phase: "blocked", nowMs: 0 }); @@ -1203,6 +1253,10 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise { const active = currentStep(); - if (isListStep() || isLoginStep()) { + if (isListStep() || isLoginStep() || isOllamaModelStep()) { input.blur(); paint(); + if (isOllamaModelStep() && ollamaDiscovery === "idle") beginOllamaDiscovery(); // Arriving on the sign-in step is the trigger: there is nothing to type, // so the flow starts itself rather than waiting for a keystroke. if (isLoginStep() && loginStatus === "idle") beginLogin(); @@ -1300,9 +1355,16 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise { + ollamaDiscoveryAttempt += 1; + ollamaDiscoveryAbort?.abort(); + ollamaDiscoveryAbort = null; + }; + const teardown = (): void => { stopRamp(); abandonLogin(); + abandonOllamaDiscovery(); renderer.keyInput.off("keypress", onKey); input.off(InputRenderableEvents.ENTER, onEnter); input.off(InputRenderableEvents.INPUT, onInput); @@ -1448,6 +1510,39 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise { + if (!isOllamaModelStep()) return; + abandonOllamaDiscovery(); + const attempt = ollamaDiscoveryAttempt; + const rootURL = values.baseURL; + const abort = new AbortController(); + ollamaDiscoveryAbort = abort; + ollamaDiscovery = "loading"; + stopRamp(); + rampTimer = setInterval(paintStatus, RAMP_TICK_MS); + paint(); + discoverOllamaModels({ rootURL, signal: abort.signal }).then((result) => { + if ( + settled || + attempt !== ollamaDiscoveryAttempt || + values.baseURL !== rootURL || + !isOllamaModelStep() + ) { + return; + } + stopRamp(); + ollamaDiscoveryAbort = null; + ollamaDiscovery = result; + if (result.status === "models" && choice !== null) { + values.model = result.models[0] ?? ""; + const dynamicChoice = { ...choice, models: result.models, defaultModel: values.model }; + listRows = modelChoiceRows(dynamicChoice).filter((row) => row.id !== TYPE_MODEL_ID); + list = createListViewport({ count: listRows.length, height: listHeight() }); + } + paint(); + }); + }; + const submit = (skipValidation: boolean): void => { submitting = true; submitPhase = "testing"; @@ -1498,7 +1593,10 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise { const picked = providerChoiceById(id); if (picked === undefined) return; + abandonOllamaDiscovery(); + ollamaDiscovery = "idle"; choice = picked; + values.apiKey = ""; typedModel = false; oauthProfileError = null; oauthProfileConfirmPending = false; @@ -1572,6 +1670,10 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise { if (stepIndex === 0) return; + if (isOllamaModelStep()) { + abandonOllamaDiscovery(); + ollamaDiscovery = "idle"; + } stepIndex -= 1; clearError(); if (currentStep() === "provider") enterProviderList(); @@ -1739,6 +1845,11 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise Date: Mon, 31 Aug 2026 19:19:01 -0700 Subject: [PATCH 2/3] Accept Ollama /v1 URLs and persist discovered models Legacy Custom rows stored the OpenAI-compatible /v1 path. Treating that as a root threw while building backup sources and crashed session start for every provider. Strip /v1 once, keep true network failures as not-running, and seed the setup catalog so /model can switch among installed models. --- .../src/providers.test.ts | 1 - src/config.test.ts | 10 ++ src/provider/ollama.test.ts | 51 ++++++++++- src/provider/ollama.ts | 21 ++++- src/tui/provider-setup-submit.test.ts | 35 +++++++ src/tui/provider-setup.test.ts | 91 +++++++++++++++++++ src/tui/provider-setup.ts | 71 +++++++++------ tests/unit/inference-sources.test.ts | 38 ++++++++ 8 files changed, 284 insertions(+), 34 deletions(-) diff --git a/packages/first-class-providers/src/providers.test.ts b/packages/first-class-providers/src/providers.test.ts index fea24de06..362c129f5 100644 --- a/packages/first-class-providers/src/providers.test.ts +++ b/packages/first-class-providers/src/providers.test.ts @@ -66,7 +66,6 @@ describe("FIRST_CLASS_PROVIDERS", () => { for (const def of FIRST_CLASS_PROVIDERS) { if (def.auth !== "api-key") continue; expect(def.baseURL?.length ?? 0).toBeGreaterThan(0); - if (def.id === "ollama") continue; expect((def.models ?? []).length).toBeGreaterThan(0); expect(def.defaultModel?.length ?? 0).toBeGreaterThan(0); expect(def.models).toContain(def.defaultModel); diff --git a/src/config.test.ts b/src/config.test.ts index e11f3f825..eb8cee22c 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1143,6 +1143,16 @@ describe("buildOpenAISource", () => { expect(source.baseURL).toBe("http://localhost:11434/v1"); }); + test("projects a legacy Ollama /v1 URL without doubling the path", () => { + const source = buildOpenAISource({ + id: "ollama", + baseURL: "http://localhost:11434/v1", + model: "llama3", + }); + + expect(source.baseURL).toBe("http://localhost:11434/v1"); + }); + test("substitutes a placeholder apiKey when none is provided (keyless)", () => { const source = buildOpenAISource({ id: "local", diff --git a/src/provider/ollama.test.ts b/src/provider/ollama.test.ts index cb14faa98..915caeac0 100644 --- a/src/provider/ollama.test.ts +++ b/src/provider/ollama.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { discoverOllamaModels, isOllamaProviderId, + normalizeOllamaRootURL, + ollamaDiscoveryFailureLine, ollamaOpenAIBaseURL, type OllamaDiscoveryState, } from "./ollama.js"; @@ -28,16 +30,44 @@ describe("ollamaOpenAIBaseURL", () => { expect(ollamaOpenAIBaseURL("http://localhost:11434/")).toBe("http://localhost:11434/v1"); }); - test("rejects non-root paths instead of ambiguously appending /v1", () => { - expect(() => ollamaOpenAIBaseURL("http://localhost:11434/v1")).toThrow( - "expected a server root without a path", - ); + test("strips a pasted or legacy /v1 before re-appending once", () => { + expect(normalizeOllamaRootURL("http://localhost:11434/v1")).toBe("http://localhost:11434"); + expect(normalizeOllamaRootURL("http://localhost:11434/v1/")).toBe("http://localhost:11434"); + expect(ollamaOpenAIBaseURL("http://localhost:11434/v1")).toBe("http://localhost:11434/v1"); + expect(ollamaOpenAIBaseURL("http://localhost:11434/v1/")).toBe("http://localhost:11434/v1"); + }); + + test("rejects non-root paths other than /v1", () => { expect(() => ollamaOpenAIBaseURL("http://localhost:11434/team")).toThrow( "expected a server root without a path", ); }); }); +describe("ollamaDiscoveryFailureLine", () => { + test("keeps unreachable as not running and surfaces HTTP and URL errors", () => { + expect(ollamaDiscoveryFailureLine({ status: "empty" })).toBe( + "Ollama is running, but no models are installed", + ); + expect( + ollamaDiscoveryFailureLine({ status: "unavailable", message: "connection refused" }), + ).toBe("Ollama is not running"); + expect( + ollamaDiscoveryFailureLine({ status: "unavailable", message: "Ollama returned HTTP 503" }), + ).toBe("Ollama returned HTTP 503"); + expect( + ollamaDiscoveryFailureLine({ status: "malformed", message: "data must be an array" }), + ).toBe("Ollama returned an invalid models response"); + expect( + ollamaDiscoveryFailureLine({ + status: "malformed", + message: + 'Invalid Ollama URL "http://localhost:11434/team": expected a server root without a path.', + }), + ).toContain("Invalid Ollama URL"); + }); +}); + describe("discoverOllamaModels", () => { test("requests the OpenAI models endpoint and validates model ids", async () => { const fetchMock = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -53,6 +83,19 @@ describe("discoverOllamaModels", () => { }); }); + test("accepts a pasted /v1 root without doubling the path", async () => { + const fetchMock = async (input: RequestInfo | URL) => { + expect(String(input)).toBe("http://localhost:11434/v1/models"); + return Response.json({ data: [{ id: "llama3" }] }); + }; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(discoverOllamaModels({ rootURL: "http://localhost:11434/v1" })).resolves.toEqual({ + status: "models", + models: ["llama3"], + }); + }); + test("distinguishes empty, unavailable, HTTP, and malformed responses", async () => { const cases: { response: () => Promise; diff --git a/src/provider/ollama.ts b/src/provider/ollama.ts index 19c49aa33..3787bb4ef 100644 --- a/src/provider/ollama.ts +++ b/src/provider/ollama.ts @@ -15,9 +15,13 @@ export function normalizeOllamaRootURL(rootURL: string): string { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error(`Invalid Ollama URL "${rootURL}": expected http or https.`); } - if (parsed.pathname !== "/") { + // Legacy Custom rows and operators coming from OpenAI-compatible setup paste + // `/v1`. Strip it once so projection can re-append without doubling. + const pathname = parsed.pathname.replace(/\/+$/, "") || "/"; + if (pathname !== "/" && pathname !== "/v1") { throw new Error(`Invalid Ollama URL "${rootURL}": expected a server root without a path.`); } + parsed.pathname = "/"; parsed.search = ""; parsed.hash = ""; return parsed.toString().replace(/\/$/, ""); @@ -88,3 +92,18 @@ export async function discoverOllamaModels(args: { const models = [...new Set(parsed.data.map(({ id }) => id.trim()).filter((id) => id.length > 0))]; return models.length > 0 ? { status: "models", models } : { status: "empty" }; } + +/** Operator-facing line for a discovery failure. Network stays canned; HTTP and URL errors surface. */ +export function ollamaDiscoveryFailureLine( + state: Exclude, +): string { + if (state.status === "empty") { + return "Ollama is running, but no models are installed"; + } + if (state.status === "malformed") { + return state.message.startsWith("Invalid Ollama URL") + ? state.message + : "Ollama returned an invalid models response"; + } + return state.message.startsWith("Ollama returned HTTP") ? state.message : "Ollama is not running"; +} diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index ed92eef41..080a73e43 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -140,6 +140,41 @@ describe("buildProviderSubmitHandler", () => { }); }); + test("accepts a pasted Ollama /v1 URL and persists the server root", async () => { + await withTempDir(async (dir) => { + connectionChecks.length = 0; + const path = join(dir, "settings.json"); + const submit = buildProviderSubmitHandler(path, null, localSettingsPath(dir)); + const values: ProviderFormValues = { + name: "ollama/default", + baseURL: "http://localhost:11434/v1", + apiKey: "", + model: "qwen3", + oauthProfile: "default", + }; + const preset = { + id: "ollama", + models: ["qwen3", "deepseek-r1"], + anthropic: false, + opencodeGo: false, + }; + + await submit(values, noopSetPhase, { skipValidation: false, preset }); + + expect(connectionChecks).toEqual([ + { baseURL: "http://localhost:11434/v1", apiKey: undefined }, + ]); + const provider = (await loadSettings(path))?.providers["ollama/default"]; + expect(provider).toMatchObject({ + baseURL: "http://localhost:11434", + keyless: true, + models: ["qwen3", "deepseek-r1"], + defaultModel: "qwen3", + }); + expect(provider?.apiKey).toBeUndefined(); + }); + }); + test("marks a save-anyway submit as unverified", async () => { await withTempDir(async (dir) => { const path = join(dir, "settings.json"); diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index f4ecbd2d1..b81b2ffe0 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -551,6 +551,97 @@ describe("runProviderSetup Ollama discovery", () => { harness.pressKey("Ctrl+C"); expect(await done).toBe(false); }); + + test("accepts a pasted /v1 URL and persists the discovered catalog", async () => { + const seen: ProviderFormValues[] = []; + const opts: SubmitOpts[] = []; + const seenRoots: string[] = []; + const harness = await createHarness({ width: 80, height: 30 }); + const done = runProviderSetup({ + onSubmit: async (values, _setPhase, o) => { + seen.push({ ...values }); + opts.push(o); + }, + showTelemetryNotice: false, + initialProviderId: "ollama", + createRenderer: async () => harness.renderer, + discoverOllamaModels: async ({ rootURL }) => { + seenRoots.push(rootURL); + return { status: "models", models: ["qwen3", "deepseek-r1"] }; + }, + }); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); + for (let i = 0; i < 80; i++) harness.pressKey("Backspace"); + type(harness, "http://localhost:11434/v1"); + harness.pressKey("Enter"); + await flush(harness); + + const frame = harness.captureCharFrame(); + expect(frame).toContain("qwen3"); + expect(frame).toContain("deepseek-r1"); + expect(frame).not.toContain("Ollama returned an invalid models response"); + expect(seenRoots).toEqual(["http://localhost:11434/v1"]); + + harness.pressKey("Enter"); + await flush(harness); + + expect(await done).toBe(true); + expect(seen[0]?.model).toBe("qwen3"); + expect(opts[0]?.preset?.models).toEqual(["qwen3", "deepseek-r1"]); + }); + + test("paints HTTP 503 instead of a canned not-running line", async () => { + const harness = await createHarness({ width: 80, height: 30 }); + const done = runProviderSetup({ + onSubmit: () => Promise.resolve(), + showTelemetryNotice: false, + initialProviderId: "ollama", + createRenderer: async () => harness.renderer, + discoverOllamaModels: async () => ({ + status: "unavailable", + message: "Ollama returned HTTP 503", + }), + }); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); + + const frame = harness.captureCharFrame(); + expect(frame).toContain("Ollama returned HTTP 503"); + expect(frame).not.toContain("Ollama is not running"); + + harness.pressKey("Ctrl+C"); + expect(await done).toBe(false); + }); + + test("a rejected discovery promise leaves the UI off the loading line", async () => { + const harness = await createHarness({ width: 80, height: 30 }); + const done = runProviderSetup({ + onSubmit: () => Promise.resolve(), + showTelemetryNotice: false, + initialProviderId: "ollama", + createRenderer: async () => harness.renderer, + discoverOllamaModels: async () => { + throw new Error("boom"); + }, + }); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); + + const frame = harness.captureCharFrame(); + expect(frame).not.toContain("checking installed Ollama models"); + expect(frame).toContain("Ollama returned an invalid models response"); + + harness.pressKey("Ctrl+C"); + expect(await done).toBe(false); + }); }); describe("runProviderSetup renderer ownership", () => { diff --git a/src/tui/provider-setup.ts b/src/tui/provider-setup.ts index 6c4eddf73..ee6ca9f59 100644 --- a/src/tui/provider-setup.ts +++ b/src/tui/provider-setup.ts @@ -36,6 +36,7 @@ import { PRODUCT_NAME } from "../branding.js"; import { discoverOllamaModels as discoverOllamaModelsRequest, isOllamaProviderId, + ollamaDiscoveryFailureLine, type OllamaDiscoveryState, } from "../provider/ollama.js"; import { codexProviderName } from "../config/codex-providers.js"; @@ -1153,14 +1154,7 @@ export async function runProviderSetup(config: ProviderSetupConfig): Promise { - if ( - settled || - attempt !== ollamaDiscoveryAttempt || - values.baseURL !== rootURL || - !isOllamaModelStep() - ) { - return; - } - stopRamp(); - ollamaDiscoveryAbort = null; - ollamaDiscovery = result; - if (result.status === "models" && choice !== null) { - values.model = result.models[0] ?? ""; - const dynamicChoice = { ...choice, models: result.models, defaultModel: values.model }; - listRows = modelChoiceRows(dynamicChoice).filter((row) => row.id !== TYPE_MODEL_ID); - list = createListViewport({ count: listRows.length, height: listHeight() }); - } - paint(); - }); + discoverOllamaModels({ rootURL, signal: abort.signal }).then( + (result) => { + if ( + settled || + attempt !== ollamaDiscoveryAttempt || + values.baseURL !== rootURL || + !isOllamaModelStep() + ) { + return; + } + stopRamp(); + ollamaDiscoveryAbort = null; + ollamaDiscovery = result; + if (result.status === "models" && choice !== null) { + values.model = result.models[0] ?? ""; + // Seed the catalog choice so submit persists every installed model, + // not only the one picked on this screen. + choice = { ...choice, models: [...result.models], defaultModel: values.model }; + listRows = modelChoiceRows(choice).filter((row) => row.id !== TYPE_MODEL_ID); + list = createListViewport({ count: listRows.length, height: listHeight() }); + } + paint(); + }, + (err: unknown) => { + if ( + settled || + attempt !== ollamaDiscoveryAttempt || + values.baseURL !== rootURL || + !isOllamaModelStep() + ) { + return; + } + stopRamp(); + ollamaDiscoveryAbort = null; + ollamaDiscovery = { + status: "malformed", + message: err instanceof Error ? err.message : String(err), + }; + paint(); + }, + ); }; const submit = (skipValidation: boolean): void => { diff --git a/tests/unit/inference-sources.test.ts b/tests/unit/inference-sources.test.ts index 009e738b5..aee6fad7b 100644 --- a/tests/unit/inference-sources.test.ts +++ b/tests/unit/inference-sources.test.ts @@ -141,6 +141,44 @@ test("buildMainSessionSources backs the active head with other configured provid expect(bundle.defaultSource).toBe("openai"); }); +test("legacy ollama /v1 backup does not throw when OpenAI is active", () => { + const settings: Settings = { + providers: { + openai: { + baseURL: "https://api.openai.com/v1", + apiKey: "k", + models: ["gpt-4o"], + }, + ollama: { baseURL: "http://localhost:11434/v1", keyless: true, models: ["llama3"] }, + }, + }; + const mixedCatalog: ProviderCatalogEntry[] = [ + { + name: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "k", + models: ["gpt-4o"], + defaultModel: "gpt-4o", + }, + { + name: "ollama", + baseURL: "http://localhost:11434/v1", + keyless: true, + models: ["llama3"], + defaultModel: "llama3", + }, + ]; + const bundle = buildMainSessionSources({ + settings, + catalog: mixedCatalog, + activeProvider: "openai", + activeModel: "gpt-4o", + sessionId: "sess", + }); + expect(bundle.defaultSource).toBe("openai"); + expect(bundle.sources.find((s) => s.id === "ollama")?.baseURL).toBe("http://localhost:11434/v1"); +}); + test("buildSubagentSources backs the head with other configured providers", () => { const settings: Settings = { providers: { From 930737ac48bc86cbbdd711a0f28678833a056d84 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 20:20:10 -0700 Subject: [PATCH 3/3] Skip backup inference sources that fail to build A leftover Ollama Custom URL with an extra path threw while building sibling sources, so a healthy head provider could not start. Skip per-ref build failures in the backup list and still fail when the active provider cannot build. --- src/config/inference-sources.ts | 24 ++++++--- src/provider/ollama.test.ts | 6 +++ tests/unit/inference-sources.test.ts | 78 ++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index 673ad6da7..d82acce67 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -178,7 +178,14 @@ export function buildSourcesFromRefs( const out: InferenceSource[] = []; const seenIds = new Set(); for (const ref of refs) { - const src = buildInferenceSourceForRef(ref, ctx, settings); + let src: InferenceSource | null; + try { + src = buildInferenceSourceForRef(ref, ctx, settings); + } catch { + // A leftover sibling URL (e.g. Custom `/api/tags`) must not take down the + // whole bundle. Head failure is re-checked in `buildSourceBundle`. + continue; + } if (src === null) continue; if (seenIds.has(src.id)) continue; seenIds.add(src.id); @@ -215,17 +222,22 @@ function buildSourceBundle(args: { const sources = buildSourcesFromRefs(refs, ctx, args.settings); const defaultId = args.head.provider; - if (sources.length === 0) { - const fallback = buildInferenceSourceForRef(args.head, ctx, args.settings); + const hasDefault = sources.some((s) => s.id === defaultId); + if (!hasDefault) { + let fallback: InferenceSource | null; + try { + fallback = buildInferenceSourceForRef(args.head, ctx, args.settings); + } catch (error) { + throw new Error(`No inference source for provider "${defaultId}"`, { cause: error }); + } if (fallback === null) { throw new Error(`No inference source for provider "${defaultId}"`); } - return { sources: [fallback], defaultSource: fallback.id }; + return { sources: [fallback, ...sources], defaultSource: fallback.id }; } - const hasDefault = sources.some((s) => s.id === defaultId); return { sources, - defaultSource: hasDefault ? defaultId : (sources[0]?.id ?? defaultId), + defaultSource: defaultId, }; } diff --git a/src/provider/ollama.test.ts b/src/provider/ollama.test.ts index 915caeac0..08a074b0b 100644 --- a/src/provider/ollama.test.ts +++ b/src/provider/ollama.test.ts @@ -41,6 +41,12 @@ describe("ollamaOpenAIBaseURL", () => { expect(() => ollamaOpenAIBaseURL("http://localhost:11434/team")).toThrow( "expected a server root without a path", ); + expect(() => ollamaOpenAIBaseURL("http://localhost:11434/api/tags")).toThrow( + "expected a server root without a path", + ); + expect(() => normalizeOllamaRootURL("http://localhost:11434/api/tags")).toThrow( + "expected a server root without a path", + ); }); }); diff --git a/tests/unit/inference-sources.test.ts b/tests/unit/inference-sources.test.ts index aee6fad7b..40a44afd9 100644 --- a/tests/unit/inference-sources.test.ts +++ b/tests/unit/inference-sources.test.ts @@ -141,6 +141,84 @@ test("buildMainSessionSources backs the active head with other configured provid expect(bundle.defaultSource).toBe("openai"); }); +test("ollama backup with leftover extra path does not throw when OpenAI is active", () => { + const leftoverURL = "http://localhost:11434/api/tags"; + const settings: Settings = { + providers: { + openai: { + baseURL: "https://api.openai.com/v1", + apiKey: "k", + models: ["gpt-4o"], + }, + ollama: { baseURL: leftoverURL, keyless: true, models: ["llama3"] }, + }, + }; + const mixedCatalog: ProviderCatalogEntry[] = [ + { + name: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "k", + models: ["gpt-4o"], + defaultModel: "gpt-4o", + }, + { + name: "ollama", + baseURL: leftoverURL, + keyless: true, + models: ["llama3"], + defaultModel: "llama3", + }, + ]; + const bundle = buildMainSessionSources({ + settings, + catalog: mixedCatalog, + activeProvider: "openai", + activeModel: "gpt-4o", + sessionId: "sess", + }); + expect(bundle.defaultSource).toBe("openai"); + expect(bundle.sources.map((s) => s.id)).toEqual(["openai"]); +}); + +test("active ollama with leftover extra path still fails that provider", () => { + const leftoverURL = "http://localhost:11434/api/tags"; + const settings: Settings = { + providers: { + ollama: { baseURL: leftoverURL, keyless: true, models: ["llama3"] }, + openai: { + baseURL: "https://api.openai.com/v1", + apiKey: "k", + models: ["gpt-4o"], + }, + }, + }; + const mixedCatalog: ProviderCatalogEntry[] = [ + { + name: "ollama", + baseURL: leftoverURL, + keyless: true, + models: ["llama3"], + defaultModel: "llama3", + }, + { + name: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "k", + models: ["gpt-4o"], + defaultModel: "gpt-4o", + }, + ]; + expect(() => + buildMainSessionSources({ + settings, + catalog: mixedCatalog, + activeProvider: "ollama", + activeModel: "llama3", + sessionId: "sess", + }), + ).toThrow('No inference source for provider "ollama"'); +}); + test("legacy ollama /v1 backup does not throw when OpenAI is active", () => { const settings: Settings = { providers: {