Skip to content

Commit ce06634

Browse files
committed
Persist project provider selection after API-key connect
API-key and Custom connects now write the project-local provider/model selection the same way OAuth does, so a restart keeps the connected model without storing secrets in the per-repo file.
1 parent 120a900 commit ce06634

8 files changed

Lines changed: 176 additions & 27 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2020
the key, so personal and team keys can coexist (`openai/default`,
2121
`anthropic/work`, …). Reusing an existing name replaces that instance after
2222
an explicit confirm. Custom endpoints stay free-form and single-entry.
23+
- **API-key connect keeps the project selection.** Connecting an API-key or
24+
Custom provider now writes the same project-local provider/model selection
25+
OAuth already wrote, so a restart in that repo resolves to the account just
26+
connected. Secrets stay in global credential storage only.
2327

2428
## [0.2.97] - 2026-08-10
2529

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; 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. 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.
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

src/tui/onboarding.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { runTUI } from "./runner.js";
22
import { buildProviderSubmitHandler } from "./provider-setup-submit.js";
33
import { loadConfig, type UnconfiguredConfig } from "../config/index.js";
4-
import { globalSettingsPath, loadSettings } from "../config/settings.js";
4+
import { globalSettingsPath, loadSettings, localSettingsPath } from "../config/settings.js";
55
import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js";
66
import { runProviderSetup } from "./provider-setup.js";
77

@@ -20,7 +20,11 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise<number>
2020
const submitted = await runProviderSetup({
2121
showTelemetryNotice,
2222
existingProviderNames: Object.keys(existing?.providers ?? {}),
23-
onSubmit: buildProviderSubmitHandler(settingsPath, existing, config.cwd),
23+
onSubmit: buildProviderSubmitHandler(
24+
settingsPath,
25+
existing,
26+
localSettingsPath(config.cwd),
27+
),
2428
});
2529

2630
// If the user cancelled (Ctrl+C) onSubmit was never called and settings were

src/tui/provider-connect.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ describe("connectProviderInline", () => {
2121
providerId: "openai",
2222
settingsPath,
2323
localSettingsPath: join(dir, "local.json"),
24-
cwd: dir,
2524
existing: null,
2625
createRenderer: async () => harness.renderer,
2726
})

