diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5e6d704..d77863e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename 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. +- **API-key connect keeps the project selection.** Connecting an API-key or + Custom provider now writes the same project-local provider/model selection + OAuth already wrote, so a restart in that repo resolves to the account just + connected. Secrets stay in global credential storage only. ### TUI diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 01e702899..5439c5f1f 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -263,7 +263,7 @@ Profiles supply per-project or named-profile overrides for `model`, `maxTurns`, 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. -**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. +**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. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. **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. diff --git a/src/tui/onboarding.ts b/src/tui/onboarding.ts index c40625b07..71a5f828a 100644 --- a/src/tui/onboarding.ts +++ b/src/tui/onboarding.ts @@ -1,7 +1,7 @@ import { runTUI } from "./runner.js"; import { buildProviderSubmitHandler } from "./provider-setup-submit.js"; import { loadConfig, type UnconfiguredConfig } from "../config/index.js"; -import { globalSettingsPath, loadSettings } from "../config/settings.js"; +import { globalSettingsPath, loadSettings, localSettingsPath } from "../config/settings.js"; import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js"; import { runProviderSetup } from "./provider-setup.js"; @@ -20,7 +20,11 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise const submitted = await runProviderSetup({ showTelemetryNotice, existingProviderNames: Object.keys(existing?.providers ?? {}), - onSubmit: buildProviderSubmitHandler(settingsPath, existing, config.cwd), + onSubmit: buildProviderSubmitHandler( + settingsPath, + existing, + localSettingsPath(config.cwd), + ), }); // If the user cancelled (Ctrl+C) onSubmit was never called and settings were diff --git a/src/tui/provider-connect.test.ts b/src/tui/provider-connect.test.ts index 1ab154e9f..114da14c9 100644 --- a/src/tui/provider-connect.test.ts +++ b/src/tui/provider-connect.test.ts @@ -21,7 +21,6 @@ describe("connectProviderInline", () => { providerId: "openai", settingsPath, localSettingsPath: join(dir, "local.json"), - cwd: dir, existing: null, createRenderer: async () => harness.renderer, }) diff --git a/src/tui/provider-connect.ts b/src/tui/provider-connect.ts index 4f8630ce3..8a08599f4 100644 --- a/src/tui/provider-connect.ts +++ b/src/tui/provider-connect.ts @@ -12,8 +12,8 @@ import { runProviderSetup, type ProviderSetupConfig } from "./provider-setup.js" export type ConnectProviderInput = { readonly providerId: string readonly settingsPath: string + /** Project-local selection file; written after a successful connect. */ readonly localSettingsPath: string - readonly cwd: string readonly existing: Settings | null readonly createRenderer?: ProviderSetupConfig["createRenderer"] readonly startLogin?: ProviderSetupConfig["startLogin"] @@ -35,7 +35,11 @@ export async function connectProviderInline( input: ConnectProviderInput, ): Promise { let result: ConnectProviderResult = { connected: false } - const submitProvider = buildProviderSubmitHandler(input.settingsPath, input.existing, input.cwd) + const submitProvider = buildProviderSubmitHandler( + input.settingsPath, + input.existing, + input.localSettingsPath, + ) const submitted = await runProviderSetup({ showTelemetryNotice: false, diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index f4cc5de47..10809bfb3 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -1,21 +1,22 @@ -import { describe, test, expect, afterEach } from "bun:test"; +import { describe, test, expect } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildProviderSubmitHandler } from "./provider-setup-submit.js"; -import { loadSettings } from "../config/settings.js"; +import { + loadLocalSettings, + loadSettings, + localSettingsPath, +} from "../config/settings.js"; import type { ProviderFormValues, SubmitPhase } from "./provider-setup.js"; const noopSetPhase = (_phase: SubmitPhase): void => {}; -async function withTempSettingsPath( - run: (path: string) => Promise, -): Promise { +async function withTempDir(run: (dir: string) => Promise): Promise { const dir = await mkdtemp(join(tmpdir(), "provider-setup-submit-")); - const path = join(dir, "settings.json"); try { - await run(path); + await run(dir); } finally { await rm(dir, { recursive: true, force: true }); } @@ -23,8 +24,10 @@ async function withTempSettingsPath( describe("buildProviderSubmitHandler", () => { test("rejects an empty key on a key-required preset without persisting", async () => { - await withTempSettingsPath(async (path) => { - const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd"); + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); const values: ProviderFormValues = { name: "openai", baseURL: "https://api.openai.com/v1", @@ -39,12 +42,14 @@ describe("buildProviderSubmitHandler", () => { ).rejects.toThrow(/api key/i); expect(await loadSettings(path)).toBeNull(); + expect(await loadLocalSettings(localPath)).toBeNull(); }); }); test("allows an empty key on the manual/custom path (no preset)", async () => { - await withTempSettingsPath(async (path) => { - const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd"); + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + const submit = buildProviderSubmitHandler(path, null, localSettingsPath(dir)); const values: ProviderFormValues = { name: "local", baseURL: "http://localhost:11434/v1", @@ -62,8 +67,9 @@ describe("buildProviderSubmitHandler", () => { }); test("marks a save-anyway submit as unverified", async () => { - await withTempSettingsPath(async (path) => { - const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd"); + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + const submit = buildProviderSubmitHandler(path, null, localSettingsPath(dir)); const values: ProviderFormValues = { name: "openai", baseURL: "https://api.openai.com/v1", @@ -79,4 +85,116 @@ describe("buildProviderSubmitHandler", () => { expect(settings?.providers.openai?.verified).toBe(false); }); }); + + test("API-key connect persists project-local selection like OAuth", async () => { + // CL-5900: API-key path must write the same local selection OAuth writes, + // so a restart in this repo resolves to the connected provider/model. + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); + const values: ProviderFormValues = { + name: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "sk-test-fake", + model: "gpt-5", + oauthProfile: "", + }; + const preset = { id: "openai", models: ["gpt-5"], anthropic: false, opencodeGo: false }; + + await submit(values, noopSetPhase, { skipValidation: true, preset }); + + const local = await loadLocalSettings(localPath); + expect(local).toEqual({ provider: "openai", model: "gpt-5" }); + // Secrets stay out of the local selection file. + expect(JSON.stringify(local)).not.toContain("sk-test-fake"); + const global = await loadSettings(path); + expect(global?.providers.openai?.apiKey).toBe("sk-test-fake"); + }); + }); + + test("Custom connect also persists project-local selection", async () => { + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); + const values: ProviderFormValues = { + name: "ollama", + baseURL: "http://localhost:11434/v1", + apiKey: "", + model: "llama3", + oauthProfile: "", + }; + + await submit(values, noopSetPhase, { skipValidation: true }); + + const local = await loadLocalSettings(localPath); + expect(local).toEqual({ provider: "ollama", model: "llama3" }); + }); + }); + + test("OAuth connect still persists project-local selection via the shared helper", async () => { + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); + const values: ProviderFormValues = { + name: "", + baseURL: "https://chatgpt.com/backend-api", + apiKey: "", + model: "gpt-5", + oauthProfile: "work", + }; + + await submit(values, noopSetPhase, { + skipValidation: true, + oauth: { kind: "codex", providerName: "codex/work", profile: "work" }, + }); + + const local = await loadLocalSettings(localPath); + expect(local).toEqual({ provider: "codex/work", model: "gpt-5" }); + }); + }); + + test("restart resolution reads the local selection written by API-key connect", async () => { + // Regression: after connect, loadLocalSettings must surface the same + // provider/model pair a subsequent session would resolve against. + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); + await submit( + { + name: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "sk-ant-test", + model: "claude-sonnet-4", + oauthProfile: "", + }, + noopSetPhase, + { + skipValidation: true, + preset: { + id: "anthropic", + models: ["claude-sonnet-4"], + anthropic: true, + opencodeGo: false, + }, + }, + ); + + // Simulate restart: re-load both files the way config resolution does. + const global = await loadSettings(path); + const local = await loadLocalSettings(localPath); + expect(local?.provider).toBe("anthropic"); + expect(local?.model).toBe("claude-sonnet-4"); + expect(global?.providers.anthropic?.defaultModel).toBe("claude-sonnet-4"); + // Local selection is what wins on restart when present. + const resolvedProvider = local?.provider ?? global?.defaultProvider; + const resolvedModel = + local?.model ?? global?.providers[resolvedProvider ?? ""]?.defaultModel; + expect(resolvedProvider).toBe("anthropic"); + expect(resolvedModel).toBe("claude-sonnet-4"); + }); + }); }); diff --git a/src/tui/provider-setup-submit.ts b/src/tui/provider-setup-submit.ts index d278cbf36..27e7f2948 100644 --- a/src/tui/provider-setup-submit.ts +++ b/src/tui/provider-setup-submit.ts @@ -1,5 +1,4 @@ import { - localSettingsPath, mergeProviderIntoSettings, saveGlobalSettings, saveLocalSettings, @@ -8,22 +7,44 @@ import { import { validateProviderConnection } from "../provider/validate-connection.js"; import type { ProviderSetupSubmit } from "./provider-setup.js"; +/** + * Persist the project-local provider/model selection after a successful + * connect. Shared by OAuth and API-key paths so both leave the same two + * files `/model` would write on a switch: global credentials/catalog and + * local selection only (never secrets). + */ +export async function persistConnectedSelection( + localSettingsFile: string, + provider: string, + model: string, +): Promise { + await saveLocalSettings(localSettingsFile, { + provider, + model, + }); +} + /** * The single write path every provider-setup exit takes, shared by first-run * onboarding and mid-session "connect a new provider" so a credential is * validated (or explicitly marked unverified) the same way regardless of * where the form was opened from. + * + * `localSettingsFile` is the project-local selection path (wired through from + * callers that already own it — never re-derived here so tests and the + * mid-session connect path can pass an explicit file). */ export function buildProviderSubmitHandler( settingsPath: string, existing: Settings | null, - cwd: string, + localSettingsFile: string, ): ProviderSetupSubmit { return async (values, setPhase, { skipValidation, preset, oauth }) => { const { name, baseURL, apiKey, model } = values; const providerName = name.trim(); const trimmedBaseURL = baseURL.trim(); const trimmedKey = apiKey.trim(); + const selectedModel = model.trim(); // A signed-in subscription provider has no key to test or store: the // tokens are already in the home-level auth store, and config load @@ -45,10 +66,7 @@ export function buildProviderSubmitHandler( ...base, defaultProvider: oauth.providerName, }); - await saveLocalSettings(localSettingsPath(cwd), { - provider: oauth.providerName, - model: model.trim(), - }); + await persistConnectedSelection(localSettingsFile, oauth.providerName, selectedModel); return; } @@ -78,7 +96,6 @@ export function buildProviderSubmitHandler( } setPhase("saving"); - const selectedModel = model.trim(); // A picked provider seeds its whole catalog so /model has more than the // one model chosen here; the protocol flags cannot be expressed by the // four form values and come from the catalog entry. @@ -104,5 +121,9 @@ export function buildProviderSubmitHandler( // plugins/pluginPaths/sessionMode/shell/tools survive re-onboarding. const merged = mergeProviderIntoSettings(existing, providerName, newProvider); await saveGlobalSettings(settingsPath, merged); + // Same project-local selection contract as OAuth: credentials stay in + // global storage; the local file is selection only so a restart in this + // repo resolves to the provider just connected. + await persistConnectedSelection(localSettingsFile, providerName, selectedModel); }; } diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 95edd7d0c..def767213 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2107,7 +2107,6 @@ export async function runTUI(initialConfig: Config): Promise { providerId: providerName, settingsPath: trueGlobalSettingsPath, localSettingsPath: localSettingsFile, - cwd: config.cwd, existing: config.settings ?? null, createRenderer: () => Promise.resolve(host.renderer), });