Skip to content

Commit 3202fe5

Browse files
committed
Add first-class Ollama provider
1 parent 3c887ac commit 3202fe5

17 files changed

Lines changed: 557 additions & 21 deletions

docs/IMPLEMENTATION.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,23 @@ OpenAI-compatible `baseURL` values are normalized during provider resolution. A
282282

283283
`--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.
284284

285+
### Ollama Provider Setup
286+
287+
Ollama is a first-class provider with no API-key field. Its setup form accepts an
288+
editable server root and defaults it to `http://localhost:11434`. Corbits
289+
projects that root to the OpenAI-compatible base URL `<root>/v1`; it does not
290+
ask the user to enter `/v1` themselves. Model discovery sends
291+
`GET <root>/v1/models` and uses the returned catalog rather than a static model
292+
list.
293+
294+
Discovery failures preserve three separate states. A request that cannot reach
295+
Ollama is expected and nonfatal, leaving setup available to edit the root or
296+
retry after starting the server. A reachable server whose catalog is empty
297+
instructs the user to pull at least one model before retrying. A reachable
298+
response that cannot be parsed as the model catalog is reported as malformed,
299+
not as an unavailable or empty server. Corbits documents only those runtime
300+
prerequisites; Ollama installation remains outside this flow.
301+
285302
### Profiles (`src/config/profiles.ts`)
286303

287304
Profiles supply per-project or named-profile overrides for `model` and `systemPromptExtensions` (the only allowed keys; any other key is rejected on load).

docs/PRODUCT.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ The TUI has an extensible slash-command framework. Built-ins include `/help` (sh
107107

108108
**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.
109109

110-
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.
110+
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.
111+
112+
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.
111113

112114
`/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).
113115

docs/TUI.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,17 +413,28 @@ kind-level "already connected" filtering hid the connect path the moment the
413413
first profile existed. **Alt+A** now opens `add_provider`
414414
(`src/tui/overlays.ts:openAddProviderOverlay`), a separate `PrimaryOverlayKind`
415415
listing every first-class provider kind from `providerChoices()` — OAuth,
416-
API-key, and Custom alike — each annotated with its live connected-account
417-
count and none of them filtered out. Custom uses the full manual form (name,
418-
base URL, key, model); first-class kinds keep their auth-only or browser
419-
login paths. Esc returns to the model list through the same
416+
API-key, keyless local, and Custom alike — each annotated with its live
417+
connected-account count and none of them filtered out. Custom uses the full
418+
manual form (name, base URL, key, model); OAuth and API-key kinds keep their
419+
auth-only or browser login paths, while Ollama has a keyless local setup path.
420+
Esc returns to the model list through the same
420421
`openModels()` entry point the picker itself uses. Picking a row runs the
421422
existing inline connect flow (`provider-connect.ts`); first-class kinds (OAuth
422423
and API-key) both ask for an instance/account name before auth so multiple
423424
instances coexist as `kind/slug` catalog rows, and reusing a name confirms
424425
before re-auth or re-key. On success the picker reopens focused on the new
425426
account's default model instead of the top of the list.
426427

428+
Ollama setup is keyless and starts with an editable server root, defaulting to
429+
`http://localhost:11434`. Continuing discovers models dynamically from that
430+
server rather than presenting a fixed catalog. If the server is unreachable,
431+
the setup stays open and treats that as an expected local-availability state:
432+
the user can start Ollama, edit the root, or retry. A reachable server with no
433+
models instead explains that at least one model must be pulled before retrying;
434+
a reachable response with an invalid shape is reported separately as malformed,
435+
not collapsed into either an empty catalog or a connection failure. These are
436+
recovery instructions, not an Ollama installation tutorial.
437+
427438
Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and
428439
the satellite pickers used for session resume and session-mode selection
429440
(`src/tui/list-modal.ts:runListModal`) deliberately do not enable DEC

packages/first-class-providers/src/providers.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe("FIRST_CLASS_PROVIDERS", () => {
1616
"zai",
1717
"anthropic",
1818
"google",
19+
"ollama",
1920
"custom",
2021
]);
2122
});
@@ -65,12 +66,20 @@ describe("FIRST_CLASS_PROVIDERS", () => {
6566
for (const def of FIRST_CLASS_PROVIDERS) {
6667
if (def.auth !== "api-key") continue;
6768
expect(def.baseURL?.length ?? 0).toBeGreaterThan(0);
69+
if (def.id === "ollama") continue;
6870
expect((def.models ?? []).length).toBeGreaterThan(0);
6971
expect(def.defaultModel?.length ?? 0).toBeGreaterThan(0);
7072
expect(def.models).toContain(def.defaultModel);
7173
}
7274
});
7375

