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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<root>/v1`; it does not
ask the user to enter `/v1` themselves. Model discovery sends
`GET <root>/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).
Expand Down
4 changes: 3 additions & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
19 changes: 15 additions & 4 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,17 +413,28 @@ 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
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
Expand Down
8 changes: 8 additions & 0 deletions packages/first-class-providers/src/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe("FIRST_CLASS_PROVIDERS", () => {
"zai",
"anthropic",
"google",
"ollama",
"custom",
]);
});
Expand Down Expand Up @@ -71,6 +72,13 @@ describe("FIRST_CLASS_PROVIDERS", () => {
}
});

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");
Expand Down
9 changes: 9 additions & 0 deletions packages/first-class-providers/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/first-class-providers/src/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
23 changes: 22 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1132,12 +1132,33 @@ describe("buildOpenAISource", () => {
});
});

test("substitutes a placeholder apiKey when none is provided (keyless)", () => {
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("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",
baseURL: "http://localhost:8080/v1",
model: "local-model",
});
expect(source.apiKey).toBe(KEYLESS_API_KEY);
});
});
Expand Down
5 changes: 4 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 18 additions & 6 deletions src/config/inference-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,14 @@ export function buildSourcesFromRefs(
const out: InferenceSource[] = [];
const seenIds = new Set<string>();
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);
Expand Down Expand Up @@ -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,
};
}

Expand Down
4 changes: 3 additions & 1 deletion src/provider/models-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ export async function requestModelsEndpoint(args: {
baseURL: string;
headers?: Record<string, string>;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<Response> {
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]),
});
}
Loading
Loading