src/tui/provider-connect.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import { runProviderSetup, type ProviderSetupConfig } from "./provider-setup.js"
1212
export type ConnectProviderInput = {
1313
readonly providerId: string
1414
readonly settingsPath: string
15+
/** Project-local selection file; written after a successful connect. */
1516
readonly localSettingsPath: string
16-
readonly cwd: string
1717
readonly existing: Settings | null
1818
readonly createRenderer?: ProviderSetupConfig["createRenderer"]
1919
readonly startLogin?: ProviderSetupConfig["startLogin"]
@@ -35,7 +35,11 @@ export async function connectProviderInline(
3535
input: ConnectProviderInput,
3636
): Promise<ConnectProviderResult> {
3737
let result: ConnectProviderResult = { connected: false }
38-
const submitProvider = buildProviderSubmitHandler(input.settingsPath, input.existing, input.cwd)
38+
const submitProvider = buildProviderSubmitHandler(
39+
input.settingsPath,
40+
input.existing,
41+
input.localSettingsPath,
42+
)
3943

4044
const submitted = await runProviderSetup({
4145
showTelemetryNotice: false,

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

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,33 @@
1-
import { describe, test, expect, afterEach } from "bun:test";
1+
import { describe, test, expect } from "bun:test";
22
import { mkdtemp, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

66
import { buildProviderSubmitHandler } from "./provider-setup-submit.js";
7-
import { loadSettings } from "../config/settings.js";
7+
import {
8+
loadLocalSettings,
9+
loadSettings,
10+
localSettingsPath,
11+
} from "../config/settings.js";
812
import type { ProviderFormValues, SubmitPhase } from "./provider-setup.js";
913

1014
const noopSetPhase = (_phase: SubmitPhase): void => {};
1115

12-
async function withTempSettingsPath(
13-
run: (path: string) => Promise<void>,
14-
): Promise<void> {
16+
async function withTempDir(run: (dir: string) => Promise<void>): Promise<void> {
1517
const dir = await mkdtemp(join(tmpdir(), "provider-setup-submit-"));
16-
const path = join(dir, "settings.json");
1718
try {
18-
await run(path);
19+
await run(dir);
1920
} finally {
2021
await rm(dir, { recursive: true, force: true });
2122
}
2223
}
2324

2425
describe("buildProviderSubmitHandler", () => {
2526
test("rejects an empty key on a key-required preset without persisting", async () => {
26-
await withTempSettingsPath(async (path) => {
27-
const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd");
27+
await withTempDir(async (dir) => {
28+
const path = join(dir, "settings.json");
29+
const localPath = localSettingsPath(dir);
30+
const submit = buildProviderSubmitHandler(path, null, localPath);
2831
const values: ProviderFormValues = {
2932
name: "openai",
3033
baseURL: "https://api.openai.com/v1",
@@ -39,12 +42,14 @@ describe("buildProviderSubmitHandler", () => {
3942
).rejects.toThrow(/api key/i);
4043

4144
expect(await loadSettings(path)).toBeNull();
45+
expect(await loadLocalSettings(localPath)).toBeNull();
4246
});
4347
});
4448

4549
test("allows an empty key on the manual/custom path (no preset)", async () => {
46-
await withTempSettingsPath(async (path) => {
47-
const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd");
50+
await withTempDir(async (dir) => {
51+
const path = join(dir, "settings.json");
52+
const submit = buildProviderSubmitHandler(path, null, localSettingsPath(dir));
4853
const values: ProviderFormValues = {
4954
name: "local",
5055
baseURL: "http://localhost:11434/v1",
@@ -62,8 +67,9 @@ describe("buildProviderSubmitHandler", () => {
6267
});
6368

6469
test("marks a save-anyway submit as unverified", async () => {
65-
await withTempSettingsPath(async (path) => {
66-
const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd");
70+
await withTempDir(async (dir) => {
71+
const path = join(dir, "settings.json");
72+
const submit = buildProviderSubmitHandler(path, null, localSettingsPath(dir));
6773
const values: ProviderFormValues = {
6874
name: "openai",
6975
baseURL: "https://api.openai.com/v1",
@@ -79,4 +85,116 @@ describe("buildProviderSubmitHandler", () => {
7985
expect(settings?.providers.openai?.verified).toBe(false);
8086
});
8187
});
88+
89+
test("API-key connect persists project-local selection like OAuth", async () => {
90+
// CL-5900: API-key path must write the same local selection OAuth writes,
91+
// so a restart in this repo resolves to the connected provider/model.
92+
await withTempDir(async (dir) => {
93+
const path = join(dir, "settings.json");
94+
const localPath = localSettingsPath(dir);
95+
const submit = buildProviderSubmitHandler(path, null, localPath);
96+
const values: ProviderFormValues = {
97+
name: "openai",
98+
baseURL: "https://api.openai.com/v1",
99+
apiKey: "sk-test-fake",
100+
model: "gpt-5",
101+
oauthProfile: "",
102+
};
103+
const preset = { id: "openai", models: ["gpt-5"], anthropic: false, opencodeGo: false };
104+
105+
await submit(values, noopSetPhase, { skipValidation: true, preset });
106+
107+
const local = await loadLocalSettings(localPath);
108+
expect(local).toEqual({ provider: "openai", model: "gpt-5" });
109+
// Secrets stay out of the local selection file.
110+
expect(JSON.stringify(local)).not.toContain("sk-test-fake");
111+
const global = await loadSettings(path);
112+
expect(global?.providers.openai?.apiKey).toBe("sk-test-fake");
113+
});
114+
});
115+
116+
test("Custom connect also persists project-local selection", async () => {
117+
await withTempDir(async (dir) => {
118+
const path = join(dir, "settings.json");
119+
const localPath = localSettingsPath(dir);
120+
const submit = buildProviderSubmitHandler(path, null, localPath);
121+
const values: ProviderFormValues = {
122+
name: "ollama",
123+
baseURL: "http://localhost:11434/v1",
124+
apiKey: "",
125+
model: "llama3",
126+
oauthProfile: "",
127+
};
128+
129+
await submit(values, noopSetPhase, { skipValidation: true });
130+
131+
const local = await loadLocalSettings(localPath);
132+
expect(local).toEqual({ provider: "ollama", model: "llama3" });
133+
});
134+
});
135+
136+
test("OAuth connect still persists project-local selection via the shared helper", async () => {
137+
await withTempDir(async (dir) => {
138+
const path = join(dir, "settings.json");
139+
const localPath = localSettingsPath(dir);
140+
const submit = buildProviderSubmitHandler(path, null, localPath);
141+
const values: ProviderFormValues = {
142+
name: "",
143+
baseURL: "https://chatgpt.com/backend-api",
144+
apiKey: "",
145+
model: "gpt-5",
146+
oauthProfile: "work",
147+
};
148+
149+
await submit(values, noopSetPhase, {
150+
skipValidation: true,
151+
oauth: { kind: "codex", providerName: "codex/work", profile: "work" },
152+
});
153+
154+
const local = await loadLocalSettings(localPath);
155+
expect(local).toEqual({ provider: "codex/work", model: "gpt-5" });
156+
});
157+
});
158+
159+
test("restart resolution reads the local selection written by API-key connect", async () => {
160+
// Regression: after connect, loadLocalSettings must surface the same
161+
// provider/model pair a subsequent session would resolve against.
162+
await withTempDir(async (dir) => {
163+
const path = join(dir, "settings.json");
164+
const localPath = localSettingsPath(dir);
165+
const submit = buildProviderSubmitHandler(path, null, localPath);
166+
await submit(
167+
{
168+
name: "anthropic",
169+
baseURL: "https://api.anthropic.com",
170+
apiKey: "sk-ant-test",
171+
model: "claude-sonnet-4",
172+
oauthProfile: "",
173+
},
174+
noopSetPhase,
175+
{
176+
skipValidation: true,
177+
preset: {
178+
id: "anthropic",
179+
models: ["claude-sonnet-4"],
180+
anthropic: true,
181+
opencodeGo: false,
182+
},
183+
},
184+
);
185+
186+
// Simulate restart: re-load both files the way config resolution does.
187+
const global = await loadSettings(path);
188+
const local = await loadLocalSettings(localPath);
189+
expect(local?.provider).toBe("anthropic");
190+
expect(local?.model).toBe("claude-sonnet-4");
191+
expect(global?.providers.anthropic?.defaultModel).toBe("claude-sonnet-4");
192+
// Local selection is what wins on restart when present.
193+
const resolvedProvider = local?.provider ?? global?.defaultProvider;
194+
const resolvedModel =
195+
local?.model ?? global?.providers[resolvedProvider ?? ""]?.defaultModel;
196+
expect(resolvedProvider).toBe("anthropic");
197+
expect(resolvedModel).toBe("claude-sonnet-4");
198+
});
199+
});
82200
});

src/tui/provider-setup-submit.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
localSettingsPath,
32
mergeProviderIntoSettings,
43
saveGlobalSettings,
54
saveLocalSettings,
@@ -8,22 +7,44 @@ import {
87
import { validateProviderConnection } from "../provider/validate-connection.js";
98
import type { ProviderSetupSubmit } from "./provider-setup.js";
109

10+
/**
11+
* Persist the project-local provider/model selection after a successful
12+
* connect. Shared by OAuth and API-key paths so both leave the same two
13+
* files `/model` would write on a switch: global credentials/catalog and
14+
* local selection only (never secrets).
15+
*/
16+
export async function persistConnectedSelection(
17+
localSettingsFile: string,
18+
provider: string,
19+
model: string,
20+
): Promise<void> {
21+
await saveLocalSettings(localSettingsFile, {
22+
provider,
23+
model,
24+
});
25+
}
26+
1127
/**
1228
* The single write path every provider-setup exit takes, shared by first-run
1329
* onboarding and mid-session "connect a new provider" so a credential is
1430
* validated (or explicitly marked unverified) the same way regardless of
1531
* where the form was opened from.
32+
*
33+
* `localSettingsFile` is the project-local selection path (wired through from
34+
* callers that already own it — never re-derived here so tests and the
35+
* mid-session connect path can pass an explicit file).
1636
*/
1737
export function buildProviderSubmitHandler(
1838
settingsPath: string,
1939
existing: Settings | null,
20-
cwd: string,
40+
localSettingsFile: string,
2141
): ProviderSetupSubmit {
2242
return async (values, setPhase, { skipValidation, preset, oauth }) => {
2343
const { name, baseURL, apiKey, model } = values;
2444
const providerName = name.trim();
2545
const trimmedBaseURL = baseURL.trim();
2646
const trimmedKey = apiKey.trim();
47+
const selectedModel = model.trim();
2748

2849
// A signed-in subscription provider has no key to test or store: the
2950
// tokens are already in the home-level auth store, and config load
@@ -45,10 +66,7 @@ export function buildProviderSubmitHandler(
4566
...base,
4667
defaultProvider: oauth.providerName,
4768
});
48-
await saveLocalSettings(localSettingsPath(cwd), {
49-
provider: oauth.providerName,
50-
model: model.trim(),
51-
});
69+
await persistConnectedSelection(localSettingsFile, oauth.providerName, selectedModel);
5270
return;
5371
}
5472

@@ -78,7 +96,6 @@ export function buildProviderSubmitHandler(
7896
}
7997

8098
setPhase("saving");
81-
const selectedModel = model.trim();
8299
// A picked provider seeds its whole catalog so /model has more than the
83100
// one model chosen here; the protocol flags cannot be expressed by the
84101
// four form values and come from the catalog entry.
@@ -104,5 +121,9 @@ export function buildProviderSubmitHandler(
104121
// plugins/pluginPaths/sessionMode/shell/tools survive re-onboarding.
105122
const merged = mergeProviderIntoSettings(existing, providerName, newProvider);
106123
await saveGlobalSettings(settingsPath, merged);
124+
// Same project-local selection contract as OAuth: credentials stay in
125+
// global storage; the local file is selection only so a restart in this
126+
// repo resolves to the provider just connected.
127+
await persistConnectedSelection(localSettingsFile, providerName, selectedModel);
107128
};
108129
}

src/tui/runner.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2113,7 +2113,6 @@ export async function runTUI(initialConfig: Config): Promise<number> {
21132113
providerId: providerName,
21142114
settingsPath: trueGlobalSettingsPath,
21152115
localSettingsPath: localSettingsFile,
2116-
cwd: config.cwd,
21172116
existing: config.settings ?? null,
21182117
createRenderer: () => Promise.resolve(host.renderer),
21192118
});

0 commit comments

Comments
 (0)