Skip to content

Commit aaa5209

Browse files
committed
Offer Custom endpoint from the Alt+A add-provider selector
The add-provider list now includes Custom alongside first-class kinds so operators can connect a free-form endpoint without leaving the model picker.
1 parent 120a900 commit aaa5209

7 files changed

Lines changed: 90 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2121
`anthropic/work`, …). Reusing an existing name replaces that instance after
2222
an explicit confirm. Custom endpoints stay free-form and single-entry.
2323

24+
### TUI
25+
26+
- **Custom from Alt+A.** The add-provider selector now includes Custom alongside
27+
first-class kinds, so free-form OpenAI-compatible endpoints are reachable from
28+
the model picker without dropping into onboarding. Custom still uses the full
29+
manual form (name, base URL, key, model).
30+
2431
## [0.2.97] - 2026-08-10
2532

2633
Codex connect works again: streaming responses no longer die on a missing

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ Profiles supply per-project or named-profile overrides for `model`, `maxTurns`,
263263

264264
Providers and credentials are read exclusively from settings files: the global `~/.corbits/settings.json` (definitions + credentials) and the per-repo `.corbits/settings.json` (selection only). There are no `OPENAI_COMPATIBLE_*` environment-variable overrides, and `index.ts` does not load `.env` files — a deliberately stale or exported key can no longer shadow the configured provider.
265265

266-
**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat model list (Recent, Favorites, then provider groups) built by `buildModelsFirstList` (`src/tui/model-picker.ts`). **Alt+A** / **c** opens Connect; API-key first-class rows use an auth-only form (key only; catalog base URL is display-only). **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal; API-key providers pre-seed models and persist on save so selection works without restart. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG.
266+
**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat model list (Recent, Favorites, then provider groups) built by `buildModelsFirstList` (`src/tui/model-picker.ts`). **Alt+A** / **c** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom; API-key first-class rows use an auth-only form (key only; catalog base URL is display-only), while Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal; API-key providers pre-seed models and persist on save so selection works without restart. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG.
267267

268268
**OpenCode Go multi-protocol.** Each Go model carries protocol metadata (`chat-completions`, `responses`, or `messages`). `buildGoSource` / `resolveGoEndpoint` pick the adapter and base URL per model (not a single provider-wide OpenAI route). When Go is the active provider, subscription usage is fetched for the status bar and omitted on auth/network failure.
269269

docs/TUI.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,11 @@ Codex or xAI login) unreachable — OAuth accounts are per-profile, so
435435
kind-level "already connected" filtering hid the connect path the moment the
436436
first profile existed. **Alt+A** now opens `add_provider`
437437
(`src/tui/overlays.ts:openAddProviderOverlay`), a separate `PrimaryOverlayKind`
438-
listing every first-class provider kind from `providerChoices()` — OAuth and
439-
API-key alike — each annotated with its live connected-account count and none
440-
of them filtered out. Esc returns to the model list through the same
438+
listing every first-class provider kind from `providerChoices()` — OAuth,
439+
API-key, and Custom alike — each annotated with its live connected-account
440+
count and none of them filtered out. Custom uses the full manual form (name,
441+
base URL, key, model); first-class kinds keep their auth-only or browser
442+
login paths. Esc returns to the model list through the same
441443
`openModels()` entry point the picker itself uses. Picking a row runs the
442444
existing inline connect flow (`provider-connect.ts`); first-class kinds (OAuth
443445
and API-key) both ask for an instance/account name before auth so multiple

src/tui/product-host.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,7 @@ describe("flat type-to-filter model picker", () => {
612612
addProviderChoices: () => [
613613
{ id: "codex", label: "Codex", hint: "ChatGPT subscription", accountCount: 2 },
614614
{ id: "openai", label: "OpenAI", hint: "", accountCount: 0 },
615+
{ id: "custom", label: "Custom", hint: "any OpenAI-compatible endpoint", accountCount: 0 },
615616
],
616617
})
617618
try {
@@ -623,13 +624,39 @@ describe("flat type-to-filter model picker", () => {
623624
expect(host.shell.overlayItems).toEqual([
624625
"Codex — 2 accounts",
625626
"OpenAI — 0 accounts",
627+
"Custom — 0 accounts",
626628
])
627629
} finally {
628630
host.dispose()
629631
harness.destroy()
630632
}
631633
})
632634