76+
test("Ollama is explicitly keyless and ships its root URL with no static model fallback", () => {
77+
const ollama = firstClassProviderById("ollama");
78+
expect(ollama?.auth).toBe("keyless");
79+
expect(ollama?.baseURL).toBe("http://localhost:11434");
80+
expect(ollama?.models).toEqual([]);
81+
});
82+
7483
test("Z.AI Coding Plan uses coding paas base URL", () => {
7584
const zai = firstClassProviderById("zai");
7685
expect(zai?.label).toBe("Z.AI Coding Plan");

packages/first-class-providers/src/providers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
104104
defaultModel: "gemini-2.5-pro",
105105
authHint: "Paste your Google AI Studio API key",
106106
},
107+
{
108+
id: "ollama",
109+
label: "Ollama",
110+
auth: "keyless",
111+
baseURL: "http://localhost:11434",
112+
models: [],
113+
defaultModel: "",
114+
authHint: "Local provider — Ollama must be running",
115+
},
107116
{
108117
id: "custom",
109118
label: "Custom",

packages/first-class-providers/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type FirstClassAuthKind = "oauth" | "api-key" | "chooser" | "custom";
1+
export type FirstClassAuthKind = "oauth" | "api-key" | "keyless" | "chooser" | "custom";
22

33
export type FirstClassOAuthProvider = "codex" | "xai";
44

src/config.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,11 +1132,22 @@ describe("buildOpenAISource", () => {
11321132
});
11331133
});
11341134

1135+
test("projects an Ollama root URL to the OpenAI-compatible /v1 endpoint", () => {
1136+
const source = buildOpenAISource({
1137+
id: "ollama/default",
1138+
baseURL: "http://localhost:11434",
1139+
model: "qwen3",
1140+
});
1141+
1142+
expect(source.provider).toBe("openai-compatible");
1143+
expect(source.baseURL).toBe("http://localhost:11434/v1");
1144+
});
1145+
11351146
test("substitutes a placeholder apiKey when none is provided (keyless)", () => {
11361147
const source = buildOpenAISource({
1137-
id: "ollama",
1138-
baseURL: "http://localhost:11434/v1",
1139-
model: "llama3",
1148+
id: "local",
1149+
baseURL: "http://localhost:8080/v1",
1150+
model: "local-model",
11401151
});
11411152
expect(source.apiKey).toBe(KEYLESS_API_KEY);
11421153
});

