Skip to content

Commit 120a900

Browse files
committed
Support unlimited named API-key provider instances
First-class API-key connects now ask for an instance name before the key, matching OAuth multi-account naming. Instances land as kind/slug catalog rows so a second key cannot silently overwrite the first; reusing a name confirms before replace.
1 parent 10a27a2 commit 120a900

8 files changed

Lines changed: 305 additions & 110 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Providers
17+
18+
- **Named API-key instances.** First-class API-key providers (OpenAI key,
19+
Anthropic, Google, OpenCode Zen/Go, Z.AI, …) ask for an instance name before
20+
the key, so personal and team keys can coexist (`openai/default`,
21+
`anthropic/work`, …). Reusing an existing name replaces that instance after
22+
an explicit confirm. Custom endpoints stay free-form and single-entry.
23+
1424
## [0.2.97] - 2026-08-10
1525

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

‎docs/PRODUCT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Continues from the last saved state in the working directory.
9292

9393
The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (open the agent configuration surface — connect providers with **c** / **Ctrl+A**, pick models, tiers, and profiles), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, and `/mcp`, plus a `/<name>` command per available workflow. Plugins can register additional commands.
9494

95-
Providers are **models-first**: there is no standalone `/login` command. `/model` opens on a **model list** (Recent, Favorites, then providers) so you pick a model without drilling provider first. **Alt+A** (or **c**) opens Connect; **Alt+F** toggles favorite on the highlighted model; **a** opens the advanced provider drill-down (edit/delete/tiers). Connect lists first-class providers (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom). OAuth providers open their existing browser login; API-key providers show an **auth-only** form (key + fixed catalog base URL), validate, and persist pre-seeded models for immediate selection. 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.
95+
Providers are **models-first**: there is no standalone `/login` command. `/model` opens on a **model list** (Recent, Favorites, then providers) so you pick a model without drilling provider first. **Alt+A** (or **c**) opens Connect; **Alt+F** toggles favorite on the highlighted model; **a** opens the advanced provider drill-down (edit/delete/tiers). Connect lists first-class providers (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom). 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. 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.
9696

