diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 981f4b9ba..638062b90 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -288,8 +288,8 @@ Ink 7 + React 19, full-screen via the alternate-screen buffer. - Hooks: `use-gates` (permission/plan/operator gates), `use-keymap`, `use-scroll`, `use-mouse-scroll`, `use-spinner`, `use-terminal-size`, `use-layout-geometry`, `use-mcp-status`, `use-provider-manager`. - Components: `header`, `event-log`, `chat-input`, `status-bar`, `task-view`, `operator-modal`, `permission-modal`, `permissions-manager`, `plugins-manager`, `settings-overlay`, `agent-modal`, `exit-confirm`, `help-overlay`, `hook-panel`, `codex-login-modal`, `mcp-auth-prompt`, `onboarding-animation`, `in-flight-indicator`. - Support: `stdin-filter.ts` (strips SGR mouse sequences before Ink parses input — see below), `tool-formatter.ts` (human-readable tool args/results), `markdown-parser.ts`, `keymap-table.ts`, `theme.ts` (semantic color roles including `dim` and `live`). -- Slash commands: `commands/registry.ts` (extensible registry) + `commands/built-in.ts` (`/help`, `/model`, `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp`). There is no `/login` — connect providers from `/model` (c / Ctrl+A). -- `/model` configuration surface (`components/agent-modal.tsx`): a full-screen, section-based modal. The Provider/Model section reuses the provider catalog (from `config.providers`) and applies a switch live via `agent.setSource()` — the runtime's in-place source mutation, read at the next inference call, so no agent recreation. **Connect provider** lists first-class providers (Codex, xAI, Zen, Anthropic, OpenAI, Google, OpenCode Go); OAuth opens `codex-login-modal`, API-key flows pre-seed models and persist on save. OpenCode Go sources are built with per-model protocol routing (`buildGoSource` / `resolveGoEndpoint`). "Set as default" persists the selection (selection-only, no credentials) to the per-repo `.corbits/settings.json` via `saveLocalSettings`. +- Slash commands: `commands/registry.ts` (extensible registry) + `commands/built-in.ts` (`/help`, `/model`, `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp`). There is no `/login` — connect providers from `/model` (Alt+A / c). +- `/model` configuration surface (`components/agent-modal.tsx`): a full-screen, section-based modal. **Default step is models-first** (Recent / Favorites / Providers via `buildModelsFirstList`); advanced provider drill-down remains on **a**. **Connect** lists first-class providers (OpenAI dual-path, xAI, Zen, Anthropic, Google, OpenCode Go, Z.AI, Custom); OAuth opens `codex-login-modal` / xAI login, API-key flows use an auth-only form and pre-seed models on save. OpenCode Go sources are built with per-model protocol routing (`buildGoSource` / `resolveGoEndpoint`) and a forced Go base URL when `opencodeGo` is set. "Set as default" persists the selection (selection-only, no credentials) to the per-repo `.corbits/settings.json` via `saveLocalSettings`. Recent/favorite model pairs persist in global settings. #### Event log rendering diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 2074924a0..bcfe530c1 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -271,7 +271,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. From `/model`, press **c** or **Ctrl+A** to open Connect provider. First-class providers (Codex, xAI, OpenCode Zen, Anthropic, OpenAI, Google, OpenCode Go) 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. +**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. **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/docs/PRODUCT.md b/docs/PRODUCT.md index 8ef2093e3..edaf2e282 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -74,9 +74,9 @@ 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`, `/mcp`, and `/goal` (session goal: expand a brief into an acceptance checklist and auto-continue until every criterion is done — see `/goal [turns] `, `/goal pause|resume|clear|status`, optional `--tokens N` / `--replace`), plus a `/` command per available workflow. Plugins can register additional commands. -Providers are **models-first**: there is no standalone `/login` command. From `/model`, Connect provider lists first-class providers (Codex, xAI, OpenCode Zen, Anthropic, OpenAI, Google, OpenCode Go). OAuth providers (Codex, xAI) open their existing browser login; API-key providers prompt for a key, 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; 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. -`/model` opens a dedicated full-screen modal — the single place agent configuration lives. It lists configured providers, marks the active one, and lets you connect new providers (**c** / **Ctrl+A**), switch provider and model, and manage tiers and profiles. 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). The surface is section-based so future configuration (system-prompt overrides, profiles) lands as additional sections rather than new slash commands. +`/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). `/goal ` arms a session-scoped goal governor. The operator brief is **not** the completion condition: the agent must clarify success (via `ask_operator` when vague) and expand it into a multi-item **acceptance** checklist with `manage_goal` *before* substantial work. Work steps go in `manage_tasks` (shown as **Work** while a goal is active) — separate from acceptance. Lifecycle phases surface in the UI: **planning** (define Acceptance) → **implementing** (Work primary; Acceptance compact; `doing` on a criterion stays here) → **reviewing** (starts when any criterion is `done` or `blocked`) → **completed** (all non-cancelled criteria done; auto-achieves). After each clean yield the agent is re-inferred until every acceptance criterion is done, a finite turn/token budget soft-stops, or the operator pauses/clears. **Default turn budget is unlimited** (`0`); an optional leading integer caps continues (`/goal 40 ship the feature`). Resume restores a prior goal as **paused** (never silently re-armed); unlimited goals stay unlimited on resume, finite ones get headroom. While a goal is **active**, permission prompts that still need a human answer auto-skip after ~15s with a note back to the agent (human may be away — continue another way); the operator can still approve/deny earlier. Pair with auto mode and/or `--dangerously-skip-permissions` for longer unattended runs. Goal mode does not shrink tools, skills, slash commands, sub-agents, or MCP. diff --git a/packages/first-class-providers/src/index.ts b/packages/first-class-providers/src/index.ts index 97f6d2af5..57eb74d60 100644 --- a/packages/first-class-providers/src/index.ts +++ b/packages/first-class-providers/src/index.ts @@ -1,11 +1,15 @@ export { FIRST_CLASS_PROVIDERS, + connectListProviders, + firstClassPathAsProvider, firstClassProviderById, } from "./providers.js"; export type { FirstClassAuthKind, + FirstClassBillingProduct, FirstClassOAuthProvider, FirstClassProviderDef, + FirstClassProviderPath, } from "./types.js"; // Re-export Go package surface so hosts can depend on one entry for connect. diff --git a/packages/first-class-providers/src/providers.test.ts b/packages/first-class-providers/src/providers.test.ts index 608c30885..3e4a11083 100644 --- a/packages/first-class-providers/src/providers.test.ts +++ b/packages/first-class-providers/src/providers.test.ts @@ -1,23 +1,62 @@ import { describe, expect, test } from "bun:test"; -import { FIRST_CLASS_PROVIDERS, firstClassProviderById } from "./providers.js"; +import { + FIRST_CLASS_PROVIDERS, + connectListProviders, + firstClassPathAsProvider, + firstClassProviderById, +} from "./providers.js"; describe("FIRST_CLASS_PROVIDERS", () => { - test("lists all first-class providers in product order", () => { + test("lists Tier A providers in product order", () => { expect(FIRST_CLASS_PROVIDERS.map((p) => p.id)).toEqual([ - "codex", + "openai", "xai", + "opencode-go", "zen", + "zai", "anthropic", - "openai", "google", - "opencode-go", + "custom", ]); }); - test("Codex and xAI are OAuth; others are API key", () => { - expect(firstClassProviderById("codex")?.auth).toBe("oauth"); + test("connectListProviders matches FIRST_CLASS_PROVIDERS", () => { + expect(connectListProviders()).toBe(FIRST_CLASS_PROVIDERS); + }); + + test("has no separate Codex connect row", () => { + expect(FIRST_CLASS_PROVIDERS.some((p) => p.id === "codex")).toBe(false); + expect(FIRST_CLASS_PROVIDERS.map((p) => p.label)).not.toContain("OpenAI Codex"); + }); + + test("Custom is last and uses custom auth", () => { + const last = FIRST_CLASS_PROVIDERS[FIRST_CLASS_PROVIDERS.length - 1]; + expect(last?.id).toBe("custom"); + expect(last?.auth).toBe("custom"); + }); + + test("OpenAI is a chooser with ChatGPT oauth and API key paths", () => { + const openai = firstClassProviderById("openai"); + expect(openai?.auth).toBe("chooser"); + expect(openai?.paths?.map((p) => p.id)).toEqual(["chatgpt", "api"]); + + const chatgpt = openai?.paths?.find((p) => p.id === "chatgpt"); + expect(chatgpt?.auth).toBe("oauth"); + expect(chatgpt?.oauth).toBe("codex"); + expect(chatgpt?.providerId).toBe("codex"); + + const api = openai?.paths?.find((p) => p.id === "api"); + expect(api?.auth).toBe("api-key"); + expect(api?.providerId).toBe("openai"); + expect(api?.baseURL).toBe("https://api.openai.com/v1"); + expect((api?.models ?? []).length).toBeGreaterThan(0); + expect(api?.models).toContain(api?.defaultModel); + }); + + test("xAI is OAuth; Go/Zen/Z.AI/Anthropic/Google are API key", () => { expect(firstClassProviderById("xai")?.auth).toBe("oauth"); - for (const id of ["zen", "anthropic", "openai", "google", "opencode-go"] as const) { + expect(firstClassProviderById("xai")?.oauth).toBe("xai"); + for (const id of ["opencode-go", "zen", "zai", "anthropic", "google"] as const) { expect(firstClassProviderById(id)?.auth).toBe("api-key"); } }); @@ -32,7 +71,40 @@ describe("FIRST_CLASS_PROVIDERS", () => { } }); + test("Z.AI Coding Plan uses coding paas base URL", () => { + const zai = firstClassProviderById("zai"); + expect(zai?.label).toBe("Z.AI Coding Plan"); + expect(zai?.baseURL).toBe("https://api.z.ai/api/coding/paas/v4"); + expect(zai?.defaultModel).toBe("glm-5.2"); + expect(zai?.models).toContain("glm-5.2"); + }); + + test("OpenCode Go is flagged with subscription billing", () => { + const go = firstClassProviderById("opencode-go"); + expect(go?.opencodeGo).toBe(true); + expect(go?.billingProduct).toBe("subscription"); + expect(go?.authHint?.toLowerCase()).toContain("subscription"); + expect(go?.authHint).toContain("https://opencode.ai/auth"); + }); + + test("OpenCode Zen uses credits billing and auth page hint", () => { + const zen = firstClassProviderById("zen"); + expect(zen?.billingProduct).toBe("credits"); + expect(zen?.authHint?.toLowerCase()).toMatch(/credit|pay-as-you-go/); + expect(zen?.authHint).toContain("https://opencode.ai/auth"); + }); + test("Anthropic is flagged for messages adapter", () => { expect(firstClassProviderById("anthropic")?.anthropic).toBe(true); }); + + test("firstClassPathAsProvider seeds OpenAI API path", () => { + const openai = firstClassProviderById("openai"); + if (openai === undefined) throw new Error("openai missing"); + const seeded = firstClassPathAsProvider(openai, "api"); + expect(seeded?.id).toBe("openai"); + expect(seeded?.auth).toBe("api-key"); + expect(seeded?.baseURL).toBe("https://api.openai.com/v1"); + expect(firstClassPathAsProvider(openai, "chatgpt")).toBeUndefined(); + }); }); diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts index 344c3e782..b35ed6ee8 100644 --- a/packages/first-class-providers/src/providers.ts +++ b/packages/first-class-providers/src/providers.ts @@ -8,16 +8,37 @@ import { } from "../../opencode-go/src/index.js"; import type { FirstClassProviderDef } from "./types.js"; +const OPENAI_API_MODELS = ["gpt-5.4", "gpt-5.4-mini", "gpt-4.1", "o3", "o4-mini"] as const; +const OPENAI_API_DEFAULT = "gpt-5.4"; + /** * First-class providers shown in the models-surface Connect list. - * Order matches product preference: subscription OAuth first, then API keys. + * Tier A order: dual-path OpenAI, OAuth xAI, Go/Zen, Z.AI, big three, Custom. */ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ { - id: "codex", - label: "OpenAI Codex", - auth: "oauth", - oauth: "codex", + id: "openai", + label: "OpenAI", + auth: "chooser", + paths: [ + { + id: "chatgpt", + label: "ChatGPT — Login via Browser", + auth: "oauth", + oauth: "codex", + providerId: "codex", + }, + { + id: "api", + label: "OpenAI API — API key", + auth: "api-key", + baseURL: "https://api.openai.com/v1", + models: OPENAI_API_MODELS, + defaultModel: OPENAI_API_DEFAULT, + authHint: "Paste your OpenAI API key (sk-...)", + providerId: "openai", + }, + ], }, { id: "xai", @@ -25,6 +46,18 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ auth: "oauth", oauth: "xai", }, + { + id: OPENCODE_GO_PROVIDER_ID, + label: OPENCODE_GO_DISPLAY_NAME, + auth: "api-key", + baseURL: OPENCODE_GO_BASE_URL, + models: OPENCODE_GO_MODEL_IDS, + defaultModel: OPENCODE_GO_DEFAULT_MODEL, + authHint: + "OpenCode Go subscription — paste your API key from https://opencode.ai/auth", + opencodeGo: true, + billingProduct: "subscription", + }, { id: "zen", label: "OpenCode Zen", @@ -39,7 +72,19 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ "gemini-3-pro", ], defaultModel: "claude-sonnet-4-5", - authHint: "Paste your OpenCode Zen API key from https://opencode.ai/auth", + authHint: + "OpenCode Zen pay-as-you-go credits — paste your API key from https://opencode.ai/auth", + billingProduct: "credits", + }, + { + id: "zai", + label: "Z.AI Coding Plan", + auth: "api-key", + // Coding Plan OpenAI-compatible endpoint (not the general paas/v4 API). + baseURL: "https://api.z.ai/api/coding/paas/v4", + models: ["glm-5.2", "glm-5.1", "glm-4.7"], + defaultModel: "glm-5.2", + authHint: "Paste your Z.AI Coding Plan API key from https://z.ai", }, { id: "anthropic", @@ -55,15 +100,6 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ authHint: "Paste your Anthropic API key (sk-ant-...)", anthropic: true, }, - { - id: "openai", - label: "OpenAI", - auth: "api-key", - baseURL: "https://api.openai.com/v1", - models: ["gpt-5.4", "gpt-5.4-mini", "gpt-4.1", "o3", "o4-mini"], - defaultModel: "gpt-5.4", - authHint: "Paste your OpenAI API key (sk-...)", - }, { id: "google", label: "Google", @@ -75,16 +111,42 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ authHint: "Paste your Google AI Studio API key", }, { - id: OPENCODE_GO_PROVIDER_ID, - label: OPENCODE_GO_DISPLAY_NAME, - auth: "api-key", - baseURL: OPENCODE_GO_BASE_URL, - models: OPENCODE_GO_MODEL_IDS, - defaultModel: OPENCODE_GO_DEFAULT_MODEL, - authHint: OPENCODE_GO_AUTH_HINT, + id: "custom", + label: "Custom", + auth: "custom", + authHint: "Open the full form for any OpenAI-compatible endpoint", }, ] as const; +/** Connect-list rows (same as FIRST_CLASS_PROVIDERS; explicit export for hosts). */ +export function connectListProviders(): readonly FirstClassProviderDef[] { + return FIRST_CLASS_PROVIDERS; +} + export function firstClassProviderById(id: string): FirstClassProviderDef | undefined { return FIRST_CLASS_PROVIDERS.find((p) => p.id === id); } + +/** + * Resolve a chooser path into a form-seedable def (api-key path only). + * OAuth paths should trigger login with path.oauth instead. + */ +export function firstClassPathAsProvider( + def: FirstClassProviderDef, + pathId: string, +): FirstClassProviderDef | undefined { + const path = def.paths?.find((p) => p.id === pathId); + if (path === undefined || path.auth !== "api-key") return undefined; + return { + id: path.providerId ?? def.id, + label: path.label, + auth: "api-key", + ...(path.baseURL !== undefined ? { baseURL: path.baseURL } : {}), + ...(path.models !== undefined ? { models: path.models } : {}), + ...(path.defaultModel !== undefined ? { defaultModel: path.defaultModel } : {}), + ...(path.authHint !== undefined ? { authHint: path.authHint } : {}), + ...(def.anthropic === true ? { anthropic: true } : {}), + ...(def.opencodeGo === true ? { opencodeGo: true } : {}), + ...(def.billingProduct !== undefined ? { billingProduct: def.billingProduct } : {}), + }; +} diff --git a/packages/first-class-providers/src/types.ts b/packages/first-class-providers/src/types.ts index d1b1c66f0..6f7abf3bf 100644 --- a/packages/first-class-providers/src/types.ts +++ b/packages/first-class-providers/src/types.ts @@ -1,7 +1,30 @@ -export type FirstClassAuthKind = "oauth" | "api-key"; +export type FirstClassAuthKind = "oauth" | "api-key" | "chooser" | "custom"; export type FirstClassOAuthProvider = "codex" | "xai"; +export type FirstClassBillingProduct = "subscription" | "credits"; + +/** One connect path under a chooser provider (e.g. OpenAI ChatGPT vs API key). */ +export type FirstClassProviderPath = { + id: string; + label: string; + auth: "oauth" | "api-key"; + /** OAuth flow key when auth === "oauth". */ + oauth?: FirstClassOAuthProvider; + /** Default inference base URL for api-key paths. */ + baseURL?: string; + /** Pre-seeded models for api-key paths. */ + models?: readonly string[]; + defaultModel?: string; + /** Short paste hint for api-key paths. */ + authHint?: string; + /** + * Catalog provider id written after connect. + * e.g. "codex" for ChatGPT OAuth, "openai" for API key. + */ + providerId?: string; +}; + export type FirstClassProviderDef = { id: string; label: string; @@ -20,4 +43,13 @@ export type FirstClassProviderDef = { * if the adapter appends /v1/messages). */ anthropic?: boolean; + /** When true, host should treat this as OpenCode Go (protocol + key validation). */ + opencodeGo?: boolean; + /** Optional product billing style for Go/Zen-style subscriptions vs credits. */ + billingProduct?: FirstClassBillingProduct; + /** + * Sub-paths when auth === "chooser". Operator picks a path, then oauth or + * api-key flow runs against that path's fields / providerId. + */ + paths?: readonly FirstClassProviderPath[]; }; diff --git a/packages/opencode-go/src/identity.test.ts b/packages/opencode-go/src/identity.test.ts new file mode 100644 index 000000000..69a27a576 --- /dev/null +++ b/packages/opencode-go/src/identity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; + +import { OPENCODE_GO_DISPLAY_NAME, OPENCODE_GO_PROVIDER_ID } from "./constants.js"; +import { isOpenCodeGoProvider, isOpenCodeGoProviderId } from "./identity.js"; + +describe("isOpenCodeGoProviderId", () => { + test("matches stable id and display name", () => { + expect(isOpenCodeGoProviderId(OPENCODE_GO_PROVIDER_ID)).toBe(true); + expect(isOpenCodeGoProviderId(OPENCODE_GO_DISPLAY_NAME)).toBe(true); + expect(isOpenCodeGoProviderId("opencode-go")).toBe(true); + expect(isOpenCodeGoProviderId("OpenCode Go")).toBe(true); + }); + + test("rejects zen, empty, and unrelated names", () => { + expect(isOpenCodeGoProviderId("zen")).toBe(false); + expect(isOpenCodeGoProviderId("openai")).toBe(false); + expect(isOpenCodeGoProviderId("")).toBe(false); + expect(isOpenCodeGoProviderId(undefined)).toBe(false); + expect(isOpenCodeGoProviderId("opencode go")).toBe(false); + }); +}); + +describe("isOpenCodeGoProvider", () => { + test("true on flag or known name", () => { + expect(isOpenCodeGoProvider({ opencodeGo: true })).toBe(true); + expect(isOpenCodeGoProvider({ name: "opencode-go" })).toBe(true); + expect(isOpenCodeGoProvider({ name: "OpenCode Go" })).toBe(true); + }); + + test("false without flag or known name", () => { + expect(isOpenCodeGoProvider({ name: "zen" })).toBe(false); + expect(isOpenCodeGoProvider({})).toBe(false); + expect(isOpenCodeGoProvider({ opencodeGo: false, name: "zen" })).toBe(false); + }); +}); diff --git a/packages/opencode-go/src/identity.ts b/packages/opencode-go/src/identity.ts new file mode 100644 index 000000000..110338021 --- /dev/null +++ b/packages/opencode-go/src/identity.ts @@ -0,0 +1,21 @@ +import { OPENCODE_GO_DISPLAY_NAME, OPENCODE_GO_PROVIDER_ID } from "./constants.js"; + +/** + * True when this catalog/settings name is the first-class OpenCode Go provider. + * Accepts both the stable id and the human display label so mis-seeded rows still + * route to the subscription gateway. + */ +export function isOpenCodeGoProviderId(name: string | undefined): boolean { + if (name === undefined || name.length === 0) return false; + return name === OPENCODE_GO_PROVIDER_ID || name === OPENCODE_GO_DISPLAY_NAME; +} + +/** + * True when a provider entry is OpenCode Go — explicit flag or known id/label. + */ +export function isOpenCodeGoProvider(entry: { + name?: string; + opencodeGo?: boolean; +}): boolean { + return entry.opencodeGo === true || isOpenCodeGoProviderId(entry.name); +} diff --git a/packages/opencode-go/src/index.ts b/packages/opencode-go/src/index.ts index bcec25fb0..65b65c94f 100644 --- a/packages/opencode-go/src/index.ts +++ b/packages/opencode-go/src/index.ts @@ -23,6 +23,7 @@ export { resolveGoEndpoint, type GoEndpoint } from "./endpoint.js"; export { validateGoApiKey, type GoApiKeyValidation } from "./auth.js"; export { fetchGoUsage, formatGoUsage, type GoFetch, type GoUsage, type GoUsageWindow } from "./usage.js"; export { buildGoCatalogEntry, type GoCatalogEntry } from "./catalog.js"; +export { isOpenCodeGoProvider, isOpenCodeGoProviderId } from "./identity.js"; export { isOpenCodeGoURL, parseGoAPIError, diff --git a/src/config/index.ts b/src/config/index.ts index b91c4c57e..8c95fcc5e 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -33,6 +33,7 @@ import { OPENAI_RESPONSES_PROVIDER } from "../provider/openai-responses-adapter. import { xaiUserIdFromAccessToken } from "../auth/xai/session.js"; import { OPENCODE_GO_BASE_URL, + isOpenCodeGoProvider, resolveGoEndpoint, } from "../../packages/opencode-go/src/index.js"; @@ -591,9 +592,11 @@ export async function loadConfig( export function catalogEntryAsProviderSettings(entry: ProviderCatalogEntry): ProviderSettings { // Anthropic and Go anthropic-protocol bases must not be forced through the // OpenAI-compatible normalizer (which assumes a /v1 chat-completions root). + // Go identity is flag or known provider id — always force subscription base. + const go = isOpenCodeGoProvider(entry); const baseURL = - entry.anthropic === true || entry.opencodeGo === true - ? entry.baseURL.replace(/\/+$/, "") + entry.anthropic === true || go + ? (go ? OPENCODE_GO_BASE_URL : entry.baseURL).replace(/\/+$/, "") : normalizeOpenAICompatibleBaseURL(entry.baseURL); return { baseURL, @@ -604,7 +607,7 @@ export function catalogEntryAsProviderSettings(entry: ProviderCatalogEntry): Pro ...(entry.free !== undefined ? { free: entry.free } : {}), ...(entry.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), ...(entry.anthropic === true ? { anthropic: true } : {}), - ...(entry.opencodeGo === true ? { opencodeGo: true } : {}), + ...(go ? { opencodeGo: true } : {}), }; } @@ -644,21 +647,29 @@ export function buildProviderCatalog( resolved: ResolvedProvider, ): ProviderCatalogEntry[] { if (settings !== null && Object.keys(settings.providers).length > 0) { - return Object.entries(settings.providers).map(([name, p]): ProviderCatalogEntry => ({ - name, - baseURL: - p.anthropic === true || p.opencodeGo === true - ? p.baseURL.replace(/\/+$/, "") - : normalizeOpenAICompatibleBaseURL(p.baseURL), - ...(p.keyless === true ? { keyless: true } : {}), - ...(p.apiKey !== undefined && p.apiKey.length > 0 ? { apiKey: p.apiKey } : {}), - models: p.models, - ...(p.defaultModel !== undefined ? { defaultModel: p.defaultModel } : {}), - ...(p.free !== undefined ? { free: p.free } : {}), - ...(p.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), - ...(p.anthropic === true ? { anthropic: true } : {}), - ...(p.opencodeGo === true ? { opencodeGo: true } : {}), - })); + return Object.entries(settings.providers).map(([name, p]): ProviderCatalogEntry => { + // Heal mis-seeded Go rows on load: known id/label or flag → pin baseURL + flag. + const go = isOpenCodeGoProvider({ + name, + ...(p.opencodeGo === true ? { opencodeGo: true as const } : {}), + }); + return { + name, + baseURL: go + ? OPENCODE_GO_BASE_URL + : p.anthropic === true + ? p.baseURL.replace(/\/+$/, "") + : normalizeOpenAICompatibleBaseURL(p.baseURL), + ...(p.keyless === true ? { keyless: true } : {}), + ...(p.apiKey !== undefined && p.apiKey.length > 0 ? { apiKey: p.apiKey } : {}), + models: p.models, + ...(p.defaultModel !== undefined ? { defaultModel: p.defaultModel } : {}), + ...(p.free !== undefined ? { free: p.free } : {}), + ...(p.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), + ...(p.anthropic === true ? { anthropic: true } : {}), + ...(go ? { opencodeGo: true } : {}), + }; + }); } return [ { diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index c30164ebe..097651d46 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -20,6 +20,7 @@ import type { import { PROVIDER_TIERS, resolveTierDefinition, tierDefinitionAt } from "./settings.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import { SOURCE_MAX_TOKENS } from "./index.js"; +import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js"; export type BuildSourceContext = { sessionId: string; @@ -126,7 +127,14 @@ export function buildInferenceSourceForRef( model: ref.model, }); } - if (entry?.opencodeGo === true || providerSettings?.opencodeGo === true) { + if ( + isOpenCodeGoProvider({ + name: ref.provider, + ...(entry?.opencodeGo === true || providerSettings?.opencodeGo === true + ? { opencodeGo: true as const } + : {}), + }) + ) { return buildGoSource({ id: ref.provider, ...(entry?.apiKey !== undefined diff --git a/src/config/providers.test.ts b/src/config/providers.test.ts index bca35737b..96bfe32d5 100644 --- a/src/config/providers.test.ts +++ b/src/config/providers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { OPENCODE_GO_BASE_URL } from "../../packages/opencode-go/src/index.js"; import type { ProviderCatalogEntry } from "./index.js"; import { buildProviderEntry } from "./providers.js"; @@ -52,3 +53,102 @@ describe("buildProviderEntry bifrostVirtualKey preserve-on-edit", () => { expect(result.entry.bifrostVirtualKey).toBeUndefined(); }); }); + +describe("buildProviderEntry OpenCode Go baseURL pin", () => { + test("forces OPENCODE_GO_BASE_URL when opencodeGo is true even if submission is bare zen", () => { + const result = buildProviderEntry( + { + name: "opencode-go", + baseURL: "https://opencode.ai/zen/v1", + apiKey: "sk-go-key-long-enough", + models: ["kimi-k2.7-code"], + defaultModel: "kimi-k2.7-code", + opencodeGo: true, + }, + [], + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.baseURL).toBe(OPENCODE_GO_BASE_URL); + expect(result.entry.opencodeGo).toBe(true); + expect(result.entry.baseURL).not.toBe("https://opencode.ai/zen/v1"); + }); + + test("pins Go baseURL on edit when existing has opencodeGo and form submits zen URL", () => { + const catalog: ProviderCatalogEntry[] = [ + { + name: "opencode-go", + baseURL: OPENCODE_GO_BASE_URL, + apiKey: "sk-go-existing", + models: ["kimi-k2.7-code"], + opencodeGo: true, + }, + ]; + const result = buildProviderEntry( + { + name: "opencode-go", + originalName: "opencode-go", + baseURL: "https://opencode.ai/zen/v1", + models: ["kimi-k2.7-code"], + }, + catalog, + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.baseURL).toBe(OPENCODE_GO_BASE_URL); + expect(result.entry.opencodeGo).toBe(true); + }); + + test("does not rewrite baseURL for non-Go providers", () => { + const result = buildProviderEntry( + { + name: "zen", + baseURL: "https://opencode.ai/zen/v1", + apiKey: "sk-zen-key", + models: ["claude-sonnet-4-5"], + }, + [], + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.baseURL).toBe("https://opencode.ai/zen/v1"); + expect(result.entry.opencodeGo).toBeUndefined(); + }); + + test("pins Go baseURL when name is opencode-go even without opencodeGo flag", () => { + const result = buildProviderEntry( + { + name: "opencode-go", + baseURL: "https://opencode.ai/zen/v1", + apiKey: "sk-go-key-long-enough", + models: ["kimi-k2.7-code"], + }, + [], + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.baseURL).toBe(OPENCODE_GO_BASE_URL); + expect(result.entry.opencodeGo).toBe(true); + }); + + test("pins Go baseURL when name is OpenCode Go display label", () => { + const result = buildProviderEntry( + { + name: "OpenCode Go", + baseURL: "https://opencode.ai/zen/v1", + apiKey: "sk-go-key-long-enough", + models: ["kimi-k2.7-code"], + }, + [], + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.baseURL).toBe(OPENCODE_GO_BASE_URL); + expect(result.entry.opencodeGo).toBe(true); + }); +}); diff --git a/src/config/providers.ts b/src/config/providers.ts index 415501b8e..f7f7fea1c 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -1,3 +1,8 @@ +import { + OPENCODE_GO_BASE_URL, + isOpenCodeGoProvider, + isOpenCodeGoProviderId, +} from "../../packages/opencode-go/src/index.js"; import type { ProviderCatalogEntry } from "./index.js"; export type ProviderSubmission = { @@ -37,17 +42,26 @@ export function buildProviderEntry( return { ok: false, error: "Provider API key is required" }; } // Protocol flags are not form fields — preserve catalog flags on edit unless - // the submission explicitly re-asserts them (Connect path). + // the submission explicitly re-asserts them (Connect path). Known Go ids and + // display labels always pin even when the flag was dropped from disk. const anthropic = submission.anthropic === true || existing?.anthropic === true; - const opencodeGo = submission.opencodeGo === true || existing?.opencodeGo === true; + const opencodeGo = isOpenCodeGoProvider({ + name: submission.name, + opencodeGo: + submission.opencodeGo === true || + existing?.opencodeGo === true || + isOpenCodeGoProviderId(submission.originalName), + }); + // Never persist bare Zen PAYG baseURL for a Go subscription provider. + const baseURL = opencodeGo ? OPENCODE_GO_BASE_URL : submission.baseURL; const entry: ProviderCatalogEntry = { name: submission.name, - baseURL: submission.baseURL, + baseURL, ...(keyless ? { keyless: true } : {}), ...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), models: submission.models, ...(submission.defaultModel !== undefined ? { defaultModel: submission.defaultModel } : {}), -// Form no longer exposes Bifrost; keep any previously stored flag on edit so + // Form no longer exposes Bifrost; keep any previously stored flag on edit so // re-saving a provider does not silently drop x-bf-vk routing. ...(submission.bifrostVirtualKey === true || existing?.bifrostVirtualKey === true ? { bifrostVirtualKey: true } diff --git a/src/config/settings.ts b/src/config/settings.ts index 1083116b0..7f16d6fd2 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -8,6 +8,10 @@ import { type } from "arktype"; import { SETTINGS_DIR_NAME } from "../branding.js"; import { REASONING_EFFORTS, isReasoningEffort, type ReasoningEffort } from "../provider/reasoning-effort.js"; import { isSessionMode, type SessionMode } from "./session-mode.js"; +import { + OPENCODE_GO_BASE_URL, + isOpenCodeGoProvider, +} from "../../packages/opencode-go/src/index.js"; // A configured inference provider. `apiKey` is secret and lives only in the // global settings file; `baseURL` is editable provider metadata that lives with @@ -52,6 +56,12 @@ export type TierConfig = TierAssignment | TierDefinition; export const PROVIDER_TIERS: readonly ProviderTier[] = ["fast", "standard", "clever"]; +// Provider+model identity used by the models-first picker (recent / favorites). +export type ModelRef = { provider: string; model: string }; + +export const DEFAULT_RECENT_MODELS_STORED = 10; +export const DEFAULT_RECENT_MODELS_SHOWN = 5; + // Global settings: the set of providers plus which one to use by default. export type Settings = { defaultProvider?: string; @@ -140,8 +150,59 @@ export type Settings = { serviceName?: string; resourceAttributes?: Record; }; + // Models-first /model picker: most-recently-used provider+model pairs (newest + // first). Global preference only — no credentials. Cap stored list (~10); + // UI surfaces fewer via listRecentModels. + recentModels?: ModelRef[]; + // Operator-starred provider+model pairs for the models-first picker. + favoriteModels?: ModelRef[]; }; +function modelRefKey(ref: ModelRef): string { + return `${ref.provider}\0${ref.model}`; +} + +// Newest first, deduped by provider+model, capped at `max` (default 10). +export function pushRecentModel( + settings: Settings, + ref: ModelRef, + max: number = DEFAULT_RECENT_MODELS_STORED, +): Settings { + const next: ModelRef = { provider: ref.provider, model: ref.model }; + const rest = (settings.recentModels ?? []).filter( + (r) => modelRefKey(r) !== modelRefKey(next), + ); + return { + ...settings, + recentModels: [next, ...rest].slice(0, Math.max(0, max)), + }; +} + +// Add the pair if absent; remove it if present. +export function toggleFavoriteModel(settings: Settings, ref: ModelRef): Settings { + const next: ModelRef = { provider: ref.provider, model: ref.model }; + const key = modelRefKey(next); + const current = settings.favoriteModels ?? []; + const has = current.some((r) => modelRefKey(r) === key); + return { + ...settings, + favoriteModels: has + ? current.filter((r) => modelRefKey(r) !== key) + : [...current, next], + }; +} + +export function listRecentModels( + settings: Settings, + max: number = DEFAULT_RECENT_MODELS_SHOWN, +): ModelRef[] { + return (settings.recentModels ?? []).slice(0, Math.max(0, max)); +} + +export function listFavoriteModels(settings: Settings): ModelRef[] { + return settings.favoriteModels ?? []; +} + // Maps the settings shell block to the shape the shell-guard plugin expects. // Returns undefined when unset so the plugin applies its own defaults. export function shellTimeoutFromSettings( @@ -352,6 +413,11 @@ const ProviderSettingsSchema = type({ "opencodeGo?": "boolean", }); +const ModelRefSchema = type({ + provider: "string", + model: "string", +}); + const TierProviderRefSchema = type({ provider: "string", model: "string", @@ -411,6 +477,8 @@ const SettingsSchema = type({ "serviceName?": "string", "resourceAttributes?": "Record", }), + "recentModels?": ModelRefSchema.array(), + "favoriteModels?": ModelRefSchema.array(), }); // Per-entry MCP shape without the name key. The "exactly one transport" rule is @@ -572,6 +640,8 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "tools", "telemetry", "otel", + "recentModels", + "favoriteModels", ] as const satisfies readonly (keyof OptionalSettingsFields)[]; /** Optional local settings keys the load path is required to consider. */ @@ -649,6 +719,8 @@ export async function loadSettings(path: string): Promise { tools: s.tools as Settings["tools"] | undefined, telemetry: s.telemetry as Settings["telemetry"] | undefined, otel: s.otel as Settings["otel"] | undefined, + recentModels: s.recentModels as Settings["recentModels"] | undefined, + favoriteModels: s.favoriteModels as Settings["favoriteModels"] | undefined, }; return { providers: s.providers as Settings["providers"], @@ -974,7 +1046,11 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider { const selected = providerName !== undefined ? providers[providerName] : undefined; - const baseURL = selected?.baseURL; + const go = isOpenCodeGoProvider({ + ...(providerName !== undefined ? { name: providerName } : {}), + ...(selected?.opencodeGo === true ? { opencodeGo: true as const } : {}), + }); + const baseURL = go ? OPENCODE_GO_BASE_URL : selected?.baseURL; const apiKey = selected?.apiKey; const keyless = selected?.keyless === true; const model = cli.model ?? local?.model ?? selected?.defaultModel ?? selected?.models[0]; @@ -1014,7 +1090,7 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider { return { providerName, - baseURL: normalizeOpenAICompatibleBaseURL(baseURL), + baseURL: go ? OPENCODE_GO_BASE_URL : normalizeOpenAICompatibleBaseURL(baseURL), apiKey: apiKey ?? "", model, ...(keyless ? { keyless: true } : {}), diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 2998baf1c..2845e1d6e 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -1,7 +1,7 @@ import type { InferenceError } from "@intx/types/runtime"; import { + isOpenCodeGoProviderId, isOpenCodeGoURL, - OPENCODE_GO_PROVIDER_ID, parseGoAPIError, } from "../packages/opencode-go/src/index.js"; @@ -117,7 +117,7 @@ function tryParseJSON(text: string): unknown { */ function isKnownOpenCodeGoError(error: InferenceErrorWithGoContext, rawText: string, messageText: string): boolean { if (error.opencodeGo === true) return true; - if (error.providerId === OPENCODE_GO_PROVIDER_ID) return true; + if (isOpenCodeGoProviderId(error.providerId)) return true; if (isOpenCodeGoURL(error.requestURL)) return true; if (isOpenCodeGoURL(rawText)) return true; return /GoUsageLimitError|FreeUsageLimitError|BlackUsageLimitError|Console Go|opencode\.ai\/zen\/go/i.test( diff --git a/src/provider/billing-product.test.ts b/src/provider/billing-product.test.ts new file mode 100644 index 000000000..642a3a857 --- /dev/null +++ b/src/provider/billing-product.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; + +import { + billingProductForProvider, + isBareZenBaseURL, + isGoModelOnZenPath, +} from "./billing-product.js"; + +describe("isBareZenBaseURL", () => { + test("matches bare zen PAYG bases", () => { + expect(isBareZenBaseURL("https://opencode.ai/zen/v1")).toBe(true); + expect(isBareZenBaseURL("https://opencode.ai/zen/v1/")).toBe(true); + expect(isBareZenBaseURL("https://opencode.ai/zen")).toBe(true); + }); + + test("rejects Go subscription bases", () => { + expect(isBareZenBaseURL("https://opencode.ai/zen/go/v1")).toBe(false); + expect(isBareZenBaseURL("https://opencode.ai/zen/go")).toBe(false); + }); + + test("rejects unrelated hosts", () => { + expect(isBareZenBaseURL("https://api.openai.com/v1")).toBe(false); + }); +}); + +describe("billingProductForProvider", () => { + test("OpenCode Go flag or id is subscription", () => { + expect(billingProductForProvider({ opencodeGo: true })).toBe("subscription"); + expect(billingProductForProvider({ name: "opencode-go" })).toBe("subscription"); + expect(billingProductForProvider({ name: "OpenCode Go" })).toBe("subscription"); + expect( + billingProductForProvider({ + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + opencodeGo: true, + }), + ).toBe("subscription"); + }); + + test("zen name or bare zen baseURL is credits", () => { + expect(billingProductForProvider({ name: "zen" })).toBe("credits"); + expect( + billingProductForProvider({ + name: "custom-zen", + baseURL: "https://opencode.ai/zen/v1", + }), + ).toBe("credits"); + }); + + test("Go takes precedence over a wrong zen baseURL when flag is set", () => { + expect( + billingProductForProvider({ + name: "opencode-go", + baseURL: "https://opencode.ai/zen/v1", + opencodeGo: true, + }), + ).toBe("subscription"); + }); + + test("unknown providers return undefined", () => { + expect( + billingProductForProvider({ + name: "openai", + baseURL: "https://api.openai.com/v1", + }), + ).toBeUndefined(); + }); +}); + +describe("isGoModelOnZenPath", () => { + test("true when a known Go model sits on a Zen-billed provider", () => { + expect( + isGoModelOnZenPath("kimi-k2.7-code", { + name: "zen", + baseURL: "https://opencode.ai/zen/v1", + }), + ).toBe(true); + }); + + test("false when provider is Go subscription", () => { + expect( + isGoModelOnZenPath("kimi-k2.7-code", { + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + opencodeGo: true, + }), + ).toBe(false); + }); + + test("false for non-Go models on Zen", () => { + expect( + isGoModelOnZenPath("claude-sonnet-4-5", { + name: "zen", + baseURL: "https://opencode.ai/zen/v1", + }), + ).toBe(false); + }); + + test("false for Go models on unrelated providers", () => { + expect( + isGoModelOnZenPath("kimi-k2.7-code", { + name: "openai", + baseURL: "https://api.openai.com/v1", + }), + ).toBe(false); + }); +}); + +describe("billingProductForProvider", () => { + test("resolves subscription and credits labels for UI rows", () => { + expect(billingProductForProvider({ name: "opencode-go", opencodeGo: true })).toBe( + "subscription", + ); + expect(billingProductForProvider({ name: "zen" })).toBe("credits"); + expect(billingProductForProvider({ name: "openai" })).toBeUndefined(); + }); +}); diff --git a/src/provider/billing-product.ts b/src/provider/billing-product.ts new file mode 100644 index 000000000..aed3f8727 --- /dev/null +++ b/src/provider/billing-product.ts @@ -0,0 +1,68 @@ +import { + isKnownGoModel, + isOpenCodeGoProvider, +} from "../../packages/opencode-go/src/index.js"; + +/** How the provider is billed when known (Go subscription vs Zen PAYG credits). */ +export type BillingProduct = "subscription" | "credits"; + +export type BillingProductProvider = { + name?: string; + baseURL?: string; + opencodeGo?: boolean; +}; + +const BARE_ZEN_BASES = new Set([ + "https://opencode.ai/zen/v1", + "https://opencode.ai/zen", + "http://opencode.ai/zen/v1", + "http://opencode.ai/zen", +]); + +/** True when baseURL is bare OpenCode Zen PAYG (no /go segment). */ +export function isBareZenBaseURL(baseURL: string): boolean { + const trimmed = baseURL.trim().replace(/\/+$/, ""); + if (BARE_ZEN_BASES.has(trimmed)) return true; + try { + const url = new URL(trimmed); + if (url.hostname !== "opencode.ai" && !url.hostname.endsWith(".opencode.ai")) { + return false; + } + const path = url.pathname.replace(/\/+$/, ""); + // /zen/v1 or /zen — exclude /zen/go and /zen/go/v1 + return path === "/zen/v1" || path === "/zen"; + } catch { + return false; + } +} + +/** + * Resolve the billing product for a catalog/provider entry. + * - OpenCode Go (flag or known id/label) → subscription + * - Zen by name or bare zen baseURL → credits + */ +export function billingProductForProvider( + entry: BillingProductProvider, +): BillingProduct | undefined { + if (isOpenCodeGoProvider(entry)) { + return "subscription"; + } + if (entry.name === "zen") return "credits"; + if (entry.baseURL !== undefined && isBareZenBaseURL(entry.baseURL)) { + return "credits"; + } + return undefined; +} + +/** + * True when a known Go model id is configured on a Zen-billed provider path. + * Used to surface a cross-product warning (Go model would bill as Zen PAYG). + */ +export function isGoModelOnZenPath( + modelId: string, + provider: BillingProductProvider, +): boolean { + if (!isKnownGoModel(modelId)) return false; + if (isOpenCodeGoProvider(provider)) return false; + return billingProductForProvider(provider) === "credits"; +} diff --git a/src/settings.test.ts b/src/settings.test.ts index f059bfce7..3385262bb 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -26,6 +26,10 @@ import { toolWatchdogFromSettings, loadGlobalSettingsWriteBase, markLastChangelogVersion, + pushRecentModel, + toggleFavoriteModel, + listRecentModels, + listFavoriteModels, } from "./config/settings.js"; const firepass: Settings = { @@ -232,6 +236,25 @@ describe("validators", () => { ).toBe(true); }); + test("isSettings accepts recentModels and favoriteModels", () => { + expect( + isSettings({ + providers: firepass.providers, + recentModels: [{ provider: "firepass", model: "fp-large" }], + favoriteModels: [{ provider: "firepass", model: "fp-small" }], + }), + ).toBe(true); + }); + + test("isSettings rejects malformed recentModels entries", () => { + expect( + isSettings({ + providers: firepass.providers, + recentModels: [{ provider: "firepass" }], + }), + ).toBe(false); + }); + test("isLocalSettings rejects credentials", () => { expect(isLocalSettings({ provider: "a", apiKey: "leak" })).toBe(false); }); @@ -736,3 +759,40 @@ describe("saveLocalSettings", () => { } }); }); + +describe("recent and favorite model helpers", () => { + test("pushRecentModel prepends, dedupes, and caps", () => { + let s: Settings = { providers: firepass.providers }; + s = pushRecentModel(s, { provider: "a", model: "m1" }); + s = pushRecentModel(s, { provider: "a", model: "m2" }); + s = pushRecentModel(s, { provider: "a", model: "m1" }); + expect(s.recentModels).toEqual([ + { provider: "a", model: "m1" }, + { provider: "a", model: "m2" }, + ]); + + for (let i = 0; i < 12; i++) { + s = pushRecentModel(s, { provider: "a", model: `x${i}` }, 10); + } + expect(s.recentModels).toHaveLength(10); + expect(s.recentModels?.[0]).toEqual({ provider: "a", model: "x11" }); + }); + + test("toggleFavoriteModel adds and removes", () => { + let s: Settings = { providers: firepass.providers }; + s = toggleFavoriteModel(s, { provider: "a", model: "m1" }); + expect(listFavoriteModels(s)).toEqual([{ provider: "a", model: "m1" }]); + s = toggleFavoriteModel(s, { provider: "a", model: "m1" }); + expect(listFavoriteModels(s)).toEqual([]); + }); + + test("listRecentModels respects max (default 5)", () => { + const recent = Array.from({ length: 8 }, (_, i) => ({ + provider: "a", + model: `m${i}`, + })); + const s: Settings = { providers: firepass.providers, recentModels: recent }; + expect(listRecentModels(s)).toHaveLength(5); + expect(listRecentModels(s, 3)).toHaveLength(3); + }); +}); diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 3355321df..2cb039d51 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -59,7 +59,7 @@ import { fetchXaiUsage, formatXaiUsage } from "../auth/xai/usage.js"; import { fetchGoUsage, formatGoUsage, - OPENCODE_GO_PROVIDER_ID, + isOpenCodeGoProviderId, } from "../../packages/opencode-go/src/index.js"; import { useLayoutGeometry } from "./hooks/use-layout-geometry.js"; import { listCommands } from "./commands/registry.js"; @@ -439,6 +439,9 @@ export function App({ registerXaiProvider, removeCodexProvider, removeXaiProvider, + recentModels, + favoriteModels, + toggleFavorite, } = providerManager; // Safe to mutate during render: the ref is only read later by the faremeter's // pricing resolver at usage-event time, never during this render pass. @@ -455,7 +458,7 @@ export function App({ // provider. Failures (auth, network, missing endpoint) clear the label so the // bar degrades cleanly rather than showing an error string. useEffect(() => { - if (provider !== OPENCODE_GO_PROVIDER_ID) { + if (!isOpenCodeGoProviderId(provider)) { setGoSubscriptionLabel(undefined); return; } @@ -1257,6 +1260,9 @@ if (workPrimary && !wasWorkPrimary.current) { setAutoLoginProfile(profile); setLoginModal(kind); }} + recentModels={recentModels} + favoriteModels={favoriteModels} + onToggleFavorite={toggleFavorite} activeApproval={gates.activeApproval} onApprove={gates.approve} onReject={gates.reject} diff --git a/src/tui/components/agent-modal.test.ts b/src/tui/components/agent-modal.test.ts index 75e08a3cb..e018984a8 100644 --- a/src/tui/components/agent-modal.test.ts +++ b/src/tui/components/agent-modal.test.ts @@ -187,6 +187,8 @@ test("trims leading and trailing spaces on text fields at save", () => { describe("seedConnectForm", () => { const goDef = FIRST_CLASS_PROVIDERS.find((p) => p.id === "opencode-go"); if (goDef === undefined) throw new Error("opencode-go missing from FIRST_CLASS_PROVIDERS"); + const zenDef = FIRST_CLASS_PROVIDERS.find((p) => p.id === "zen"); + if (zenDef === undefined) throw new Error("zen missing from FIRST_CLASS_PROVIDERS"); test("creates a new provider submission path when catalog is empty", () => { const seed = seedConnectForm(goDef, undefined); @@ -206,8 +208,8 @@ describe("seedConnectForm", () => { }); expect(seed.editingProvider).toBe("opencode-go"); expect(seed.connectDraft.opencodeGo).toBe(true); - // Operator-customized models are kept; only the key is re-entered. - expect(seed.formValues.models).toBe("kimi-k2.7-code"); + // Go re-connect seeds catalog models; only the key is re-entered. + expect(seed.formValues.baseURL).toBe(goDef.baseURL ?? ""); expect(seed.formValues.apiKey).toBe(""); const validated = validateProviderForm( @@ -222,4 +224,33 @@ describe("seedConnectForm", () => { expect(validated.submission.apiKey).toBe("sk-go-rotated-key-long"); } }); + + test("does not re-seed a wrong Zen PAYG baseURL for OpenCode Go", () => { + const seed = seedConnectForm(goDef, { + name: "opencode-go", + baseURL: "https://opencode.ai/zen/v1", + models: ["wrong-model"], + defaultModel: "wrong-model", + opencodeGo: true, + }); + expect(seed.formValues.baseURL).toBe(goDef.baseURL ?? ""); + expect(seed.formValues.baseURL).not.toBe("https://opencode.ai/zen/v1"); + // Catalog models/default, not the stale existing values. + expect(seed.formValues.models).toBe((goDef.models ?? []).join(", ")); + expect(seed.formValues.defaultModel).toBe(goDef.defaultModel ?? goDef.models?.[0] ?? ""); + expect(seed.connectDraft.opencodeGo).toBe(true); + }); + + test("zen re-connect always seeds catalog baseURL", () => { + const seed = seedConnectForm(zenDef, { + name: "zen", + baseURL: "https://opencode.ai/zen/go/v1", + models: ["claude-sonnet-4-5"], + defaultModel: "claude-sonnet-4-5", + }); + expect(seed.formValues.baseURL).toBe(zenDef.baseURL ?? ""); + expect(seed.formValues.baseURL).toBe("https://opencode.ai/zen/v1"); + // Operator-customized models still kept for zen. + expect(seed.formValues.models).toBe("claude-sonnet-4-5"); + }); }); diff --git a/src/tui/components/agent-modal.tsx b/src/tui/components/agent-modal.tsx index e4a487335..696697ce5 100644 --- a/src/tui/components/agent-modal.tsx +++ b/src/tui/components/agent-modal.tsx @@ -1,10 +1,15 @@ import { Box, Text, useInput } from "ink"; import type { ReactNode } from "react"; -import { useState, useRef } from "react"; +import { useMemo, useState, useRef } from "react"; import { color } from "../theme.js"; import { type ProviderSubmission } from "../../config/providers.js"; import { supportedEfforts, type ReasoningEffort } from "../../provider/reasoning-effort.js"; -import { PROVIDER_TIERS, type ProviderTier, type TierConfig } from "../../config/settings.js"; +import { + PROVIDER_TIERS, + type ModelRef, + type ProviderTier, + type TierConfig, +} from "../../config/settings.js"; import { formatTierChain, normalizeTierDefinition } from "../../config/inference-sources.js"; import type { AgentProfile } from "../../agent/profiles.js"; import { useTerminalSize } from "../hooks/use-terminal-size.js"; @@ -15,10 +20,15 @@ import { wrapHelpSegments, } from "./form-reflow.js"; import { - FIRST_CLASS_PROVIDERS, + connectListProviders, + firstClassPathAsProvider, type FirstClassProviderDef, + type FirstClassProviderPath, validateGoApiKey, } from "../../../packages/first-class-providers/src/index.js"; +import { isOpenCodeGoProviderId } from "../../../packages/opencode-go/src/index.js"; +import { billingProductForProvider, isGoModelOnZenPath } from "../../provider/billing-product.js"; +import { buildModelsFirstList, type ModelPick } from "../model-picker.js"; // Effort display: undefined means "no override" (field omitted); "none" is // OpenAI's explicit disable-reasoning value. Both read as "off". @@ -65,8 +75,10 @@ export type { ProviderSubmission, ProviderSubmission as ProviderFormSubmission } export type ProviderFormField = "name" | "baseURL" | "keyless" | "apiKey" | "models" | "defaultModel"; export type ProviderFormValues = Record; type Step = + | "models" | "provider" | "connect" + | "connect-path" | "model" | "effort" | "form" @@ -77,7 +89,22 @@ type Step = | "profile-form" | "profile-delete"; +function connectAuthLabel(auth: FirstClassProviderDef["auth"]): string { + switch (auth) { + case "oauth": + return "OAuth"; + case "api-key": + return "API key"; + case "chooser": + return "choose path"; + case "custom": + return "custom"; + } +} + const FORM_FIELDS: readonly ProviderFormField[] = ["name", "baseURL", "keyless", "apiKey", "models", "defaultModel"]; +/** First-class connect only collects credentials; catalog seeds the rest. */ +const AUTH_ONLY_FIELDS: readonly ProviderFormField[] = ["apiKey"]; const FIELD_LABELS: Record = { name: "Provider name", @@ -160,6 +187,12 @@ export type AgentModalProps = { unauthedProviders?: ReadonlySet; /** Called when user presses Enter on an unauthed OAuth provider to trigger login. */ onRequestLogin?: (kind: "codex" | "xai", profile: string) => void; + /** Recent provider+model pairs for the models-first list (newest first). */ + recentModels?: ModelRef[]; + /** Favorite provider+model pairs. */ + favoriteModels?: ModelRef[]; + /** Toggle favorite for the highlighted model (Alt+F). */ + onToggleFavorite?: (ref: ModelRef) => void; }; function initialFormValues(provider: AgentProvider | undefined): ProviderFormValues { @@ -175,7 +208,11 @@ function initialFormValues(provider: AgentProvider | undefined): ProviderFormVal /** Pre-seed the Connect form for an API-key first-class provider. * If the catalog already has that id, treat Connect as re-key/edit so save - * upserts instead of failing with "already exists". */ + * upserts instead of failing with "already exists". + * + * OpenCode Go always seeds catalog baseURL/models/defaultModel so a prior + * wrong Zen URL cannot stick. Zen always seeds catalog baseURL for the same + * reason. Other first-class providers keep operator-customized models/URL. */ export function seedConnectForm( def: FirstClassProviderDef, existing: AgentProvider | undefined, @@ -184,6 +221,8 @@ export function seedConnectForm( formValues: ProviderFormValues; connectDraft: { anthropic: boolean; opencodeGo: boolean }; } { + const isGo = def.opencodeGo === true || isOpenCodeGoProviderId(def.id); + const isZen = def.id === "zen"; const base: ProviderFormValues = { name: def.id, baseURL: def.baseURL ?? "", @@ -192,26 +231,44 @@ export function seedConnectForm( models: (def.models ?? []).join(", "), defaultModel: def.defaultModel ?? def.models?.[0] ?? "", }; - // Re-connect keeps operator-customized models/URL/keyless when present. - const formValues = - existing !== undefined - ? { - ...base, - baseURL: existing.baseURL.length > 0 ? existing.baseURL : base.baseURL, - keyless: existing.keyless === true ? "yes" : "no", - models: existing.models.length > 0 ? existing.models.join(", ") : base.models, - defaultModel: - existing.defaultModel ?? - existing.models[0] ?? - base.defaultModel, - } - : base; + + let formValues: ProviderFormValues = base; + if (existing !== undefined) { + if (isGo) { + // Pin catalog baseURL/models/defaultModel; only keyless may carry over. + // API key is always re-entered on Connect. + formValues = { + ...base, + keyless: existing.keyless === true ? "yes" : "no", + }; + } else if (isZen) { + // Pin catalog baseURL; keep operator-customized models when present. + formValues = { + ...base, + baseURL: base.baseURL, + keyless: existing.keyless === true ? "yes" : "no", + models: existing.models.length > 0 ? existing.models.join(", ") : base.models, + defaultModel: + existing.defaultModel ?? existing.models[0] ?? base.defaultModel, + }; + } else { + formValues = { + ...base, + baseURL: existing.baseURL.length > 0 ? existing.baseURL : base.baseURL, + keyless: existing.keyless === true ? "yes" : "no", + models: existing.models.length > 0 ? existing.models.join(", ") : base.models, + defaultModel: + existing.defaultModel ?? existing.models[0] ?? base.defaultModel, + }; + } + } + return { editingProvider: existing?.name, formValues, connectDraft: { anthropic: def.anthropic === true || existing?.anthropic === true, - opencodeGo: def.id === "opencode-go" || existing?.opencodeGo === true, + opencodeGo: isGo || existing?.opencodeGo === true, }, }; } @@ -317,6 +374,9 @@ export function AgentModal({ onRequestUsage, unauthedProviders, onRequestLogin, + recentModels = [], + favoriteModels = [], + onToggleFavorite, }: AgentModalProps): ReactNode { const { columns } = useTerminalSize(); const stackFields = columns < STACK_FORM_COLUMNS; @@ -331,13 +391,17 @@ export function AgentModal({ 0, providers.findIndex((p) => p.name === activeProvider), ); - const [step, setStep] = useState("provider"); + const [step, setStep] = useState("models"); + const [pickIndex, setPickIndex] = useState(0); const [providerIndex, setProviderIndex] = useState(initialProvider); const [modelIndex, setModelIndex] = useState(0); const [pendingProvider, setPendingProvider] = useState(undefined); const [pendingModel, setPendingModel] = useState(undefined); const [effortIndex, setEffortIndex] = useState(0); const [formIndex, setFormIndex] = useState(0); + const [formAuthOnly, setFormAuthOnly] = useState(false); + // Where Esc returns from connect/profiles when entered from models-first vs advanced. + const [navReturnStep, setNavReturnStep] = useState<"models" | "provider">("models"); const [formValues, setFormValues] = useState(() => initialFormValues(undefined)); const [editingProvider, setEditingProvider] = useState(undefined); const [formError, setFormError] = useState(null); @@ -351,11 +415,46 @@ export function AgentModal({ const [editingProfileId, setEditingProfileId] = useState(undefined); const [profileFormError, setProfileFormError] = useState(null); const [connectIndex, setConnectIndex] = useState(0); + const [connectPathIndex, setConnectPathIndex] = useState(0); + const [chooserDef, setChooserDef] = useState(null); const connectDraft = useRef<{ anthropic: boolean; opencodeGo: boolean } | null>(null); const selectedProvider = providers[providerIndex]; const models = selectedProvider?.models ?? []; + const modelPicks = useMemo( + () => + buildModelsFirstList({ + providers: providers.map((p) => { + const account = + p.codexProfile !== undefined + ? p.codexProfile + : p.xaiProfile !== undefined + ? p.xaiProfile + : undefined; + return { + name: p.name, + models: p.models, + baseURL: p.baseURL, + ...(p.defaultModel !== undefined ? { defaultModel: p.defaultModel } : {}), + ...(p.codexProfile !== undefined ? { codexProfile: p.codexProfile } : {}), + ...(p.xaiProfile !== undefined ? { xaiProfile: p.xaiProfile } : {}), + ...(p.opencodeGo === true ? { opencodeGo: true } : {}), + ...(account !== undefined ? { account } : {}), + }; + }), + recent: recentModels, + favorites: favoriteModels, + isGoModelOnZenPath: (model, provider) => + isGoModelOnZenPath(model, { + name: provider.name, + ...(provider.baseURL !== undefined ? { baseURL: provider.baseURL } : {}), + ...(provider.opencodeGo === true ? { opencodeGo: true } : {}), + }), + }), + [providers, recentModels, favoriteModels], + ); + const requestUsageForIndex = (idx: number): void => { if (onRequestUsage === undefined) return; const p = providers[idx]; @@ -369,7 +468,8 @@ export function AgentModal({ providers.find((p) => p.name === name)?.codexProfile !== undefined; const efforts: ReasoningEffort[] = pendingModel !== undefined ? supportedEfforts(pendingModel, undefined, isCodexProvider(pendingProvider)) : []; - const currentField = FORM_FIELDS[formIndex] ?? "name"; + const activeFormFields = formAuthOnly ? AUTH_ONLY_FIELDS : FORM_FIELDS; + const currentField = activeFormFields[formIndex] ?? activeFormFields[0] ?? "name"; const enterModelStep = (): void => { const provider = providers[providerIndex]; @@ -393,6 +493,7 @@ export function AgentModal({ const enterAddForm = (): void => { connectDraft.current = null; + setFormAuthOnly(false); setEditingProvider(undefined); setFormValues(initialFormValues(undefined)); setFormIndex(0); @@ -401,28 +502,57 @@ export function AgentModal({ }; const enterConnectStep = (): void => { + setNavReturnStep(step === "provider" ? "provider" : "models"); setConnectIndex(0); + setChooserDef(null); + setConnectPathIndex(0); setStep("connect"); }; - const enterConnectForm = (def: FirstClassProviderDef): void => { - if (def.auth === "oauth") { - if (def.oauth !== undefined && onRequestLogin !== undefined) { - onRequestLogin(def.oauth, "default"); - } - return; - } -// Upsert: re-Connect on an existing first-class provider re-keys in place. + const enterApiKeyConnectForm = (def: FirstClassProviderDef): void => { + // Upsert: re-Connect on an existing first-class provider re-keys in place. const existing = providers.find((p) => p.name === def.id); const seed = seedConnectForm(def, existing); setEditingProvider(seed.editingProvider); setFormValues(seed.formValues); connectDraft.current = seed.connectDraft; - setFormIndex(3); // apiKey field + setFormAuthOnly(true); + setFormIndex(0); // apiKey is the only field in auth-only mode setFormError(null); setStep("form"); }; + const enterConnectPath = (path: FirstClassProviderPath, parent: FirstClassProviderDef): void => { + if (path.auth === "oauth") { + if (path.oauth !== undefined && onRequestLogin !== undefined) { + onRequestLogin(path.oauth, "default"); + } + return; + } + const seeded = firstClassPathAsProvider(parent, path.id); + if (seeded !== undefined) enterApiKeyConnectForm(seeded); + }; + + const enterConnectForm = (def: FirstClassProviderDef): void => { + if (def.auth === "oauth") { + if (def.oauth !== undefined && onRequestLogin !== undefined) { + onRequestLogin(def.oauth, "default"); + } + return; + } + if (def.auth === "custom") { + enterAddForm(); + return; + } + if (def.auth === "chooser") { + setChooserDef(def); + setConnectPathIndex(0); + setStep("connect-path"); + return; + } + enterApiKeyConnectForm(def); + }; + const enterEditForm = (): void => { const provider = providers[providerIndex]; if (provider === undefined) return; @@ -431,6 +561,7 @@ export function AgentModal({ anthropic: provider.anthropic === true, opencodeGo: provider.opencodeGo === true, }; + setFormAuthOnly(false); setEditingProvider(provider.name); setFormValues(initialFormValues(provider)); setFormIndex(0); @@ -463,7 +594,7 @@ export function AgentModal({ if (pendingTierAssign !== null) { setStep("tiers"); } else { - setStep("provider"); + setStep("models"); } }; @@ -529,6 +660,63 @@ export function AgentModal({ }; useInput((input, key) => { + if (step === "models") { + if (key.upArrow) { + setPickIndex((i) => (modelPicks.length === 0 ? 0 : i > 0 ? i - 1 : modelPicks.length - 1)); + return; + } + if (key.downArrow) { + setPickIndex((i) => (modelPicks.length === 0 ? 0 : i < modelPicks.length - 1 ? i + 1 : 0)); + return; + } + if (key.return) { + const pick = modelPicks[pickIndex]; + if (pick === undefined) return; + const options = supportedEfforts(pick.model, undefined, isCodexProvider(pick.provider)); + if (options.length === 0) { + onApply(pick.provider, pick.model, undefined); + onClose(); + return; + } + enterEffortStep(pick.provider, pick.model); + return; + } + // Alt+A connect; some terminals send meta+a + if ((key.meta && (input === "a" || input === "A")) || (key.ctrl && input === "a")) { + enterConnectStep(); + return; + } + if (input === "c") { + enterConnectStep(); + return; + } + // Alt+F favorite + if (key.meta && (input === "f" || input === "F")) { + const pick = modelPicks[pickIndex]; + if (pick !== undefined) { + onToggleFavorite?.({ provider: pick.provider, model: pick.model }); + } + return; + } + if (input === "a") { + // Advanced: provider drill-down for edit/delete/tiers + setStep("provider"); + return; + } + if (input === "t") { + setStep("tiers"); + return; + } + if (input === "p") { + setNavReturnStep("models"); + setProfileIndex(0); + setStep("profiles"); + return; + } + if (key.escape) onClose(); + return; + } + if (step === "provider") { if (key.upArrow) { setProviderIndex((i) => { @@ -582,30 +770,64 @@ export function AgentModal({ return; } if (input === "p") { + setNavReturnStep("provider"); setProfileIndex(0); setStep("profiles"); return; } - if (key.escape) onClose(); + if (key.escape) { + setStep("models"); + return; + } return; } if (step === "connect") { if (key.upArrow) { - setConnectIndex((i) => (i > 0 ? i - 1 : FIRST_CLASS_PROVIDERS.length - 1)); + setConnectIndex((i) => (i > 0 ? i - 1 : connectListProviders().length - 1)); return; } if (key.downArrow) { - setConnectIndex((i) => (i < FIRST_CLASS_PROVIDERS.length - 1 ? i + 1 : 0)); + setConnectIndex((i) => (i < connectListProviders().length - 1 ? i + 1 : 0)); return; } if (key.return) { - const def = FIRST_CLASS_PROVIDERS[connectIndex]; + const def = connectListProviders()[connectIndex]; if (def !== undefined) enterConnectForm(def); return; } if (key.escape) { - setStep("provider"); + setStep(navReturnStep); + return; + } + return; + } + + if (step === "connect-path") { + const paths = chooserDef?.paths ?? []; + if (paths.length === 0) { + if (key.escape) { + setChooserDef(null); + setStep("connect"); + } + return; + } + if (key.upArrow) { + setConnectPathIndex((i) => (i > 0 ? i - 1 : paths.length - 1)); + return; + } + if (key.downArrow) { + setConnectPathIndex((i) => (i < paths.length - 1 ? i + 1 : 0)); + return; + } + if (key.return) { + const path = paths[connectPathIndex]; + if (path !== undefined && chooserDef !== null) enterConnectPath(path, chooserDef); + return; + } + if (key.escape) { + setChooserDef(null); + setStep("connect"); return; } return; @@ -633,7 +855,7 @@ export function AgentModal({ return; } if (key.escape) { - setStep("provider"); + setStep(navReturnStep); return; } return; @@ -870,26 +1092,26 @@ export function AgentModal({ } if (key.upArrow) { - setFormIndex((i) => (i > 0 ? i - 1 : FORM_FIELDS.length - 1)); + setFormIndex((i) => (i > 0 ? i - 1 : activeFormFields.length - 1)); return; } if (key.downArrow || key.tab) { setFormIndex((i) => { // Skip the apiKey field when keyless is enabled — there's nothing to enter. if (currentField === "keyless" && formValues.keyless === "yes") { - const next = i + 1 >= FORM_FIELDS.length ? 0 : i + 2; - return next >= FORM_FIELDS.length ? 0 : next; + const next = i + 1 >= activeFormFields.length ? 0 : i + 2; + return next >= activeFormFields.length ? 0 : next; } - return i < FORM_FIELDS.length - 1 ? i + 1 : 0; + return i < activeFormFields.length - 1 ? i + 1 : 0; }); return; } if (key.return) { - if (formIndex < FORM_FIELDS.length - 1) { + if (formIndex < activeFormFields.length - 1) { setFormIndex((i) => { // Skip apiKey when keyless. if (currentField === "keyless" && formValues.keyless === "yes") { - return Math.min(i + 2, FORM_FIELDS.length - 1); + return Math.min(i + 2, activeFormFields.length - 1); } return i + 1; }); @@ -899,7 +1121,8 @@ export function AgentModal({ return; } if (key.escape) { - setStep("provider"); + setFormAuthOnly(false); + setStep("models"); setFormError(null); setPendingTierAssign(null); return; @@ -924,10 +1147,14 @@ export function AgentModal({ const helpText = ((): string | null => { switch (step) { + case "models": + return "Up/Down · Enter use · Alt+A connect · Alt+F favorite · a advanced · t tiers · p profiles · Esc close"; case "provider": - return "Up/Down navigate · Enter models · c/Ctrl+A connect · a advanced · e edit · x remove · t tiers · p profiles · Esc close"; + return "Up/Down navigate · Enter models · c/Alt+A connect · a add custom · e edit · x remove · t tiers · p profiles · Esc back"; case "connect": return "Up/Down navigate · Enter connect · Esc back"; + case "connect-path": + return "Up/Down navigate · Enter choose path · Esc back"; case "tiers": return "Up/Down navigate · Enter add · e edit chain · m mode · c clear · Esc back"; case "tier-chain": @@ -943,7 +1170,9 @@ const helpText = ((): string | null => { case "effort": return "Up/Down navigate · Enter use now · d set as default · Esc back"; case "form": - return "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel"; + return formAuthOnly + ? "Enter save · Esc cancel" + : "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel"; case "delete": return "y remove · n cancel · Esc back"; } @@ -975,9 +1204,84 @@ const helpText = ((): string | null => { )} - Provider / Model + + {step === "models" ? "Models" : "Provider / Model"} + + {step === "models" && ( + + {modelPicks.length === 0 ? ( + + No models yet — press Alt+A (or c) to connect a provider + + ) : ( + (() => { + let lastSection: ModelPick["section"] | null = null; + let lastProvider: string | null = null; + return modelPicks.map((pick, i) => { + const isCursor = i === pickIndex; + const isActive = + pick.provider === activeProvider && pick.model === activeModel; + const headers: ReactNode[] = []; + if (pick.section !== lastSection) { + lastSection = pick.section; + lastProvider = null; + const title = + pick.section === "recent" + ? "Recent" + : pick.section === "favorites" + ? "Favorites" + : "Providers"; + headers.push( + + {title} + , + ); + } + if ( + pick.section === "provider" && + pick.provider !== lastProvider + ) { + lastProvider = pick.provider; + headers.push( + + {pick.providerLabel ?? pick.provider} + , + ); + } + const meta = [ + pick.section !== "provider" ? (pick.providerLabel ?? pick.provider) : null, + pick.account, + pick.warning, + ] + .filter((x): x is string => x !== undefined && x !== null && x.length > 0) + .join(" · "); + return ( + + {headers} + + + {isCursor ? ">" : " "} + + + {isActive ? "* " : " "} + {pick.model} + + {meta.length > 0 && ( + + {meta} + + )} + + + ); + }); + })() + )} + + )} + {step === "provider" && ( {providers.map((p, i) => { @@ -985,6 +1289,7 @@ const helpText = ((): string | null => { const isCursor = i === providerIndex; const isOAuth = p.codexProfile !== undefined || p.xaiProfile !== undefined; const isUnauthed = isOAuth && unauthedProviders?.has(p.name) === true; + const productHint = billingProductForProvider(p); return ( @@ -997,6 +1302,9 @@ const helpText = ((): string | null => { ({p.models.length} model{p.models.length === 1 ? "" : "s"}) + {productHint !== undefined && ( + [{productHint}] + )} {isUnauthed && ( ! not authenticated )} @@ -1014,7 +1322,7 @@ const helpText = ((): string | null => { {step === "connect" && ( Connect a first-class provider - {FIRST_CLASS_PROVIDERS.map((def, i) => { + {connectListProviders().map((def, i) => { const isCursor = i === connectIndex; return ( @@ -1025,9 +1333,7 @@ const helpText = ((): string | null => { {def.label} - - ({def.auth === "oauth" ? "OAuth" : "API key"}) - + ({connectAuthLabel(def.auth)}) {isCursor && def.authHint !== undefined && ( @@ -1041,6 +1347,36 @@ const helpText = ((): string | null => { )} + {step === "connect-path" && chooserDef !== null && ( + + Connect {chooserDef.label} + {(chooserDef.paths ?? []).map((path, i) => { + const isCursor = i === connectPathIndex; + return ( + + + + {isCursor ? ">" : " "} + + + {path.label} + + + ({path.auth === "oauth" ? "OAuth" : "API key"}) + + + {isCursor && path.authHint !== undefined && ( + + {" "} + {path.authHint} + + )} + + ); + })} + + )} + {step === "tier-chain" && tierChainFocus !== null && ( @@ -1174,9 +1510,18 @@ const helpText = ((): string | null => { {step === "form" && ( - {editingProvider === undefined ? "Add provider" : `Edit provider ${editingProvider}`} + {formAuthOnly + ? editingProvider === undefined + ? "Connect provider" + : `Connect ${editingProvider}` + : editingProvider === undefined + ? "Add provider" + : `Edit provider ${editingProvider}`} - {FORM_FIELDS.map((field, i) => { + {formAuthOnly && formValues.baseURL.length > 0 && ( + {formValues.baseURL} + )} + {activeFormFields.map((field, i) => { const isCursor = i === formIndex; const value = formValues[field]; const isKeyless = formValues.keyless === "yes"; diff --git a/src/tui/components/modal-stack.tsx b/src/tui/components/modal-stack.tsx index 1d7347a2f..c7084df37 100644 --- a/src/tui/components/modal-stack.tsx +++ b/src/tui/components/modal-stack.tsx @@ -71,6 +71,9 @@ export type ModalStackProps = { onRequestAgentUsage?: (kind: "codex" | "xai", profile: string, baseURL?: string) => void; unauthedProviders?: ReadonlySet; onRequestAgentLogin?: (kind: "codex" | "xai", profile: string) => void; + recentModels?: import("../../config/settings.js").ModelRef[]; + favoriteModels?: import("../../config/settings.js").ModelRef[]; + onToggleFavorite?: (ref: import("../../config/settings.js").ModelRef) => void; activeApproval: ActiveApproval | null; onApprove: (id: number) => void; @@ -115,6 +118,9 @@ export function ModalStack({ onRequestAgentUsage, unauthedProviders, onRequestAgentLogin, + recentModels, + favoriteModels, + onToggleFavorite, activeApproval, onApprove, onReject, @@ -154,6 +160,9 @@ export function ModalStack({ {...(onRequestAgentUsage !== undefined ? { onRequestUsage: onRequestAgentUsage } : {})} {...(unauthedProviders !== undefined ? { unauthedProviders } : {})} {...(onRequestAgentLogin !== undefined ? { onRequestLogin: onRequestAgentLogin } : {})} + {...(recentModels !== undefined ? { recentModels } : {})} + {...(favoriteModels !== undefined ? { favoriteModels } : {})} + {...(onToggleFavorite !== undefined ? { onToggleFavorite } : {})} /> )} {activeApproval?.kind === "plan" && ( diff --git a/src/tui/hooks/use-provider-manager.ts b/src/tui/hooks/use-provider-manager.ts index 26acbcbef..2f9d45010 100644 --- a/src/tui/hooks/use-provider-manager.ts +++ b/src/tui/hooks/use-provider-manager.ts @@ -1,13 +1,16 @@ import type { Agent } from "@intx/agent"; import type { InferenceSource } from "@intx/types/runtime"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { providerCatalogToSettings, runtimeSettingsWithCatalog, type ProviderCatalogEntry } from "../../config/index.js"; import { loadSettings, localSettingsPath, + pushRecentModel, saveGlobalSettings, saveLocalSettings, + toggleFavoriteModel, type LocalSettings, + type ModelRef, type ProviderTier, type Settings, type TierConfig, @@ -63,8 +66,12 @@ export type ProviderManagerController = { providerCatalog: ProviderCatalogEntry[]; globalDefaultProvider: string | undefined; tiers: Partial>; + recentModels: ModelRef[]; + favoriteModels: ModelRef[]; applySelection: (providerName: string, nextModel: string, nextEffort: ReasoningEffort | undefined) => void; persistSelection: (providerName: string, nextModel: string, nextEffort: ReasoningEffort | undefined) => void; + recordRecentModel: (ref: ModelRef) => void; + toggleFavorite: (ref: ModelRef) => void; upsertProvider: (submission: ProviderSubmission) => { ok: true } | { ok: false; error: string }; deleteProvider: (providerName: string) => void; saveTierAssignment: ( @@ -151,9 +158,11 @@ function persistGlobalSettings( onMessage: (msg: string) => void, successMessage: string, failPrefix: string, -): void { - void saveGlobalSettings(globalSettingsPath, settings).then( - () => onMessage(successMessage), +): Promise { + return saveGlobalSettings(globalSettingsPath, settings).then( + () => { + if (successMessage.length > 0) onMessage(successMessage); + }, (err: unknown) => { onMessage(`${failPrefix}: ${err instanceof Error ? err.message : String(err)}`); }, @@ -178,7 +187,7 @@ async function persistWithMergeBase(args: { const base = await loadMergeBase(args.globalSettingsPath, args.initialSettings); const settings = args.buildSettings(base); args.onBeforeSave?.(); - persistGlobalSettings( + await persistGlobalSettings( args.globalSettingsPath, settings, args.onMessage, @@ -212,6 +221,17 @@ export function useProviderManager({ const [providerCatalog, setProviderCatalog] = useState(initialCatalog); const [globalDefaultProvider, setGlobalDefaultProvider] = useState(initialGlobalDefaultProvider); const [tiers, setTiers] = useState>>(initialTiers ?? {}); + const [recentModels, setRecentModels] = useState( + () => initialSettings?.recentModels ?? [], + ); + const [favoriteModels, setFavoriteModels] = useState( + () => initialSettings?.favoriteModels ?? [], + ); + // Absolute in-memory truth for model prefs — writes never re-derive from disk. + const recentModelsRef = useRef(initialSettings?.recentModels ?? []); + const favoriteModelsRef = useRef(initialSettings?.favoriteModels ?? []); + // Serialize concurrent recent/favorite saves so they cannot clobber each other. + const modelPrefsWriteChain = useRef(Promise.resolve()); const publishRuntimeResolution = ( catalog: readonly ProviderCatalogEntry[], @@ -293,9 +313,55 @@ export function useProviderManager({ ): void => { if (applyCatalogSelection(providerCatalog, providerName, nextModel, nextEffort)) { onMessage(`Now using ${providerName} · ${nextModel}`); + // Keep recent list in sync even when the modal does not call recordRecentModel. + recordRecentModel({ provider: providerName, model: nextModel }); } }; + const enqueueModelPrefsWrite = ( + failPrefix: string, + apply: (base: Settings) => Settings, + ): void => { + modelPrefsWriteChain.current = modelPrefsWriteChain.current + .then(() => + persistWithMergeBase({ + globalSettingsPath, + initialSettings, + buildSettings: (base) => apply(base ?? { providers: {} }), + onMessage, + successMessage: "", + failPrefix, + }), + ) + .catch(() => { + // Errors already reported via onMessage inside persistWithMergeBase. + }); + }; + + const recordRecentModel = (ref: ModelRef): void => { + const next = + pushRecentModel({ providers: {}, recentModels: recentModelsRef.current }, ref).recentModels ?? + []; + recentModelsRef.current = next; + setRecentModels(next); + enqueueModelPrefsWrite("Failed to save recent models", (base) => ({ + ...base, + recentModels: recentModelsRef.current, + })); + }; + + const toggleFavorite = (ref: ModelRef): void => { + const next = + toggleFavoriteModel({ providers: {}, favoriteModels: favoriteModelsRef.current }, ref) + .favoriteModels ?? []; + favoriteModelsRef.current = next; + setFavoriteModels(next); + enqueueModelPrefsWrite("Failed to save favorite models", (base) => ({ + ...base, + favoriteModels: favoriteModelsRef.current, + })); + }; + const persistLocalSelection = (providerName: string, nextModel: string): void => { void saveLocalSettings(localSettingsPath(cwd), localSelection(providerName, nextModel, reasoningEffort)).catch( (err: unknown) => { @@ -506,8 +572,12 @@ export function useProviderManager({ providerCatalog, globalDefaultProvider, tiers, + recentModels, + favoriteModels, applySelection, persistSelection, + recordRecentModel, + toggleFavorite, upsertProvider, deleteProvider, saveTierAssignment, diff --git a/src/tui/model-picker.test.ts b/src/tui/model-picker.test.ts new file mode 100644 index 000000000..bb46f74ed --- /dev/null +++ b/src/tui/model-picker.test.ts @@ -0,0 +1,136 @@ +import { describe, test, expect } from "bun:test"; + +import { buildModelsFirstList, type ModelPickerProvider } from "./model-picker.js"; + +const xai: ModelPickerProvider = { + name: "xai", + label: "xAI", + models: ["grok-4", "grok-3"], + account: "thegreataxios", +}; + +const zen: ModelPickerProvider = { + name: "zen", + label: "OpenCode Zen", + models: ["kimi-k2.7-code", "claude-sonnet-4-5"], + baseURL: "https://opencode.ai/zen/v1", +}; + +const go: ModelPickerProvider = { + name: "opencode-go", + label: "OpenCode Go", + models: ["kimi-k2.7-code", "glm-5"], + opencodeGo: true, +}; + +describe("buildModelsFirstList", () => { + test("orders Recent, then Favorites, then provider buckets", () => { + const list = buildModelsFirstList({ + providers: [xai, zen], + recent: [{ provider: "zen", model: "claude-sonnet-4-5" }], + favorites: [{ provider: "xai", model: "grok-4" }], + }); + + expect(list.map((r) => `${r.section}:${r.provider}/${r.model}`)).toEqual([ + "recent:zen/claude-sonnet-4-5", + "favorites:xai/grok-4", + "provider:xai/grok-3", + "provider:zen/kimi-k2.7-code", + ]); + }); + + test("drops recent entries whose model no longer exists on the provider", () => { + const list = buildModelsFirstList({ + providers: [xai], + recent: [ + { provider: "xai", model: "gone-model" }, + { provider: "xai", model: "grok-4" }, + ], + favorites: [], + }); + + expect(list.filter((r) => r.section === "recent")).toEqual([ + { + provider: "xai", + model: "grok-4", + section: "recent", + providerLabel: "xAI", + account: "thegreataxios", + }, + ]); + }); + + test("skips favorites already covered by recent", () => { + const list = buildModelsFirstList({ + providers: [xai], + recent: [{ provider: "xai", model: "grok-4" }], + favorites: [{ provider: "xai", model: "grok-4" }], + }); + + expect(list.filter((r) => r.provider === "xai" && r.model === "grok-4")).toEqual([ + { + provider: "xai", + model: "grok-4", + section: "recent", + providerLabel: "xAI", + account: "thegreataxios", + }, + ]); + }); + + test("skips provider-bucket models already shown in recent or favorites", () => { + const list = buildModelsFirstList({ + providers: [xai], + recent: [{ provider: "xai", model: "grok-4" }], + favorites: [{ provider: "xai", model: "grok-3" }], + }); + + expect(list.filter((r) => r.section === "provider")).toEqual([]); + expect(list.map((r) => r.section)).toEqual(["recent", "favorites"]); + }); + + test("caps recent at recentMax (default 5)", () => { + const many = Array.from({ length: 8 }, (_, i) => ({ + provider: "xai", + model: `m${i}`, + })); + const provider: ModelPickerProvider = { + name: "xai", + models: many.map((r) => r.model), + }; + const list = buildModelsFirstList({ + providers: [provider], + recent: many, + favorites: [], + }); + + expect(list.filter((r) => r.section === "recent")).toHaveLength(5); + }); + + test("attaches zen-path warning when predicate returns true", () => { + const list = buildModelsFirstList({ + providers: [zen, go], + recent: [{ provider: "zen", model: "kimi-k2.7-code" }], + favorites: [], + isGoModelOnZenPath: (model, provider) => + model === "kimi-k2.7-code" && provider.name === "zen", + }); + + const recent = list.find((r) => r.section === "recent"); + expect(recent?.warning).toMatch(/Go model on Zen path/); + + const goRow = list.find( + (r) => r.section === "provider" && r.provider === "opencode-go" && r.model === "kimi-k2.7-code", + ); + expect(goRow?.warning).toBeUndefined(); + }); + + test("uses provider name as label when label is unset", () => { + const list = buildModelsFirstList({ + providers: [{ name: "custom", models: ["m1"] }], + recent: [], + favorites: [], + }); + expect(list[0]?.providerLabel).toBe("custom"); + }); +}); diff --git a/src/tui/model-picker.ts b/src/tui/model-picker.ts new file mode 100644 index 000000000..7a432af33 --- /dev/null +++ b/src/tui/model-picker.ts @@ -0,0 +1,115 @@ +import type { ModelRef } from "../config/settings.js"; +import { DEFAULT_RECENT_MODELS_SHOWN } from "../config/settings.js"; + +export type ModelPickSection = "recent" | "favorites" | "provider"; + +export type ModelPick = { + provider: string; + model: string; + section: ModelPickSection; + providerLabel?: string; + account?: string; + warning?: string; +}; + +export type ModelPickerProvider = { + name: string; + models: string[]; + defaultModel?: string; + codexProfile?: string; + xaiProfile?: string; + opencodeGo?: boolean; + baseURL?: string; + /** Display name for the provider bucket / row (falls back to `name`). */ + label?: string; + /** Account or profile label when known (e.g. OAuth login name). */ + account?: string; +}; + +export type BuildModelsFirstListArgs = { + providers: ModelPickerProvider[]; + recent: ModelRef[]; + favorites: ModelRef[]; + /** Max recent rows (default 5). */ + recentMax?: number; + /** + * When true for a model on a provider, attach a cross-product billing warning + * (Go model configured on a Zen-billed path). + */ + isGoModelOnZenPath?: (model: string, provider: ModelPickerProvider) => boolean; +}; + +const GO_ON_ZEN_WARNING = "Go model on Zen path — billed as Zen credits"; + +function refKey(provider: string, model: string): string { + return `${provider}\0${model}`; +} + +function modelExistsOnProvider( + providers: ModelPickerProvider[], + provider: string, + model: string, +): ModelPickerProvider | undefined { + const p = providers.find((x) => x.name === provider); + if (p === undefined) return undefined; + return p.models.includes(model) ? p : undefined; +} + +function pickRow( + provider: ModelPickerProvider, + model: string, + section: ModelPickSection, + isGoModelOnZenPath?: (model: string, provider: ModelPickerProvider) => boolean, +): ModelPick { + const warning = + isGoModelOnZenPath?.(model, provider) === true ? GO_ON_ZEN_WARNING : undefined; + return { + provider: provider.name, + model, + section, + providerLabel: provider.label ?? provider.name, + ...(provider.account !== undefined ? { account: provider.account } : {}), + ...(warning !== undefined ? { warning } : {}), + }; +} + +/** + * Models-first picker rows: Recent (still-valid, capped) → Favorites (not already + * in Recent) → each connected provider's models (skipping pairs already listed). + * Identity is provider+model. + */ +export function buildModelsFirstList(args: BuildModelsFirstListArgs): ModelPick[] { + const recentMax = args.recentMax ?? DEFAULT_RECENT_MODELS_SHOWN; + const seen = new Set(); + const out: ModelPick[] = []; + + for (const ref of args.recent) { + if (out.filter((r) => r.section === "recent").length >= recentMax) break; + const provider = modelExistsOnProvider(args.providers, ref.provider, ref.model); + if (provider === undefined) continue; + const key = refKey(ref.provider, ref.model); + if (seen.has(key)) continue; + seen.add(key); + out.push(pickRow(provider, ref.model, "recent", args.isGoModelOnZenPath)); + } + + for (const ref of args.favorites) { + const provider = modelExistsOnProvider(args.providers, ref.provider, ref.model); + if (provider === undefined) continue; + const key = refKey(ref.provider, ref.model); + if (seen.has(key)) continue; + seen.add(key); + out.push(pickRow(provider, ref.model, "favorites", args.isGoModelOnZenPath)); + } + + for (const provider of args.providers) { + for (const model of provider.models) { + const key = refKey(provider.name, model); + if (seen.has(key)) continue; + seen.add(key); + out.push(pickRow(provider, model, "provider", args.isGoModelOnZenPath)); + } + } + + return out; +} diff --git a/src/tui/runner.tsx b/src/tui/runner.tsx index b39a0bb94..3555fdeca 100644 --- a/src/tui/runner.tsx +++ b/src/tui/runner.tsx @@ -17,7 +17,6 @@ import { buildCodexSource, buildOpenAISource, buildXaiSource, type Config } from import { globalSettingsPath, loadLocalSettings, - loadLocalSettingsWriteBase, loadGlobalSettingsWriteBase, loadSettings, localSettingsPath, diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 13006da08..1a19ea2c3 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -139,6 +139,8 @@ test("loadSettings cannot silently drop a known optional key", async () => { tools: { timeoutMs: 2000, waitForApproval: false }, telemetry: { enabled: false, installationId: "id", noticeShown: true }, otel: { endpoint: "http://localhost:4318", serviceName: "corbits-test" }, + recentModels: [{ provider: "p", model: "m" }], + favoriteModels: [{ provider: "p", model: "m" }], }; await writeFile(globalPath, JSON.stringify(fixture)); const loaded = await loadSettings(globalPath);