635+
test("Enter on a Custom add-provider row runs the connect flow for custom", async () => {
636+
const connected: string[] = []
637+
const { harness, host } = await mountPicker({
638+
onConnectProvider: (name) => connected.push(name),
639+
addProviderChoices: () => [
640+
{ id: "openai", label: "OpenAI", hint: "", accountCount: 0 },
641+
{ id: "custom", label: "Custom", hint: "", accountCount: 0 },
642+
],
643+
})
644+
try {
645+
host.openModels?.()
646+
await harness.renderOnce()
647+
runOverlayAction(host.shell, altA)
648+
await harness.renderOnce()
649+
// Move to the Custom row (second item) and accept.
650+
moveOverlaySelection(host.shell, 1)
651+
acceptOverlaySelection(host.shell)
652+
expect(connected).toEqual(["custom"])
653+
} finally {
654+
host.dispose()
655+
harness.destroy()
656+
}
657+
})
658+
659+
633660
test("Esc from the add-provider selector returns to the model list", async () => {
634661
const { harness, host } = await mountPicker({
635662
onConnectProvider: () => {},

src/tui/provider-setup.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
22

33
import { createHarness, type Harness } from "./harness.js"
44
import {
5+
addProviderSelectorChoices,
56
connectedAccountCount,
67
CUSTOM_CHOICE_ID,
78
failureGuidance,
@@ -140,6 +141,25 @@ describe("provider setup pure helpers", () => {
140141
expect(providerChoiceRows(choices)[0]?.label).toContain("OpenAI")
141142
})
142143

144+
test("Alt+A selector rows include Custom and never filter by account count", () => {
145+
// Regression for CL-5899: a prior filter dropped Custom from Alt+A even
146+
// though onboarding still offered the full manual form. Connected kinds
147+
// also stay listed so a second account remains reachable.
148+
const choices = providerChoices()
149+
const rows = addProviderSelectorChoices(choices, [
150+
{ name: "openai" },
151+
{ name: "codex/default" },
152+
])
153+
expect(rows.map((r) => r.id)).toContain(CUSTOM_CHOICE_ID)
154+
expect(rows.map((r) => r.id)).toEqual(choices.map((c) => c.id))
155+
const openai = rows.find((r) => r.id === "openai")
156+
const codex = rows.find((r) => r.id === "codex")
157+
const custom = rows.find((r) => r.id === CUSTOM_CHOICE_ID)
158+
expect(openai?.accountCount).toBe(1)
159+
expect(codex?.accountCount).toBe(1)
160+
expect(custom?.accountCount).toBe(0)
161+
})
162+
143163
test("a connected Codex account counts under its profile-qualified name (CL-5606)", () => {
144164
// The ChatGPT-via-browser choice is keyed "codex", but a signed-in
145165
// account lands in the catalog as "codex/<profile>" — one row per

src/tui/provider-setup.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,29 @@ export function resolveApiKeyInstanceName(
423423
return compound
424424
}
425425

426+
/**
427+
* Rows for the model picker's Alt+A add-provider selector. Every first-class
428+
* kind is included, including Custom — filtering Custom out made free-form
429+
* endpoints unreachable from Alt+A even though onboarding still offered them.
430+
* Account counts use the same rules as the onboarding list.
431+
*/
432+
export function addProviderSelectorChoices(
433+
choices: readonly ProviderChoice[],
434+
providers: readonly { readonly name: string }[],
435+
): readonly {
436+
readonly id: string
437+
readonly label: string
438+
readonly hint: string
439+
readonly accountCount: number
440+
}[] {
441+
return choices.map((choice) => ({
442+
id: choice.id,
443+
label: choice.label,
444+
hint: choice.hint,
445+
accountCount: connectedAccountCount(choice, providers),
446+
}))
447+
}
448+
426449
/** Pick-list rows for the provider step. */
427450
export function providerChoiceRows(
428451
choices: readonly ProviderChoice[] = providerChoices(),

src/tui/runner.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
type LocalSettings,
4343
type PluginConfig,
4444
} from "../config/settings.js";
45-
import { connectedAccountCount, providerChoices } from "./provider-setup.js";
45+
import { addProviderSelectorChoices, providerChoices } from "./provider-setup.js";
4646
import { connectProviderInline } from "./provider-connect.js";
4747
import { modelOptionId } from "./model-catalog.js";
4848
import type { SessionModeScope } from "./command-surfaces.js";
@@ -2044,19 +2044,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
20442044
// Mount OpenTUI before the initial task is sent so gate and stream listeners
20452045
// are registered first. Ctrl+C stays with the shell (interrupt the run);
20462046
// OpenTUI owns the alternate screen and mouse reporting itself.
2047-
// Alt+A add-provider selector rows: every first-class provider kind, no
2048-
// already-connected filtering, so a second OAuth account is reachable once
2049-
// the first is already connected. Read fresh on each open against the live
2050-
// catalog.
2047+
// Alt+A add-provider selector rows: every first-class provider kind, including
2048+
// Custom (full manual form). No already-connected filtering — OAuth and
2049+
// multi-instance accounts are per-name, so dropping a kind once it has one
2050+
// account would hide the path to a second. Read fresh on each open against
2051+
// the live catalog.
20512052
const computeAddProviderChoices = () =>
2052-
providerChoices()
2053-
.filter((choice) => !choice.custom)
2054-
.map((choice) => ({
2055-
id: choice.id,
2056-
label: choice.label,
2057-
hint: choice.hint,
2058-
accountCount: connectedAccountCount(choice, config.providers),
2059-
}));
2053+
addProviderSelectorChoices(providerChoices(), config.providers);
20602054

20612055
const host = await mountRunnerHost({
20622056
// An unnamed session shows nothing rather than a placeholder.

0 commit comments

Comments
 (0)