Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 6 additions & 2 deletions src/tui/onboarding.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,7 +20,11 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise<number>
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
Expand Down
1 change: 0 additions & 1 deletion src/tui/provider-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ describe("connectProviderInline", () => {
providerId: "openai",
settingsPath,
localSettingsPath: join(dir, "local.json"),
cwd: dir,
existing: null,
createRenderer: async () => harness.renderer,
})
Expand Down
8 changes: 6 additions & 2 deletions src/tui/provider-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -35,7 +35,11 @@ export async function connectProviderInline(
input: ConnectProviderInput,
): Promise<ConnectProviderResult> {
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,
Expand Down
144 changes: 131 additions & 13 deletions src/tui/provider-setup-submit.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
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<void>,
): Promise<void> {
async function withTempDir(run: (dir: string) => Promise<void>): Promise<void> {
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 });
}
}

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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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");
});
});
});
35 changes: 28 additions & 7 deletions src/tui/provider-setup-submit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
localSettingsPath,
mergeProviderIntoSettings,
saveGlobalSettings,
saveLocalSettings,
Expand All @@ -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<void> {
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
Expand All @@ -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;
}

Expand Down Expand Up @@ -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.
Expand All @@ -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);
};
}
1 change: 0 additions & 1 deletion src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2107,7 +2107,6 @@ export async function runTUI(initialConfig: Config): Promise<number> {
providerId: providerName,
settingsPath: trueGlobalSettingsPath,
localSettingsPath: localSettingsFile,
cwd: config.cwd,
existing: config.settings ?? null,
createRenderer: () => Promise.resolve(host.renderer),
});
Expand Down
Loading