From 4fa0dd10437781d1f3b6ea71810267c9c65b9e65 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 11:28:35 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 10 ++ docs/PRODUCT.md | 2 +- docs/TUI.md | 8 +- src/tui/onboarding.ts | 1 + src/tui/provider-connect.test.ts | 5 +- src/tui/provider-connect.ts | 1 + src/tui/provider-setup.test.ts | 153 +++++++++++++++++--- src/tui/provider-setup.ts | 235 +++++++++++++++++++------------ 8 files changed, 305 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9bd8d1d..16b8d2efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [Unreleased] + +### Providers + +- **Named API-key instances.** First-class API-key providers (OpenAI key, + Anthropic, Google, OpenCode Zen/Go, Z.AI, …) ask for an instance name before + the key, so personal and team keys can coexist (`openai/default`, + `anthropic/work`, …). Reusing an existing name replaces that instance after + an explicit confirm. Custom endpoints stay free-form and single-entry. + ## [0.2.97] - 2026-08-10 Codex connect works again: streaming responses no longer die on a missing diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 9d3719e4d..601f73dd1 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -92,7 +92,7 @@ Continues from the last saved state in the working directory. 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 `/` command per available workflow. Plugins can register additional commands. -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. +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. `/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). diff --git a/docs/TUI.md b/docs/TUI.md index bac6a0c3d..de97903d4 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -439,9 +439,11 @@ listing every first-class provider kind from `providerChoices()` — OAuth and API-key alike — each annotated with its live connected-account count and none of them filtered out. 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`); on success the picker -reopens focused on the new account's default model instead of the top of the -list. +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. Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and the satellite pickers used for session resume and session-mode selection diff --git a/src/tui/onboarding.ts b/src/tui/onboarding.ts index 3bcb9f31f..c40625b07 100644 --- a/src/tui/onboarding.ts +++ b/src/tui/onboarding.ts @@ -19,6 +19,7 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise const submitted = await runProviderSetup({ showTelemetryNotice, + existingProviderNames: Object.keys(existing?.providers ?? {}), onSubmit: buildProviderSubmitHandler(settingsPath, existing, config.cwd), }); diff --git a/src/tui/provider-connect.test.ts b/src/tui/provider-connect.test.ts index 8bbc153a1..1ab154e9f 100644 --- a/src/tui/provider-connect.test.ts +++ b/src/tui/provider-connect.test.ts @@ -27,7 +27,10 @@ describe("connectProviderInline", () => { }) await harness.renderOnce() - // initialProviderId lands directly on the api key step; leave it blank. + // initialProviderId lands on the instance-name step first. + harness.pressKey("Enter") + await harness.renderOnce() + // Leave the api key blank. harness.pressKey("Enter") await harness.renderOnce() // Model step: accept the default. diff --git a/src/tui/provider-connect.ts b/src/tui/provider-connect.ts index 602fe7dd3..4f8630ce3 100644 --- a/src/tui/provider-connect.ts +++ b/src/tui/provider-connect.ts @@ -40,6 +40,7 @@ export async function connectProviderInline( const submitted = await runProviderSetup({ showTelemetryNotice: false, initialProviderId: input.providerId, + existingProviderNames: Object.keys(input.existing?.providers ?? {}), ...(input.createRenderer !== undefined ? { createRenderer: input.createRenderer } : {}), ...(input.startLogin !== undefined ? { startLogin: input.startLogin } : {}), onSubmit: async (values, setPhase, opts) => { diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index 278714e7d..582dccec2 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -5,6 +5,7 @@ import { connectedAccountCount, CUSTOM_CHOICE_ID, failureGuidance, + instanceSlugsForKind, LOGIN_CANCELLED_MESSAGE, LOGIN_TIMEOUT_MESSAGE, maskEcho, @@ -14,6 +15,7 @@ import { providerChoiceById, providerChoiceRows, providerChoices, + resolveApiKeyInstanceName, runProviderSetup, secretFromMaskedEdit, stepHeadline, @@ -78,12 +80,12 @@ describe("provider setup pure helpers", () => { expect(secretFromMaskedEdit(secret, "")).toBe("") }) - test("a picked provider takes three steps, custom takes five, oauth takes four", () => { + test("a picked API-key provider names an instance before the key; custom and oauth keep their shapes", () => { const openai = providerChoiceById("openai") expect(openai?.baseURL).toBe("https://api.openai.com/v1") - expect(stepsFor(openai ?? null)).toEqual(["provider", "apiKey", "model"]) - // A subscription provider swaps the paste for a name-then-sign-in pair, - // so it lands one step longer than a preset. + // Multi-instance API-key path: pick, name, key, model. + expect(stepsFor(openai ?? null)).toEqual(["provider", "name", "apiKey", "model"]) + // A subscription provider swaps the paste for a name-then-sign-in pair. expect(stepsFor(providerChoiceById("codex") ?? null)).toEqual([ "provider", "name", @@ -151,11 +153,36 @@ describe("provider setup pure helpers", () => { expect(connectedAccountCount(codexChoice, [])).toBe(0) }) - test("connectedAccountCount does not prefix-match key-based providers", () => { + test("connected API-key instances count under kind and kind/slug", () => { + // CL-5898: first-class API-key kinds are multi-instance. A bare "openai" + // key is the legacy single-instance row; "openai/work" is a sibling. + // Unrelated names like "openai-eu" must not count. const openaiChoice = providerChoiceById("openai") if (openaiChoice === undefined) throw new Error("expected an openai choice") expect(connectedAccountCount(openaiChoice, [{ name: "openai-eu" }])).toBe(0) expect(connectedAccountCount(openaiChoice, [{ name: "openai" }])).toBe(1) + expect( + connectedAccountCount(openaiChoice, [ + { name: "openai" }, + { name: "openai/work" }, + { name: "openai-eu" }, + ]), + ).toBe(2) + }) + + test("instance slug helpers map legacy bare keys and compound names", () => { + expect(instanceSlugsForKind("openai", [])).toEqual([]) + expect(instanceSlugsForKind("openai", ["openai"])).toEqual(["default"]) + expect(instanceSlugsForKind("openai", ["openai", "openai/work", "anthropic"])).toEqual([ + "default", + "work", + ]) + expect(resolveApiKeyInstanceName("openai", "work", [])).toBe("openai/work") + expect(resolveApiKeyInstanceName("openai", "default", ["openai"])).toBe("openai") + expect(resolveApiKeyInstanceName("openai", "default", ["openai/default"])).toBe( + "openai/default", + ) + expect(resolveApiKeyInstanceName("openai", "default", [])).toBe("openai/default") }) test("model rows come from the provider catalog plus a free-text escape", () => { @@ -185,6 +212,14 @@ describe("provider setup pure helpers", () => { expect(rows[1]).toMatchObject({ label: "account name", value: "work" }) }) + test("the API-key name step is headlined and summarized as an account name", () => { + const openai = providerChoiceById("openai") ?? null + const steps = stepsFor(openai) + expect(stepHeadline(steps, 1, openai)).toBe("step 2 of 4 · account name") + const rows = summaryRows(steps, 2, { ...EMPTY, oauthProfile: "work" }, openai) + expect(rows[1]).toMatchObject({ label: "account name", value: "work" }) + }) + test("summary rows mark done, current, and pending steps", () => { const values: ProviderFormValues = { ...EMPTY, name: "openai" } const choice = providerChoiceById("openai") ?? null @@ -216,11 +251,13 @@ describe("provider setup pure helpers", () => { async function mountSetup( onSubmit: ProviderSetupSubmit = async () => {}, showTelemetryNotice = false, + existingProviderNames: readonly string[] = [], ): Promise<{ done: Promise; harness: Harness }> { const harness = await createHarness({ width: 80, height: 30 }) const done = runProviderSetup({ onSubmit, showTelemetryNotice, + existingProviderNames, createRenderer: async () => harness.renderer, }) await harness.renderOnce() @@ -252,9 +289,13 @@ async function pickRow( const PROVIDER_IDS = providerChoiceRows().map((r) => r.id) -/** Pick OpenAI, type a key, accept its default model. */ +/** Pick OpenAI, accept the suggested instance name, type a key, accept model. */ async function connectOpenAI(harness: Harness, key = "sk-key"): Promise { await pickRow(harness, PROVIDER_IDS, "openai") + await flush(harness) + // Suggested slug is "default" when no instances exist yet. + harness.pressKey("Enter") + await harness.renderOnce() type(harness, key) harness.pressKey("Enter") await harness.renderOnce() @@ -677,14 +718,14 @@ describe("runProviderSetup", () => { await harness.renderOnce() const frame = harness.captureCharFrame() expect(frame).toContain("setup") - expect(frame).toContain("step 1 of 3") + expect(frame).toContain("step 1 of 4") expect(frame).toContain("OpenAI") expect(frame).toContain("Custom") harness.pressKey("Ctrl+C") expect(await done).toBe(false) }) - test("picking a known provider prefills base URL and model", async () => { + test("picking a known provider names an instance then takes a key", async () => { const seen: ProviderFormValues[] = [] const opts: SubmitOpts[] = [] const { done, harness } = await mountSetup(async (values, _phase, o) => { @@ -692,23 +733,27 @@ describe("runProviderSetup", () => { opts.push(o) }) await pickRow(harness, PROVIDER_IDS, "openai") - // Two steps left: only the key is typed. - expect(harness.captureCharFrame()).toContain("step 2 of 3") + await flush(harness) + expect(harness.captureCharFrame()).toContain("step 2 of 4") + // Accept suggested "default" instance name. + harness.pressKey("Enter") + await harness.renderOnce() + expect(harness.captureCharFrame()).toContain("step 3 of 4") type(harness, "sk-key") harness.pressKey("Enter") await harness.renderOnce() - expect(harness.captureCharFrame()).toContain("step 3 of 3") + expect(harness.captureCharFrame()).toContain("step 4 of 4") harness.pressKey("Enter") await harness.renderOnce() expect(await done).toBe(true) const openai = providerChoiceById("openai") expect(seen[0]).toEqual({ - name: "openai", + name: "openai/default", baseURL: "https://api.openai.com/v1", apiKey: "sk-key", model: openai?.defaultModel ?? "", - oauthProfile: "", + oauthProfile: "default", }) expect(opts[0]?.preset?.id).toBe("openai") expect(opts[0]?.preset?.models.length).toBeGreaterThan(1) @@ -750,6 +795,9 @@ describe("runProviderSetup", () => { seen.push({ ...values }) }) await pickRow(harness, PROVIDER_IDS, "openai") + await flush(harness) + harness.pressKey("Enter") + await harness.renderOnce() type(harness, "sk-key") harness.pressKey("Enter") await harness.renderOnce() @@ -763,6 +811,7 @@ describe("runProviderSetup", () => { await harness.renderOnce() expect(await done).toBe(true) expect(seen[0]?.model).toBe("gpt-4o") + expect(seen[0]?.name).toBe("openai/default") }) test("shows the telemetry notice only when asked to", async () => { @@ -792,9 +841,9 @@ describe("runProviderSetup", () => { test("Escape goes back a step", async () => { const { done, harness } = await mountSetup() await pickRow(harness, PROVIDER_IDS, "openai") - expect(harness.captureCharFrame()).toContain("step 2 of 3") + expect(harness.captureCharFrame()).toContain("step 2 of 4") await pressEscape(harness) - expect(harness.captureCharFrame()).toContain("step 1 of 3") + expect(harness.captureCharFrame()).toContain("step 1 of 4") harness.pressKey("Ctrl+C") await done }) @@ -802,7 +851,7 @@ describe("runProviderSetup", () => { test("Escape on the first step stays put", async () => { const { done, harness } = await mountSetup() await pressEscape(harness) - expect(harness.captureCharFrame()).toContain("step 1 of 3") + expect(harness.captureCharFrame()).toContain("step 1 of 4") harness.pressKey("Ctrl+C") await done }) @@ -821,6 +870,9 @@ describe("runProviderSetup", () => { test("the typed API key is never painted in the clear", async () => { const { done, harness } = await mountSetup() await pickRow(harness, PROVIDER_IDS, "openai") + await flush(harness) + harness.pressKey("Enter") + await harness.renderOnce() type(harness, "sk-secret") await harness.renderOnce() const frame = harness.captureCharFrame() @@ -907,11 +959,70 @@ describe("runProviderSetup", () => { submits += 1 }) await pickRow(harness, PROVIDER_IDS, "openai") + await flush(harness) type(harness, "sk-key") harness.pressKey("Ctrl+C") expect(await done).toBe(false) expect(submits).toBe(0) }) + + test("a second API-key instance gets a compound name without overwriting the first", async () => { + const seen: ProviderFormValues[] = [] + const { done, harness } = await mountSetup( + async (values) => { + seen.push({ ...values }) + }, + false, + ["openai/default"], + ) + await pickRow(harness, PROVIDER_IDS, "openai") + await flush(harness) + // Existing "default" forces suggested "default-2". + expect(harness.captureCharFrame()).toContain("default-2") + harness.pressKey("Enter") + await harness.renderOnce() + type(harness, "sk-work") + harness.pressKey("Enter") + await harness.renderOnce() + harness.pressKey("Enter") + await harness.renderOnce() + expect(await done).toBe(true) + expect(seen[0]?.name).toBe("openai/default-2") + expect(seen[0]?.oauthProfile).toBe("default-2") + expect(seen[0]?.apiKey).toBe("sk-work") + }) + + test("reusing an existing API-key instance name requires confirm before replace", async () => { + const seen: ProviderFormValues[] = [] + const { done, harness } = await mountSetup( + async (values) => { + seen.push({ ...values }) + }, + false, + ["openai"], + ) + await pickRow(harness, PROVIDER_IDS, "openai") + await flush(harness) + // Clear suggested "default-2" and type the legacy bare-key slug "default". + for (let i = 0; i < 80; i++) harness.pressKey("Backspace") + type(harness, "default") + harness.pressKey("Enter") + await flush(harness) + expect(harness.captureCharFrame()).toContain("already connected") + // Confirm replace. + harness.pressKey("Enter") + await harness.renderOnce() + type(harness, "sk-replaced") + harness.pressKey("Enter") + await harness.renderOnce() + harness.pressKey("Enter") + await harness.renderOnce() + expect(await done).toBe(true) + // Legacy bare key is updated in place rather than rewritten as openai/default. + expect(seen[0]?.name).toBe("openai") + expect(seen[0]?.oauthProfile).toBe("default") + expect(seen[0]?.apiKey).toBe("sk-replaced") + }) }) /** @@ -920,7 +1031,7 @@ describe("runProviderSetup", () => { * handler is registered would pass while paste was broken. */ describe("runProviderSetup paste", () => { - /** Pick OpenAI, paste `key`, accept the default model, return what was saved. */ + /** Pick OpenAI, accept the instance name, paste `key`, accept default model. */ async function pasteKey( key: string, ): Promise<{ values: ProviderFormValues | null; frame: string }> { @@ -930,6 +1041,9 @@ describe("runProviderSetup paste", () => { }) try { await pickRow(harness, PROVIDER_IDS, "openai") + await flush(harness) + harness.pressKey("Enter") + await harness.renderOnce() await harness.mockInput.pasteBracketedText(key) await harness.renderOnce() const frame = harness.captureCharFrame() @@ -984,7 +1098,7 @@ describe("runProviderSetup pick-list height cap", () => { // The garbled-overlap bug glued the step line and the intro line // together on one row; each survives as its own line, or is clipped // entirely, but never merges into the other. - const stepLine = lines.find((l) => l.includes("step 1 of 3")) + const stepLine = lines.find((l) => l.includes("step 1 of 4")) if (stepLine !== undefined) { expect(stepLine).not.toContain("connect an inference provider") } @@ -1025,6 +1139,9 @@ describe("runProviderSetup pick-list height cap", () => { await harness.renderOnce() await harness.renderOnce() await pickRow(harness, PROVIDER_IDS, "openai") + await flush(harness) + harness.pressKey("Enter") + await harness.renderOnce() type(harness, "sk-key") harness.pressKey("Enter") await harness.renderOnce() diff --git a/src/tui/provider-setup.ts b/src/tui/provider-setup.ts index fb24f50a9..f0fc93f36 100644 --- a/src/tui/provider-setup.ts +++ b/src/tui/provider-setup.ts @@ -69,11 +69,13 @@ export type ProviderFormValues = { apiKey: string model: string /** - * Pre-login account slug for the OAuth path (e.g. "personal"). Kept apart - * from `name`, which for OAuth is only written once login succeeds and - * then carries the compound catalog name ("codex/personal") — reusing it - * for the slug would make the field mean two different things depending - * on where the operator is in the flow. + * Pre-login / pre-key account slug for multi-instance paths (e.g. "personal"). + * Kept apart from `name`, which is only written once the account is settled + * and then carries the compound catalog name (`codex/personal`, + * `openai/work`) — reusing it for the slug would make the field mean two + * different things depending on where the operator is in the flow. Shared + * by OAuth and first-class API-key multi-instance connects; Custom still + * edits `name` free-form. */ oauthProfile: string } @@ -87,8 +89,8 @@ export type SetupStep = | "model" | "login" -/** Known-provider path: pick, paste key, pick model. */ -export const PRESET_STEPS: readonly SetupStep[] = ["provider", "apiKey", "model"] +/** Known-provider path: pick, name the instance, paste key, pick model. */ +export const PRESET_STEPS: readonly SetupStep[] = ["provider", "name", "apiKey", "model"] /** * Subscription path: pick, name the account (a suggested slug is prefilled; @@ -129,10 +131,12 @@ const STEP_PROMPTS: Record = { login: "authorize in the browser — this window waits for you", } -/** Instruction for the "name" step on the OAuth path, which names an account - * rather than a whole provider — reusing an existing name re-authorizes it. */ -function oauthNamePrompt(kind: OAuthKind): string { - return `name this account — stored as ${kind}/, and used again if you reconnect it` +/** Instruction for the multi-instance "name" step (OAuth and API-key). */ +function accountNamePrompt(choice: ProviderChoice): string { + if (choice.oauth != null) { + return `name this account — stored as ${choice.oauth}/, and used again if you reconnect it` + } + return `name this instance — stored as ${choice.id}/, and used again if you reconnect it` } // "testing" covers the connection-check call against the entered credentials; @@ -366,21 +370,58 @@ export function providerChoiceById(id: string): ProviderChoice | undefined { } /** - * How many connected accounts `choice` has in `providers`. OAuth choices - * (`codex`, `xai`) are keyed by vendor id, but a signed-in account lands in - * the catalog as `codex/` / `xai/` — one row per account — - * so exact-id matching alone can only ever find zero or one. Key-based - * choices still match by exact id, which is also at most one. + * How many connected accounts `choice` has in `providers`. Both OAuth and + * first-class API-key kinds store instances as `kind/` (plus a legacy + * bare `kind` key for the original single-instance connect), so prefix + * matching is required. Custom is free-form and never counted here. */ export function connectedAccountCount( choice: ProviderChoice, providers: readonly { readonly name: string }[], ): number { + if (choice.custom) return 0 + const prefix = `${choice.id}/` return providers.filter( - (p) => p.name === choice.id || (choice.oauth !== null && p.name.startsWith(`${choice.id}/`)), + (p) => p.name === choice.id || p.name.startsWith(prefix), ).length } +/** + * Instance slugs already claimed for `kind` in the settings catalog. A legacy + * bare `kind` key counts as the slug `"default"` so reconnecting the original + * single-instance row still hits the confirm path. + */ +export function instanceSlugsForKind( + kind: string, + existingNames: readonly string[], +): readonly string[] { + const prefix = `${kind}/` + const slugs: string[] = [] + for (const name of existingNames) { + if (name === kind) slugs.push("default") + else if (name.startsWith(prefix)) { + const slug = name.slice(prefix.length) + if (slug.length > 0) slugs.push(slug) + } + } + return slugs +} + +/** + * Catalog key an API-key instance of `kind`/`slug` is stored under. Reuses a + * legacy bare `kind` key when the slug is `"default"` and that bare key still + * exists; otherwise always writes the compound form so siblings coexist. + */ +export function resolveApiKeyInstanceName( + kind: string, + slug: string, + existingNames: readonly string[], +): string { + const compound = `${kind}/${slug}` + if (existingNames.includes(compound)) return compound + if (slug === "default" && existingNames.includes(kind)) return kind + return compound +} /** Pick-list rows for the provider step. */ export function providerChoiceRows( @@ -475,13 +516,13 @@ export function secretFromMaskedEdit(secret: string, displayed: string): string } // The "name" step names a whole provider on the custom path but a single -// account on the OAuth path; the shared label would mislabel the latter. +// account/instance on multi-instance first-class kinds (OAuth and API-key). function stepLabel(step: SetupStep, choice: ProviderChoice | null): string { - if (step === "name" && choice?.oauth != null) return "account name" + if (step === "name" && choice !== null && !choice.custom) return "account name" return STEP_LABELS[step] } -/** `step 2 of 3 · api key` — always says where the operator is and what is left. */ +/** `step 2 of 4 · api key` — always says where the operator is and what is left. */ export function stepHeadline( steps: readonly SetupStep[], index: number, @@ -525,7 +566,9 @@ function settledValue( if (step === "apiKey") { return values.apiKey.length > 0 ? maskSecret(values.apiKey) : "keyless" } - if (step === "name") return choice?.oauth != null ? values.oauthProfile : values.name + if (step === "name") { + return choice !== null && !choice.custom ? values.oauthProfile : values.name + } if (step === "baseURL") return values.baseURL return values.model } @@ -665,11 +708,18 @@ export type ProviderSetupConfig = { /** Sign-in deadline override, in milliseconds. */ readonly loginTimeoutMs?: number /** - * Skip the provider pick-list and start directly on that provider's - * apiKey/login step — the inline connect path from the model picker's - * add-provider selector already knows which provider it wants. + * 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 + * field) — the inline connect path from the model picker's add-provider + * selector already knows which provider it wants. */ readonly initialProviderId?: string + /** + * Catalog keys already present in global settings. Used by the API-key + * multi-instance name step for suggested slugs and collision confirms. + * OAuth still reads live profiles from the auth store. + */ + readonly existingProviderNames?: readonly string[] } const SUMMARY_SLOTS = CUSTOM_STEPS.length @@ -712,6 +762,7 @@ export async function runProviderSetup( }) const choices = providerChoices() + const existingProviderNames = config.existingProviderNames ?? [] const values: ProviderFormValues = { name: "", baseURL: "", @@ -801,10 +852,11 @@ export async function runProviderSetup( return step === "model" && choice !== null && !choice.custom && !typedModel } // The "name" step means two different things depending on the path: a - // free-text provider name (custom) or an OAuth account slug with its own - // suggestion/collision machinery. Only the latter needs this branch. - const isOAuthNameStep = (): boolean => - currentStep() === "name" && choice !== null && choice.oauth !== null + // free-text provider name (custom) or a multi-instance account slug (OAuth + // and first-class API-key) with suggestion/collision machinery. Only the + // latter needs this branch. + const isAccountNameStep = (): boolean => + currentStep() === "name" && choice !== null && !choice.custom const root = new BoxRenderable(renderer, { id: "provider-setup", @@ -1059,7 +1111,7 @@ export async function runProviderSetup( } const paintStatus = (): void => { - if (!submitting && isOAuthNameStep()) { + if (!submitting && isAccountNameStep()) { if (oauthProfileError !== null) { const ramp = rampFor({ phase: "blocked", nowMs: 0 }) statusLine.content = rampLine(ramp, oauthProfileError) @@ -1075,7 +1127,10 @@ export async function runProviderSetup( `"${confirmedSlug ?? values.oauthProfile}" is already connected`, ) statusLine.fg = ramp.fg - guidance.content = "enter again to re-authorize this account · esc to cancel" + guidance.content = + choice?.oauth != null + ? "enter again to re-authorize this account · esc to cancel" + : "enter again to replace this instance's key · esc to cancel" guidance.fg = UI.textDim return } @@ -1159,8 +1214,8 @@ export async function runProviderSetup( const active = currentStep() step.content = stepHeadline(steps(), stepIndex, choice) instruction.content = - isOAuthNameStep() && choice?.oauth != null - ? oauthNamePrompt(choice.oauth) + isAccountNameStep() && choice !== null + ? accountNamePrompt(choice) : STEP_PROMPTS[active] paintSummary() paintList() @@ -1182,8 +1237,8 @@ export async function runProviderSetup( if (isLoginStep() && loginStatus === "idle") beginLogin() return } - if (isOAuthNameStep()) { - enterOAuthNameStep() + if (isAccountNameStep()) { + enterAccountNameStep() return } const field = active as ProviderField @@ -1195,32 +1250,35 @@ export async function runProviderSetup( } /** - * Enter the OAuth "name" step: reset its per-visit state, show whatever - * slug is already typed, then fetch this provider's profiles fresh (never - * cached across visits — another sign-in could have landed between two - * visits to this step) to prefill a suggested, non-colliding slug when the - * field is still blank. + * Enter the multi-instance "name" step: reset per-visit state, show whatever + * slug is already typed, then resolve existing instance names to prefill a + * suggested, non-colliding slug when the field is still blank. OAuth reads + * the live auth store; API-key reads the settings catalog snapshot. */ - const enterOAuthNameStep = (): void => { + const enterAccountNameStep = (): void => { oauthProfileError = null oauthProfileConfirmPending = false input.placeholder = OAUTH_PROFILE_HINT input.value = values.oauthProfile paint() input.focus() - const kind = choice?.oauth ?? null - if (kind === null) return + if (choice === null || choice.custom) return const attempt = (oauthNameAttempt += 1) - listOAuthProfiles(kind) - .catch((): readonly string[] => []) - .then((names) => { - if (settled || attempt !== oauthNameAttempt) return - if (values.oauthProfile.trim().length === 0) { - values.oauthProfile = suggestOAuthProfileSlug(names) - input.value = values.oauthProfile - paint() - } - }) + const applySuggestion = (names: readonly string[]): void => { + if (settled || attempt !== oauthNameAttempt) return + if (values.oauthProfile.trim().length === 0) { + values.oauthProfile = suggestOAuthProfileSlug(names) + input.value = values.oauthProfile + paint() + } + } + if (choice.oauth !== null) { + listOAuthProfiles(choice.oauth) + .catch((): readonly string[] => []) + .then(applySuggestion) + return + } + applySuggestion(instanceSlugsForKind(choice.id, existingProviderNames)) } let settled = false @@ -1439,18 +1497,14 @@ export async function runProviderSetup( values.name = "" values.baseURL = "" values.model = "" - } else if (picked.oauth !== null) { - // Left blank until login succeeds — see the `oauthProfile` doc comment - // on `ProviderFormValues` for why the pre-login slug is a field of - // its own rather than a stand-in value here. + } else { + // Multi-instance first-class kinds (OAuth and API-key): leave the catalog + // name blank until the account/instance slug is settled. See the + // `oauthProfile` doc comment on `ProviderFormValues`. values.name = "" values.baseURL = picked.baseURL values.model = picked.defaultModel values.oauthProfile = "" - } else { - values.name = picked.id - values.baseURL = picked.baseURL - values.model = picked.defaultModel } stepIndex += 1 if (isListStep()) enterModelList() @@ -1519,8 +1573,8 @@ export async function runProviderSetup( if (loginStatus !== "pending") beginLogin() return } - if (isOAuthNameStep()) { - advanceOAuthNameStep() + if (isAccountNameStep()) { + advanceAccountNameStep() return } const field = currentStep() as ProviderField @@ -1537,15 +1591,12 @@ export async function runProviderSetup( } /** - * Validate the entered slug, then re-derive the collision check against a - * fresh profile fetch rather than trusting the snapshot taken when the - * step was entered — a profile authorized elsewhere in the meantime must - * still be caught. A collision needs one more Enter to confirm before the - * step advances, worded as a re-authorization rather than a bare retry. + * Validate the entered slug, then check collisions against a fresh source + * (auth store for OAuth, settings catalog for API-key). A collision needs + * one more Enter to confirm before the step advances. */ - const advanceOAuthNameStep = (): void => { - const kind = choice?.oauth ?? null - if (kind === null) return + const advanceAccountNameStep = (): void => { + if (choice === null || choice.custom) return const validated = validateOAuthProfileSlug(values.oauthProfile) if (!validated.ok) { oauthProfileError = validated.error @@ -1559,30 +1610,40 @@ export async function runProviderSetup( // without another round-trip. Any edit since then cleared the flag (see // onInput), so this only fires on a genuine second, unmodified Enter. if (oauthProfileConfirmPending && confirmedSlug === slug) { - enterLoginStepWithSlug(slug) + settleAccountNameSlug(slug) return } const attempt = (oauthNameAttempt += 1) - listOAuthProfiles(kind) - .catch((): readonly string[] => []) - .then((names) => { - if (settled || attempt !== oauthNameAttempt) return - if (names.includes(slug)) { - oauthProfileError = null - oauthProfileConfirmPending = true - confirmedSlug = slug - paint() - return - } - enterLoginStepWithSlug(slug) - }) + const handleNames = (names: readonly string[]): void => { + if (settled || attempt !== oauthNameAttempt) return + if (names.includes(slug)) { + oauthProfileError = null + oauthProfileConfirmPending = true + confirmedSlug = slug + paint() + return + } + settleAccountNameSlug(slug) + } + if (choice.oauth !== null) { + listOAuthProfiles(choice.oauth) + .catch((): readonly string[] => []) + .then(handleNames) + return + } + handleNames(instanceSlugsForKind(choice.id, existingProviderNames)) } - const enterLoginStepWithSlug = (slug: string): void => { + const settleAccountNameSlug = (slug: string): void => { values.oauthProfile = slug oauthProfileError = null oauthProfileConfirmPending = false confirmedSlug = null + if (choice !== null && choice.oauth === null && !choice.custom) { + // API-key multi-instance: catalog key is kind/slug (or legacy bare kind + // when reconnecting the original single-instance "default"). + values.name = resolveApiKeyInstanceName(choice.id, slug, existingProviderNames) + } stepIndex += 1 showStep() } @@ -1598,7 +1659,7 @@ export async function runProviderSetup( function onInput(next: string): void { if (submitting || isListStep()) return - if (isOAuthNameStep()) { + if (isAccountNameStep()) { values.oauthProfile = next // An edit invalidates whatever the last submit attempt found — the // confirm applies to one exact slug, and any inline error is stale @@ -1650,7 +1711,7 @@ export async function runProviderSetup( key.preventDefault() if (isLoginStep()) { cancelLogin() - } else if (isOAuthNameStep() && oauthProfileConfirmPending) { + } else if (isAccountNameStep() && oauthProfileConfirmPending) { // Cancel the re-authorize confirm without leaving the step — the // operator is about to edit the name, not abandon the provider. oauthProfileConfirmPending = false