9797
`/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-first (Recent / Favorites / Providers); connect, tiers, and profiles remain reachable from the same surface. A switch applies to the running session immediately (no restart), and 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).
9898

‎docs/TUI.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,11 @@ listing every first-class provider kind from `providerChoices()` — OAuth and
439439
API-key alike — each annotated with its live connected-account count and none
440440
of them filtered out. Esc returns to the model list through the same
441441
`openModels()` entry point the picker itself uses. Picking a row runs the
442-
existing inline connect flow (`provider-connect.ts`); on success the picker
443-
reopens focused on the new account's default model instead of the top of the
444-
list.
442+
existing inline connect flow (`provider-connect.ts`); first-class kinds (OAuth
443+
and API-key) both ask for an instance/account name before auth so multiple
444+
instances coexist as `kind/slug` catalog rows, and reusing a name confirms
445+
before re-auth or re-key. On success the picker reopens focused on the new
446+
account's default model instead of the top of the list.
445447

446448
Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and
447449
the satellite pickers used for session resume and session-mode selection

‎src/tui/onboarding.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise<number>
1919

2020
const submitted = await runProviderSetup({
2121
showTelemetryNotice,
22+
existingProviderNames: Object.keys(existing?.providers ?? {}),
2223
onSubmit: buildProviderSubmitHandler(settingsPath, existing, config.cwd),
2324
});
2425

‎src/tui/provider-connect.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ describe("connectProviderInline", () => {
2727
})
2828
await harness.renderOnce()
2929

30-
// initialProviderId lands directly on the api key step; leave it blank.
30+
// initialProviderId lands on the instance-name step first.
31+
harness.pressKey("Enter")
32+
await harness.renderOnce()
33+
// Leave the api key blank.
3134
harness.pressKey("Enter")
3235
await harness.renderOnce()
3336
// Model step: accept the default.

‎src/tui/provider-connect.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export async function connectProviderInline(
4040
const submitted = await runProviderSetup({
4141
showTelemetryNotice: false,
4242
initialProviderId: input.providerId,
43+
existingProviderNames: Object.keys(input.existing?.providers ?? {}),
4344
...(input.createRenderer !== undefined ? { createRenderer: input.createRenderer } : {}),
4445
...(input.startLogin !== undefined ? { startLogin: input.startLogin } : {}),
4546
onSubmit: async (values, setPhase, opts) => {

‎src/tui/provider-setup.test.ts‎

Lines changed: 135 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
connectedAccountCount,
66
CUSTOM_CHOICE_ID,
77
failureGuidance,
8+
instanceSlugsForKind,
89
LOGIN_CANCELLED_MESSAGE,
910
LOGIN_TIMEOUT_MESSAGE,
1011
maskEcho,
@@ -14,6 +15,7 @@ import {
1415
providerChoiceById,
1516
providerChoiceRows,
1617
providerChoices,
18+
resolveApiKeyInstanceName,
1719
runProviderSetup,
1820
secretFromMaskedEdit,
1921
stepHeadline,
@@ -78,12 +80,12 @@ describe("provider setup pure helpers", () => {
7880
expect(secretFromMaskedEdit(secret, "")).toBe("")
7981
})
8082

81-
test("a picked provider takes three steps, custom takes five, oauth takes four", () => {
83+
test("a picked API-key provider names an instance before the key; custom and oauth keep their shapes", () => {
8284
const openai = providerChoiceById("openai")
8385
expect(openai?.baseURL).toBe("https://api.openai.com/v1")
84-
expect(stepsFor(openai ?? null)).toEqual(["provider", "apiKey", "model"])
85-
// A subscription provider swaps the paste for a name-then-sign-in pair,
86-
// so it lands one step longer than a preset.
86+
// Multi-instance API-key path: pick, name, key, model.
87+
expect(stepsFor(openai ?? null)).toEqual(["provider", "name", "apiKey", "model"])
88+
// A subscription provider swaps the paste for a name-then-sign-in pair.
8789
expect(stepsFor(providerChoiceById("codex") ?? null)).toEqual([
8890
"provider",
8991
"name",
@@ -151,11 +153,36 @@ describe("provider setup pure helpers", () => {
151153
expect(connectedAccountCount(codexChoice, [])).toBe(0)
152154
})
153155

154-
test("connectedAccountCount does not prefix-match key-based providers", () => {
156+
test("connected API-key instances count under kind and kind/slug", () => {
157+
// CL-5898: first-class API-key kinds are multi-instance. A bare "openai"
158+
// key is the legacy single-instance row; "openai/work" is a sibling.
159+
// Unrelated names like "openai-eu" must not count.
155160
const openaiChoice = providerChoiceById("openai")
156161
if (openaiChoice === undefined) throw new Error("expected an openai choice")
157162
expect(connectedAccountCount(openaiChoice, [{ name: "openai-eu" }])).toBe(0)
158163
expect(connectedAccountCount(openaiChoice, [{ name: "openai" }])).toBe(1)
164+
expect(
165+
connectedAccountCount(openaiChoice, [
166+
{ name: "openai" },
167+
{ name: "openai/work" },
168+
{ name: "openai-eu" },
169+
]),
170+
).toBe(2)
171+
})
172+
173+
test("instance slug helpers map legacy bare keys and compound names", () => {
174+
expect(instanceSlugsForKind("openai", [])).toEqual([])
175+
expect(instanceSlugsForKind("openai", ["openai"])).toEqual(["default"])
176+
expect(instanceSlugsForKind("openai", ["openai", "openai/work", "anthropic"])).toEqual([
177+
"default",
178+
"work",
179+
])
180+
expect(resolveApiKeyInstanceName("openai", "work", [])).toBe("openai/work")
181+
expect(resolveApiKeyInstanceName("openai", "default", ["openai"])).toBe("openai")
182+
expect(resolveApiKeyInstanceName("openai", "default", ["openai/default"])).toBe(
183+
"openai/default",
184+
)
185+
expect(resolveApiKeyInstanceName("openai", "default", [])).toBe("openai/default")
159186
})
160187

161188
test("model rows come from the provider catalog plus a free-text escape", () => {
@@ -185,6 +212,14 @@ describe("provider setup pure helpers", () => {
185212
expect(rows[1]).toMatchObject({ label: "account name", value: "work" })
186213
})
187214

215+
test("the API-key name step is headlined and summarized as an account name", () => {
216+
const openai = providerChoiceById("openai") ?? null
217+
const steps = stepsFor(openai)
218+
expect(stepHeadline(steps, 1, openai)).toBe("step 2 of 4 · account name")
219+
const rows = summaryRows(steps, 2, { ...EMPTY, oauthProfile: "work" }, openai)
220+
expect(rows[1]).toMatchObject({ label: "account name", value: "work" })
221+
})
222+
188223
test("summary rows mark done, current, and pending steps", () => {
189224
const values: ProviderFormValues = { ...EMPTY, name: "openai" }
190225
const choice = providerChoiceById("openai") ?? null
@@ -216,11 +251,13 @@ describe("provider setup pure helpers", () => {
216251
async function mountSetup(
217252
onSubmit: ProviderSetupSubmit = async () => {},
218253
showTelemetryNotice = false,
254+
existingProviderNames: readonly string[] = [],
219255
): Promise<{ done: Promise<boolean>; harness: Harness }> {
220256
const harness = await createHarness({ width: 80, height: 30 })
221257
const done = runProviderSetup({
222258
onSubmit,
223259
showTelemetryNotice,
260+
existingProviderNames,
224261
createRenderer: async () => harness.renderer,
225262
})
226263
await harness.renderOnce()
@@ -252,9 +289,13 @@ async function pickRow(
252289

253290
const PROVIDER_IDS = providerChoiceRows().map((r) => r.id)
254291

255-
/** Pick OpenAI, type a key, accept its default model. */
292+
/** Pick OpenAI, accept the suggested instance name, type a key, accept model. */
256293
async function connectOpenAI(harness: Harness, key = "sk-key"): Promise<void> {
257294
await pickRow(harness, PROVIDER_IDS, "openai")
295+
await flush(harness)
296+
// Suggested slug is "default" when no instances exist yet.
297+
harness.pressKey("Enter")
298+
await harness.renderOnce()
258299
type(harness, key)
259300
harness.pressKey("Enter")
260301
await harness.renderOnce()
@@ -677,38 +718,42 @@ describe("runProviderSetup", () => {
677718
await harness.renderOnce()
678719
const frame = harness.captureCharFrame()
679720
expect(frame).toContain("setup")
680-
expect(frame).toContain("step 1 of 3")
721+
expect(frame).toContain("step 1 of 4")
681722
expect(frame).toContain("OpenAI")
682723
expect(frame).toContain("Custom")
683724
harness.pressKey("Ctrl+C")
684725
expect(await done).toBe(false)
685726
})
686727

687-
test("picking a known provider prefills base URL and model", async () => {
728+
test("picking a known provider names an instance then takes a key", async () => {
688729
const seen: ProviderFormValues[] = []
689730
const opts: SubmitOpts[] = []
690731
const { done, harness } = await mountSetup(async (values, _phase, o) => {
691732
seen.push({ ...values })
692733
opts.push(o)
693734
})
694735
await pickRow(harness, PROVIDER_IDS, "openai")
695-
// Two steps left: only the key is typed.
696-
expect(harness.captureCharFrame()).toContain("step 2 of 3")
736+
await flush(harness)
737+
expect(harness.captureCharFrame()).toContain("step 2 of 4")
738+
// Accept suggested "default" instance name.
739+
harness.pressKey("Enter")
740+
await harness.renderOnce()
741+
expect(harness.captureCharFrame()).toContain("step 3 of 4")
697742
type(harness, "sk-key")
698743
harness.pressKey("Enter")
699744
await harness.renderOnce()
700-
expect(harness.captureCharFrame()).toContain("step 3 of 3")
745+
expect(harness.captureCharFrame()).toContain("step 4 of 4")
701746
harness.pressKey("Enter")
702747
await harness.renderOnce()
703748

704749
expect(await done).toBe(true)
705750
const openai = providerChoiceById("openai")
706751
expect(seen[0]).toEqual({
707-
name: "openai",
752+
name: "openai/default",
708753
baseURL: "https://api.openai.com/v1",
709754
apiKey: "sk-key",
710755
model: openai?.defaultModel ?? "",
711-
oauthProfile: "",
756+
oauthProfile: "default",
712757
})
713758
expect(opts[0]?.preset?.id).toBe("openai")
714759
expect(opts[0]?.preset?.models.length).toBeGreaterThan(1)
@@ -750,6 +795,9 @@ describe("runProviderSetup", () => {
750795
seen.push({ ...values })
751796
})
752797
await pickRow(harness, PROVIDER_IDS, "openai")
798+
await flush(harness)
799+
harness.pressKey("Enter")
800+
await harness.renderOnce()
753801
type(harness, "sk-key")
754802
harness.pressKey("Enter")
755803
await harness.renderOnce()
@@ -763,6 +811,7 @@ describe("runProviderSetup", () => {
763811
await harness.renderOnce()
764812
expect(await done).toBe(true)
765813
expect(seen[0]?.model).toBe("gpt-4o")
814+
expect(seen[0]?.name).toBe("openai/default")
766815
})
767816

768817
test("shows the telemetry notice only when asked to", async () => {
@@ -792,17 +841,17 @@ describe("runProviderSetup", () => {
792841
test("Escape goes back a step", async () => {
793842
const { done, harness } = await mountSetup()
794843
await pickRow(harness, PROVIDER_IDS, "openai")
795-
expect(harness.captureCharFrame()).toContain("step 2 of 3")
844+
expect(harness.captureCharFrame()).toContain("step 2 of 4")
796845
await pressEscape(harness)
797-
expect(harness.captureCharFrame()).toContain("step 1 of 3")
846+
expect(harness.captureCharFrame()).toContain("step 1 of 4")
798847
harness.pressKey("Ctrl+C")
799848
await done
800849
})
801850

802851
test("Escape on the first step stays put", async () => {
803852
const { done, harness } = await mountSetup()
804853
await pressEscape(harness)
805-
expect(harness.captureCharFrame()).toContain("step 1 of 3")
854+
expect(harness.captureCharFrame()).toContain("step 1 of 4")
806855
harness.pressKey("Ctrl+C")
807856
await done
808857
})
@@ -821,6 +870,9 @@ describe("runProviderSetup", () => {
821870
test("the typed API key is never painted in the clear", async () => {
822871
const { done, harness } = await mountSetup()
823872
await pickRow(harness, PROVIDER_IDS, "openai")
873+
await flush(harness)
874+
harness.pressKey("Enter")
875+
await harness.renderOnce()
824876
type(harness, "sk-secret")
825877
await harness.renderOnce()
826878
const frame = harness.captureCharFrame()
@@ -907,11 +959,70 @@ describe("runProviderSetup", () => {
907959
submits += 1
908960
})
909961
await pickRow(harness, PROVIDER_IDS, "openai")
962+
await flush(harness)
910963
type(harness, "sk-key")
911964
harness.pressKey("Ctrl+C")
912965
expect(await done).toBe(false)
913966
expect(submits).toBe(0)
914967
})
968+
969+
test("a second API-key instance gets a compound name without overwriting the first", async () => {
970+
const seen: ProviderFormValues[] = []
971+
const { done, harness } = await mountSetup(
972+
async (values) => {
973+
seen.push({ ...values })
974+
},
975+
false,
976+
["openai/default"],
977+
)
978+
await pickRow(harness, PROVIDER_IDS, "openai")
979+
await flush(harness)
980+
// Existing "default" forces suggested "default-2".
981+
expect(harness.captureCharFrame()).toContain("default-2")
982+
harness.pressKey("Enter")
983+
await harness.renderOnce()
984+
type(harness, "sk-work")
985+
harness.pressKey("Enter")
986+
await harness.renderOnce()
987+
harness.pressKey("Enter")
988+
await harness.renderOnce()
989+
expect(await done).toBe(true)
990+
expect(seen[0]?.name).toBe("openai/default-2")
991+
expect(seen[0]?.oauthProfile).toBe("default-2")
992+
expect(seen[0]?.apiKey).toBe("sk-work")
993+
})
994+
995+
test("reusing an existing API-key instance name requires confirm before replace", async () => {
996+
const seen: ProviderFormValues[] = []
997+
const { done, harness } = await mountSetup(
998+
async (values) => {
999+
seen.push({ ...values })
1000+
},
1001+
false,
1002+
["openai"],
1003+
)
1004+
await pickRow(harness, PROVIDER_IDS, "openai")
1005+
await flush(harness)
1006+
// Clear suggested "default-2" and type the legacy bare-key slug "default".
1007+
for (let i = 0; i < 80; i++) harness.pressKey("Backspace")
1008+
type(harness, "default")
1009+
harness.pressKey("Enter")
1010+
await flush(harness)
1011+
expect(harness.captureCharFrame()).toContain("already connected")
1012+
// Confirm replace.
1013+
harness.pressKey("Enter")
1014+
await harness.renderOnce()
1015+
type(harness, "sk-replaced")
1016+
harness.pressKey("Enter")
1017+
await harness.renderOnce()
1018+
harness.pressKey("Enter")
1019+
await harness.renderOnce()
1020+
expect(await done).toBe(true)
1021+
// Legacy bare key is updated in place rather than rewritten as openai/default.
1022+
expect(seen[0]?.name).toBe("openai")
1023+
expect(seen[0]?.oauthProfile).toBe("default")
1024+
expect(seen[0]?.apiKey).toBe("sk-replaced")
1025+
})
9151026
})
9161027

9171028
/**
@@ -920,7 +1031,7 @@ describe("runProviderSetup", () => {
9201031
* handler is registered would pass while paste was broken.
9211032
*/
9221033
describe("runProviderSetup paste", () => {
923-
/** Pick OpenAI, paste `key`, accept the default model, return what was saved. */
1034+
/** Pick OpenAI, accept the instance name, paste `key`, accept default model. */
9241035
async function pasteKey(
9251036
key: string,
9261037
): Promise<{ values: ProviderFormValues | null; frame: string }> {
@@ -930,6 +1041,9 @@ describe("runProviderSetup paste", () => {
9301041
})
9311042
try {
9321043
await pickRow(harness, PROVIDER_IDS, "openai")
1044+
await flush(harness)
1045+
harness.pressKey("Enter")
1046+
await harness.renderOnce()
9331047
await harness.mockInput.pasteBracketedText(key)
9341048
await harness.renderOnce()
9351049
const frame = harness.captureCharFrame()
@@ -984,7 +1098,7 @@ describe("runProviderSetup pick-list height cap", () => {
9841098
// The garbled-overlap bug glued the step line and the intro line
9851099
// together on one row; each survives as its own line, or is clipped
9861100
// entirely, but never merges into the other.
987-
const stepLine = lines.find((l) => l.includes("step 1 of 3"))
1101+
const stepLine = lines.find((l) => l.includes("step 1 of 4"))
9881102
if (stepLine !== undefined) {
9891103
expect(stepLine).not.toContain("connect an inference provider")
9901104
}
@@ -1025,6 +1139,9 @@ describe("runProviderSetup pick-list height cap", () => {
10251139
await harness.renderOnce()
10261140
await harness.renderOnce()
10271141
await pickRow(harness, PROVIDER_IDS, "openai")
1142+
await flush(harness)
1143+
harness.pressKey("Enter")
1144+
await harness.renderOnce()
10281145
type(harness, "sk-key")
10291146
harness.pressKey("Enter")
10301147
await harness.renderOnce()

0 commit comments

Comments
 (0)