src/config/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
GROK_USER_ID_OPTION,
3939
} from "../provider/grok-responses-adapter.js";
4040
import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js";
41+
import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js";
4142
import {
4243
OPENAI_RESPONSES_PROVIDER,
4344
OPENAI_SESSION_ID_OPTION,
@@ -183,7 +184,9 @@ export function buildOpenAISource(fields: {
183184
return {
184185
id: fields.id,
185186
provider: "openai-compatible",
186-
baseURL: normalizeOpenAICompatibleBaseURL(fields.baseURL),
187+
baseURL: isOllamaProviderId(fields.id)
188+
? ollamaOpenAIBaseURL(fields.baseURL)
189+
: normalizeOpenAICompatibleBaseURL(fields.baseURL),
187190
apiKey:
188191
fields.apiKey !== undefined && fields.apiKey.length > 0 ? fields.apiKey : KEYLESS_API_KEY,
189192
model: fields.model,

src/provider/models-endpoint.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ export async function requestModelsEndpoint(args: {
1313
baseURL: string;
1414
headers?: Record<string, string>;
1515
timeoutMs?: number;
16+
signal?: AbortSignal;
1617
}): Promise<Response> {
18+
const timeout = AbortSignal.timeout(args.timeoutMs ?? DEFAULT_MODELS_REQUEST_TIMEOUT_MS);
1719
return fetch(modelsEndpointURL(args.baseURL), {
1820
method: "GET",
1921
headers: args.headers ?? {},
20-
signal: AbortSignal.timeout(args.timeoutMs ?? DEFAULT_MODELS_REQUEST_TIMEOUT_MS),
22+
signal: args.signal === undefined ? timeout : AbortSignal.any([args.signal, timeout]),
2123
});
2224
}

src/provider/ollama.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
3+
import {
4+
discoverOllamaModels,
5+
isOllamaProviderId,
6+
ollamaOpenAIBaseURL,
7+
type OllamaDiscoveryState,
8+
} from "./ollama.js";
9+
10+
const originalFetch = globalThis.fetch;
11+
12+
afterEach(() => {
13+
globalThis.fetch = originalFetch;
14+
});
15+
16+
describe("Ollama provider identity", () => {
17+
test("recognizes the reserved family without matching unrelated keyless providers", () => {
18+
expect(isOllamaProviderId("ollama")).toBe(true);
19+
expect(isOllamaProviderId("ollama/workstation")).toBe(true);
20+
expect(isOllamaProviderId("local")).toBe(false);
21+
expect(isOllamaProviderId("ollama-cloud")).toBe(false);
22+
});
23+
});
24+
25+
describe("ollamaOpenAIBaseURL", () => {
26+
test("projects a root URL to exactly one /v1", () => {
27+
expect(ollamaOpenAIBaseURL("http://localhost:11434")).toBe("http://localhost:11434/v1");
28+
expect(ollamaOpenAIBaseURL("http://localhost:11434/")).toBe("http://localhost:11434/v1");
29+
});
30+
31+
test("rejects non-root paths instead of ambiguously appending /v1", () => {
32+
expect(() => ollamaOpenAIBaseURL("http://localhost:11434/v1")).toThrow(
33+
"expected a server root without a path",
34+
);
35+
expect(() => ollamaOpenAIBaseURL("http://localhost:11434/team")).toThrow(
36+
"expected a server root without a path",
37+
);
38+
});
39+
});
40+
41+
describe("discoverOllamaModels", () => {
42+
test("requests the OpenAI models endpoint and validates model ids", async () => {
43+
const fetchMock = async (input: RequestInfo | URL, init?: RequestInit) => {
44+
expect(String(input)).toBe("http://localhost:11434/v1/models");
45+
expect(init?.method).toBe("GET");
46+
return Response.json({ data: [{ id: "qwen3" }, { id: "deepseek-r1" }] });
47+
};
48+
globalThis.fetch = fetchMock as unknown as typeof fetch;
49+
50+
await expect(discoverOllamaModels({ rootURL: "http://localhost:11434/" })).resolves.toEqual({
51+
status: "models",
52+
models: ["qwen3", "deepseek-r1"],
53+
});
54+
});
55+
56+
test("distinguishes empty, unavailable, HTTP, and malformed responses", async () => {
57+
const cases: {
58+
response: () => Promise<Response>;
59+
expected: OllamaDiscoveryState["status"];
60+
}[] = [
61+
{ response: async () => Response.json({ data: [] }), expected: "empty" },
62+
{ response: async () => new Response("no", { status: 503 }), expected: "unavailable" },
63+
{ response: async () => Response.json({ models: [] }), expected: "malformed" },
64+
];
65+
66+
for (const item of cases) {
67+
globalThis.fetch = item.response as unknown as typeof fetch;
68+
expect((await discoverOllamaModels({ rootURL: "http://localhost:11434" })).status).toBe(
69+
item.expected,
70+
);
71+
}
72+
73+
globalThis.fetch = (async () => {
74+
throw new Error("connection refused");
75+
}) as unknown as typeof fetch;
76+
expect((await discoverOllamaModels({ rootURL: "http://localhost:11434" })).status).toBe(
77+
"unavailable",
78+
);
79+
});
80+
});

0 commit comments

Comments
 (0)