diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dff6f2ef0..981f4b9ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -286,10 +286,10 @@ Ink 7 + React 19, full-screen via the alternate-screen buffer. - **In-flight indicator** — Spinner uses the `"live"` semantic color (calm blue) rather than the brand orange, reducing visual noise during long runs. - 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`, `login-provider-picker`, `codex-login-modal`, `mcp-auth-prompt`, `onboarding-animation`, `in-flight-indicator`. +- 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`, `/login`, `/codex`, `/xai`, `/grok`, `/clear`, `/new`, `/mcp`). -- `/agent` 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. "Set as default" persists the selection (selection-only, no credentials) to the per-repo `.corbits/settings.json` via `saveLocalSettings`. The section model leaves room for system-prompt/profile sections without new slash commands, and for the add-provider/onboarding step. +- 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`. #### Event log rendering diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 7acd48fe1..2074924a0 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -127,13 +127,13 @@ src/ commands/ registry.ts Extensible slash-command registry built-in.ts /help, /model, /settings, /permissions, /plugins, - /login, /codex, /xai, /grok, /clear, /new, /mcp + /clear, /new, /mcp (connect providers from /model) components/ header.tsx, event-log.tsx, chat-input.tsx, status-bar.tsx, task-view.tsx, at-mention/, operator-modal.tsx, permission-modal.tsx, permissions-manager.tsx, plugins-manager.tsx, settings-overlay.tsx, agent-modal.tsx, exit-confirm.tsx, help-overlay.tsx, hook-panel.tsx, - login-provider-picker.tsx, codex-login-modal.tsx, mcp-auth-prompt.tsx, + codex-login-modal.tsx, mcp-auth-prompt.tsx, onboarding-animation.tsx, in-flight-indicator.tsx, modal-stack.tsx hooks/ use-gates.ts, use-keymap.ts, use-layout-geometry.ts, use-mcp-status.ts, @@ -271,6 +271,10 @@ 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. + +**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. + ### CLI Verbs and Flags | Verb / Flag | Default | Description | diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 308540374..8ef2093e3 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -24,7 +24,7 @@ Existing coding agents stall. They get stuck in thinking loops, read files endle 6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why. 7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI) or via stdin when the product agent runs under `corbits exec`. 8. **Mid-run steering** — Two modes while the agent is running: **Enter** interrupts the current run immediately and starts a new turn with your message; **Alt+Enter** queues the message for delivery at the next turn boundary without stopping the current run. A badge on the input shows the count of queued messages. A hint line in the input area makes both options discoverable. -9. **Session mode (TUI)** — **Single-agent** keeps one primary loop on the wire (no `task` / `search_agents` tools). **Orchestrator** is for chatting with the top agent while it delegates via `task` and manages parallel sub-agents. On first launch, Corbits Code asks once; **Enter** saves to global settings (highlight defaults to single-agent; **Ctrl+C** skips save, runs orchestrator this session only, and the prompt returns on later launches until you save). **Settings → Session** can change global or per-repo defaults, but mode takes effect on the **next** session start (unlike `/agent` provider switches). The `exec` path uses the same `sessionMode` resolution as the TUI (global + per-repo settings; defaults to orchestrator when unset). +9. **Session mode (TUI)** — **Single-agent** keeps one primary loop on the wire (no `task` / `search_agents` tools). **Orchestrator** is for chatting with the top agent while it delegates via `task` and manages parallel sub-agents. On first launch, Corbits Code asks once; **Enter** saves to global settings (highlight defaults to single-agent; **Ctrl+C** skips save, runs orchestrator this session only, and the prompt returns on later launches until you save). **Settings → Session** can change global or per-repo defaults, but mode takes effect on the **next** session start (unlike `/model` provider switches). The `exec` path uses the same `sessionMode` resolution as the TUI (global + per-repo settings; defaults to orchestrator when unset). ## User Experience @@ -72,14 +72,13 @@ Continues from the last saved state in the working directory. ## Slash Commands (TUI) -The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (open the agent configuration surface), `/settings`, `/permissions`, `/plugins`, `/login`, `/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. - -`/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. +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. +`/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. - -`/agent` opens a dedicated full-screen modal — the single place agent configuration lives. Today it holds a Provider / Model section: it lists configured providers, marks the active one, and lets you switch provider and model. 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. +`/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. ## Lifecycle Hooks diff --git a/package.json b/package.json index 9d7e7a564..3f560aee1 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ }, "workspaces": [ ".", - "./vendor/intx-inference" + "./vendor/intx-inference", + "./packages/*" ], "overrides": { "@intx/inference": "workspace:*" diff --git a/packages/first-class-providers/package.json b/packages/first-class-providers/package.json new file mode 100644 index 000000000..9ed9f8625 --- /dev/null +++ b/packages/first-class-providers/package.json @@ -0,0 +1,13 @@ +{ + "name": "@corbits/first-class-providers", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "SEE LICENSE IN LICENSE.md", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/first-class-providers/src/index.ts b/packages/first-class-providers/src/index.ts new file mode 100644 index 000000000..97f6d2af5 --- /dev/null +++ b/packages/first-class-providers/src/index.ts @@ -0,0 +1,33 @@ +export { + FIRST_CLASS_PROVIDERS, + firstClassProviderById, +} from "./providers.js"; +export type { + FirstClassAuthKind, + FirstClassOAuthProvider, + FirstClassProviderDef, +} from "./types.js"; + +// Re-export Go package surface so hosts can depend on one entry for connect. +export { + OPENCODE_GO_ANTHROPIC_BASE_URL, + OPENCODE_GO_AUTH_HINT, + OPENCODE_GO_BASE_URL, + OPENCODE_GO_DEFAULT_MODEL, + OPENCODE_GO_DISPLAY_NAME, + OPENCODE_GO_MODEL_IDS, + OPENCODE_GO_MODELS, + OPENCODE_GO_PROVIDER_ID, + buildGoCatalogEntry, + fetchGoUsage, + formatGoUsage, + isKnownGoModel, + protocolForGoModel, + resolveGoEndpoint, + validateGoApiKey, + type GoCatalogEntry, + type GoEndpoint, + type GoModel, + type GoProtocol, + type GoUsage, +} from "../../opencode-go/src/index.js"; diff --git a/packages/first-class-providers/src/providers.test.ts b/packages/first-class-providers/src/providers.test.ts new file mode 100644 index 000000000..608c30885 --- /dev/null +++ b/packages/first-class-providers/src/providers.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { FIRST_CLASS_PROVIDERS, firstClassProviderById } from "./providers.js"; + +describe("FIRST_CLASS_PROVIDERS", () => { + test("lists all first-class providers in product order", () => { + expect(FIRST_CLASS_PROVIDERS.map((p) => p.id)).toEqual([ + "codex", + "xai", + "zen", + "anthropic", + "openai", + "google", + "opencode-go", + ]); + }); + + test("Codex and xAI are OAuth; others are API key", () => { + expect(firstClassProviderById("codex")?.auth).toBe("oauth"); + expect(firstClassProviderById("xai")?.auth).toBe("oauth"); + for (const id of ["zen", "anthropic", "openai", "google", "opencode-go"] as const) { + expect(firstClassProviderById(id)?.auth).toBe("api-key"); + } + }); + + test("API-key providers ship baseURL, models, and defaultModel", () => { + for (const def of FIRST_CLASS_PROVIDERS) { + if (def.auth !== "api-key") continue; + expect(def.baseURL?.length ?? 0).toBeGreaterThan(0); + expect((def.models ?? []).length).toBeGreaterThan(0); + expect(def.defaultModel?.length ?? 0).toBeGreaterThan(0); + expect(def.models).toContain(def.defaultModel); + } + }); + + test("Anthropic is flagged for messages adapter", () => { + expect(firstClassProviderById("anthropic")?.anthropic).toBe(true); + }); +}); diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts new file mode 100644 index 000000000..344c3e782 --- /dev/null +++ b/packages/first-class-providers/src/providers.ts @@ -0,0 +1,90 @@ +import { + OPENCODE_GO_AUTH_HINT, + OPENCODE_GO_BASE_URL, + OPENCODE_GO_DEFAULT_MODEL, + OPENCODE_GO_DISPLAY_NAME, + OPENCODE_GO_MODEL_IDS, + OPENCODE_GO_PROVIDER_ID, +} from "../../opencode-go/src/index.js"; +import type { FirstClassProviderDef } from "./types.js"; + +/** + * First-class providers shown in the models-surface Connect list. + * Order matches product preference: subscription OAuth first, then API keys. + */ +export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ + { + id: "codex", + label: "OpenAI Codex", + auth: "oauth", + oauth: "codex", + }, + { + id: "xai", + label: "xAI Grok", + auth: "oauth", + oauth: "xai", + }, + { + id: "zen", + label: "OpenCode Zen", + auth: "api-key", + baseURL: "https://opencode.ai/zen/v1", + models: [ + "gpt-5.4", + "gpt-5.4-mini", + "claude-sonnet-4-5", + "claude-opus-4-5", + "gemini-3-flash", + "gemini-3-pro", + ], + defaultModel: "claude-sonnet-4-5", + authHint: "Paste your OpenCode Zen API key from https://opencode.ai/auth", + }, + { + id: "anthropic", + label: "Anthropic", + auth: "api-key", + baseURL: "https://api.anthropic.com", + models: [ + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-haiku-4-5", + ], + defaultModel: "claude-sonnet-4-5", + 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", + auth: "api-key", + // OpenAI-compatible Gemini endpoint. + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + models: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"], + defaultModel: "gemini-2.5-pro", + 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, + }, +] as const; + +export function firstClassProviderById(id: string): FirstClassProviderDef | undefined { + return FIRST_CLASS_PROVIDERS.find((p) => p.id === id); +} diff --git a/packages/first-class-providers/src/types.ts b/packages/first-class-providers/src/types.ts new file mode 100644 index 000000000..d1b1c66f0 --- /dev/null +++ b/packages/first-class-providers/src/types.ts @@ -0,0 +1,23 @@ +export type FirstClassAuthKind = "oauth" | "api-key"; + +export type FirstClassOAuthProvider = "codex" | "xai"; + +export type FirstClassProviderDef = { + id: string; + label: string; + auth: FirstClassAuthKind; + /** OAuth flow key when auth === "oauth". */ + oauth?: FirstClassOAuthProvider; + /** Default inference base URL for api-key providers. */ + baseURL?: string; + /** Pre-seeded models for api-key providers (empty until connected for oauth). */ + models?: readonly string[]; + defaultModel?: string; + /** Short paste hint for api-key providers. */ + authHint?: string; + /** + * When true, host should use Anthropic Messages adapter (baseURL without /v1 + * if the adapter appends /v1/messages). + */ + anthropic?: boolean; +}; diff --git a/packages/opencode-go/package.json b/packages/opencode-go/package.json new file mode 100644 index 000000000..7c3313cae --- /dev/null +++ b/packages/opencode-go/package.json @@ -0,0 +1,13 @@ +{ + "name": "@corbits/provider-opencode-go", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "SEE LICENSE IN LICENSE.md", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/opencode-go/src/auth.ts b/packages/opencode-go/src/auth.ts new file mode 100644 index 000000000..b7d764750 --- /dev/null +++ b/packages/opencode-go/src/auth.ts @@ -0,0 +1,22 @@ +export type GoApiKeyValidation = + | { ok: true; apiKey: string } + | { ok: false; error: string }; + +/** + * Validate a pasted OpenCode Go API key at the boundary. + * Keys are opaque bearer tokens from the Zen console; we only require + * non-empty printable content (no whitespace-only paste). + */ +export function validateGoApiKey(raw: string): GoApiKeyValidation { + const apiKey = raw.trim(); + if (apiKey.length === 0) { + return { ok: false, error: "API key is required" }; + } + if (/\s/.test(apiKey)) { + return { ok: false, error: "API key must not contain whitespace" }; + } + if (apiKey.length < 8) { + return { ok: false, error: "API key looks too short" }; + } + return { ok: true, apiKey }; +} diff --git a/packages/opencode-go/src/catalog.ts b/packages/opencode-go/src/catalog.ts new file mode 100644 index 000000000..f5213a870 --- /dev/null +++ b/packages/opencode-go/src/catalog.ts @@ -0,0 +1,39 @@ +import { + OPENCODE_GO_AUTH_HINT, + OPENCODE_GO_BASE_URL, + OPENCODE_GO_DISPLAY_NAME, + OPENCODE_GO_PROVIDER_ID, +} from "./constants.js"; +import { + OPENCODE_GO_DEFAULT_MODEL, + OPENCODE_GO_MODEL_IDS, + OPENCODE_GO_MODELS, + type GoProtocol, +} from "./models.js"; + +/** Host-agnostic catalog projection for connect + model pickers. */ +export type GoCatalogEntry = { + name: typeof OPENCODE_GO_PROVIDER_ID; + displayName: typeof OPENCODE_GO_DISPLAY_NAME; + baseURL: typeof OPENCODE_GO_BASE_URL; + models: readonly string[]; + defaultModel: string; + protocols: Readonly>; + authHint: typeof OPENCODE_GO_AUTH_HINT; +}; + +export function buildGoCatalogEntry(): GoCatalogEntry { + const protocols: Record = {}; + for (const m of OPENCODE_GO_MODELS) { + protocols[m.id] = m.protocol; + } + return { + name: OPENCODE_GO_PROVIDER_ID, + displayName: OPENCODE_GO_DISPLAY_NAME, + baseURL: OPENCODE_GO_BASE_URL, + models: OPENCODE_GO_MODEL_IDS, + defaultModel: OPENCODE_GO_DEFAULT_MODEL, + protocols, + authHint: OPENCODE_GO_AUTH_HINT, + }; +} diff --git a/packages/opencode-go/src/constants.ts b/packages/opencode-go/src/constants.ts new file mode 100644 index 000000000..b0b8a4532 --- /dev/null +++ b/packages/opencode-go/src/constants.ts @@ -0,0 +1,20 @@ +// OpenCode Go subscription endpoints. +// Docs: https://opencode.ai/docs/go/ +// Auth: API key from https://opencode.ai/auth after subscribing to Go. + +export const OPENCODE_GO_PROVIDER_ID = "opencode-go"; +export const OPENCODE_GO_DISPLAY_NAME = "OpenCode Go"; + +// OpenAI-compatible surface (chat completions + responses). Paths are relative +// to this base: /chat/completions, /responses, /models, /usage. +export const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"; + +// Anthropic Messages adapter appends `/v1/messages`, so the root (without /v1) +// is the correct base for message-protocol models. +export const OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go"; + +export const OPENCODE_GO_USAGE_PATH = "/usage"; +export const OPENCODE_GO_MODELS_PATH = "/models"; + +export const OPENCODE_GO_AUTH_HINT = + "Paste your OpenCode Go API key from https://opencode.ai/auth"; diff --git a/packages/opencode-go/src/endpoint.test.ts b/packages/opencode-go/src/endpoint.test.ts new file mode 100644 index 000000000..46c467c59 --- /dev/null +++ b/packages/opencode-go/src/endpoint.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; +import { + buildGoCatalogEntry, + fetchGoUsage, + formatGoUsage, + isOpenCodeGoURL, + parseGoAPIError, + protocolForGoModel, + resolveGoEndpoint, + validateGoApiKey, +} from "./index.js"; + +describe("protocolForGoModel", () => { + test("returns protocol for known models", () => { + expect(protocolForGoModel("kimi-k2.7-code")).toBe("chat-completions"); + expect(protocolForGoModel("gpt-5.6-luna")).toBe("responses"); + expect(protocolForGoModel("minimax-m3")).toBe("messages"); + }); + + test("returns undefined for unknown models", () => { + expect(protocolForGoModel("not-a-model")).toBeUndefined(); + }); + + test("catalog covers chat-completions and messages at minimum", () => { + const protocols = new Set( + ["kimi-k2.7-code", "gpt-5.6-luna", "minimax-m3"].map((id) => protocolForGoModel(id)), + ); + expect(protocols.has("chat-completions")).toBe(true); + expect(protocols.has("messages")).toBe(true); + expect(protocols.has("responses")).toBe(true); + }); +}); + +describe("resolveGoEndpoint", () => { + test("routes chat-completions models to openai-compatible base", () => { + const ep = resolveGoEndpoint("kimi-k2.7-code"); + expect(ep.adapter).toBe("openai-compatible"); + expect(ep.protocol).toBe("chat-completions"); + expect(ep.baseURL).toContain("/zen/go/v1"); + }); + + test("routes responses models to openai-responses adapter", () => { + const ep = resolveGoEndpoint("gpt-5.6-luna"); + expect(ep.adapter).toBe("openai-responses"); + expect(ep.protocol).toBe("responses"); + expect(ep.baseURL).toContain("/zen/go/v1"); + }); + + test("routes messages models to anthropic base", () => { + const ep = resolveGoEndpoint("minimax-m3"); + expect(ep.adapter).toBe("anthropic"); + expect(ep.protocol).toBe("messages"); + expect(ep.baseURL).toBe("https://opencode.ai/zen/go"); + }); + + test("unknown models default to chat-completions", () => { + const ep = resolveGoEndpoint("future-model"); + expect(ep.adapter).toBe("openai-compatible"); + expect(ep.protocol).toBe("chat-completions"); + }); +}); + +describe("validateGoApiKey", () => { + test("accepts a non-empty printable key", () => { + expect(validateGoApiKey("sk-test-key-long-enough").ok).toBe(true); + }); + + test("rejects empty, whitespace-only, short, or spaced keys", () => { + expect(validateGoApiKey("").ok).toBe(false); + expect(validateGoApiKey(" ").ok).toBe(false); + expect(validateGoApiKey("short").ok).toBe(false); + expect(validateGoApiKey("has space").ok).toBe(false); + }); +}); + +describe("fetchGoUsage / formatGoUsage", () => { + test("maps 404 to unavailable and formats without throwing", async () => { + const usage = await fetchGoUsage("sk-test-key", { + fetchImpl: async () => new Response(null, { status: 404 }), + }); + expect(usage.status).toBe("unavailable"); + expect(formatGoUsage(usage)).toContain("unavailable"); + }); + + test("maps 401 to unauthorized", async () => { + const usage = await fetchGoUsage("sk-bad", { + fetchImpl: async () => new Response(null, { status: 401 }), + }); + expect(usage.status).toBe("unauthorized"); + }); + + test("parses ok body and formats rolling window", async () => { + const usage = await fetchGoUsage("sk-ok", { + fetchImpl: async () => + new Response( + JSON.stringify({ + rolling5h: { usagePercent: 42.2 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + }); + expect(usage.status).toBe("ok"); + expect(formatGoUsage(usage)).toBe("Go 5h 42%"); + }); + + test("network failure degrades to error status", async () => { + const usage = await fetchGoUsage("sk-ok", { + fetchImpl: async () => { + throw new Error("network down"); + }, + }); + expect(usage.status).toBe("error"); + expect(usage.message).toContain("network down"); + }); +}); + +describe("buildGoCatalogEntry", () => { + test("pre-seeds models and protocol map", () => { + const entry = buildGoCatalogEntry(); + expect(entry.name).toBe("opencode-go"); + expect(entry.models.length).toBeGreaterThan(0); + expect(entry.protocols["minimax-m3"]).toBe("messages"); + expect(entry.protocols["kimi-k2.7-code"]).toBe("chat-completions"); + }); +}); + +describe("parseGoAPIError", () => { + test("classifies GoUsageLimitError on 429 as quota_exhausted", () => { + const parsed = parseGoAPIError({ + statusCode: 429, + body: { + type: "error", + error: { type: "GoUsageLimitError", message: "subscription quota exceeded" }, + metadata: { workspace: "ws_1" }, + }, + headers: { "retry-after": "120" }, + }); + expect(parsed?.kind).toBe("quota_exhausted"); + expect(parsed?.category).toBe("quota_exhausted"); + expect(parsed?.retryAfterSec).toBe(120); + expect(parsed?.workspace).toBe("ws_1"); + expect(parsed?.message.toLowerCase()).toContain("quota"); + }); + + test("classifies FreeUsageLimitError as quota_exhausted", () => { + const parsed = parseGoAPIError({ + statusCode: 429, + body: { + type: "error", + error: { type: "FreeUsageLimitError", message: "free usage limit" }, + }, + }); + expect(parsed?.kind).toBe("quota_exhausted"); + }); + + test("classifies provider rate limit on 429 as retryable rate_limit", () => { + const parsed = parseGoAPIError({ + statusCode: 429, + body: { + error: { + message: "Error from provider (Console Go): Provider rate limit exceeded", + type: "rate_limit_error", + code: "provider_rate_limit_exceeded", + }, + }, + }); + expect(parsed?.kind).toBe("rate_limit"); + expect(parsed?.category).toBe("retryable"); + }); + + test("treats rate-limit payload on HTTP 400 as rate_limit (gateway quirk)", () => { + const parsed = parseGoAPIError({ + statusCode: 400, + body: { + error: { + message: "Provider rate limit exceeded", + type: "rate_limit_error", + code: "provider_rate_limit_exceeded", + }, + }, + }); + expect(parsed?.kind).toBe("rate_limit"); + expect(parsed?.category).toBe("retryable"); + }); + + test("treats GoUsageLimitError on HTTP 400 as quota_exhausted", () => { + const parsed = parseGoAPIError({ + statusCode: 400, + body: { + type: "error", + error: { type: "GoUsageLimitError", message: "weekly limit hit" }, + }, + }); + expect(parsed?.kind).toBe("quota_exhausted"); + expect(parsed?.category).toBe("quota_exhausted"); + }); + + test("classifies 401 as unauthorized", () => { + const parsed = parseGoAPIError({ + statusCode: 401, + body: { error: { message: "invalid key" } }, + }); + expect(parsed?.kind).toBe("unauthorized"); + expect(parsed?.category).toBe("auth"); + }); + + test("classifies 403 with usage-limit body as quota_exhausted not unauthorized", () => { + const parsed = parseGoAPIError({ + statusCode: 403, + body: { + type: "error", + error: { type: "GoUsageLimitError", message: "subscription usage limit reached" }, + }, + }); + expect(parsed?.kind).toBe("quota_exhausted"); + expect(parsed?.category).toBe("quota_exhausted"); + }); + + test("classifies 403 without limit markers as unauthorized", () => { + const parsed = parseGoAPIError({ + statusCode: 403, + body: { error: { message: "forbidden" } }, + }); + expect(parsed?.kind).toBe("unauthorized"); + expect(parsed?.category).toBe("auth"); + }); + + test("bare 429 without body markers is retryable rate_limit", () => { + const parsed = parseGoAPIError({ + statusCode: 429, + body: { error: { message: "Too Many Requests" } }, + }); + expect(parsed?.kind).toBe("rate_limit"); + expect(parsed?.category).toBe("retryable"); + }); + + test("returns undefined for unrelated 500 bodies", () => { + const parsed = parseGoAPIError({ + statusCode: 500, + body: { error: { message: "internal" } }, + }); + expect(parsed).toBeUndefined(); + }); +}); + +describe("isOpenCodeGoURL", () => { + test("matches zen/go bases", () => { + expect(isOpenCodeGoURL("https://opencode.ai/zen/go/v1")).toBe(true); + expect(isOpenCodeGoURL("https://opencode.ai/zen/go")).toBe(true); + expect(isOpenCodeGoURL("https://api.openai.com/v1")).toBe(false); + }); +}); diff --git a/packages/opencode-go/src/endpoint.ts b/packages/opencode-go/src/endpoint.ts new file mode 100644 index 000000000..f29826fd5 --- /dev/null +++ b/packages/opencode-go/src/endpoint.ts @@ -0,0 +1,44 @@ +import { + OPENCODE_GO_ANTHROPIC_BASE_URL, + OPENCODE_GO_BASE_URL, +} from "./constants.js"; +import { type GoProtocol, protocolForGoModel } from "./models.js"; + +export type GoEndpoint = { + protocol: GoProtocol; + /** Base URL for the selected protocol's adapter. */ + baseURL: string; + /** + * Adapter id expected by the host inference registry. + * Hosts map these to concrete ProviderAdapters. + */ + adapter: "openai-compatible" | "openai-responses" | "anthropic"; +}; + +/** + * Resolve how a Go model should be called. Unknown models default to + * chat-completions so a live catalog refresh can still route something. + */ +export function resolveGoEndpoint(modelId: string): GoEndpoint { + const protocol = protocolForGoModel(modelId) ?? "chat-completions"; + switch (protocol) { + case "messages": + return { + protocol, + baseURL: OPENCODE_GO_ANTHROPIC_BASE_URL, + adapter: "anthropic", + }; + case "responses": + return { + protocol, + baseURL: OPENCODE_GO_BASE_URL, + adapter: "openai-responses", + }; + case "chat-completions": + return { + protocol, + baseURL: OPENCODE_GO_BASE_URL, + adapter: "openai-compatible", + }; + } +} diff --git a/packages/opencode-go/src/errors.ts b/packages/opencode-go/src/errors.ts new file mode 100644 index 000000000..418947689 --- /dev/null +++ b/packages/opencode-go/src/errors.ts @@ -0,0 +1,322 @@ +/** + * Classify OpenCode Go gateway HTTP failures. + * + * Upstream shapes observed in the wild (and in anomalyco/opencode): + * - 429 with `{ type:"error", error:{ type:"GoUsageLimitError"|"FreeUsageLimitError"|"RateLimitError", message } }` + * - 429 with OpenAI-style `{ error:{ type:"rate_limit_error", code:"provider_rate_limit_exceeded", message } }` + * - Occasional 400 with the same rate-limit / quota payload (mis-status from the gateway) + * + * Quota exhaustion is terminal for long windows; short provider rate limits remain retryable. + */ + +export type GoErrorKind = + | "quota_exhausted" + | "rate_limit" + | "unauthorized" + | "unavailable" + | "unknown"; + +/** Subset of InferenceError.category used when reclassifying Go failures. */ +export type GoErrorCategory = "quota_exhausted" | "retryable" | "auth" | "fatal"; + +export type ParsedGoAPIError = { + kind: GoErrorKind; + category: GoErrorCategory; + message: string; + typeName?: string; + code?: string; + retryAfterSec?: number; + workspace?: string; + statusCode: number; +}; + +const QUOTA_TYPE_NAMES = new Set([ + "GoUsageLimitError", + "FreeUsageLimitError", + "BlackUsageLimitError", + "SubscriptionUsageLimitError", +]); + +const RATE_LIMIT_TYPE_NAMES = new Set(["RateLimitError", "rate_limit_error"]); + +const QUOTA_MESSAGE_MARKERS = [ + "usage limit", + "quota exceeded", + "subscription quota", + "gousagelimit", + "freeusagelimit", +] as const; + +const RATE_LIMIT_MESSAGE_MARKERS = [ + "rate limit", + "rate_limit", + "provider rate limit", + "provider_rate_limit", + "too many requests", +] as const; + +function asRecord(value: unknown): Record | undefined { + if (value === null || typeof value !== "object") return undefined; + return value as Record; +} + +function headerValue( + headers: Headers | Record | undefined, + name: string, +): string | undefined { + if (headers === undefined) return undefined; + if (typeof (headers as Headers).get === "function") { + const v = (headers as Headers).get(name); + return v === null ? undefined : v; + } + const rec = headers as Record; + const lower = name.toLowerCase(); + for (const [k, v] of Object.entries(rec)) { + if (k.toLowerCase() === lower) return v; + } + return undefined; +} + +function parseRetryAfterSec( + headers: Headers | Record | undefined, + body: Record | undefined, +): number | undefined { + const rawHeader = headerValue(headers, "retry-after"); + if (rawHeader !== undefined) { + const n = Number(rawHeader); + if (Number.isFinite(n) && n >= 0) return n; + } + if (body !== undefined) { + const meta = asRecord(body["metadata"]); + if (meta !== undefined && typeof meta["retryAfter"] === "number") { + return meta["retryAfter"]; + } + if (typeof body["retry_after"] === "number") return body["retry_after"]; + } + return undefined; +} + +function extractErrorNode(body: unknown): { + typeName?: string; + code?: string; + message: string; + workspace?: string; + root?: Record; +} { + const root = asRecord(body); + if (root === undefined) { + return { message: typeof body === "string" ? body : "" }; + } + // Shape A: { type:"error", error:{ type, message }, metadata?:{ workspace } } + const nested = asRecord(root["error"]); + if (nested !== undefined) { + const meta = asRecord(root["metadata"]); + const out: { + typeName?: string; + code?: string; + message: string; + workspace?: string; + root: Record; + } = { + message: + typeof nested["message"] === "string" + ? nested["message"] + : typeof root["message"] === "string" + ? root["message"] + : "", + root, + }; + if (typeof nested["type"] === "string") out.typeName = nested["type"]; + if (typeof nested["code"] === "string") out.code = nested["code"]; + if (typeof meta?.["workspace"] === "string") out.workspace = meta["workspace"]; + return out; + } + // Shape B: flat { type, message, code } + const flat: { + typeName?: string; + code?: string; + message: string; + root: Record; + } = { + message: typeof root["message"] === "string" ? root["message"] : "", + root, + }; + if (typeof root["type"] === "string") flat.typeName = root["type"]; + if (typeof root["code"] === "string") flat.code = root["code"]; + return flat; +} + +function looksLikeQuota(typeName: string | undefined, code: string | undefined, message: string): boolean { + if (typeName !== undefined && QUOTA_TYPE_NAMES.has(typeName)) return true; + if (code !== undefined && /quota|usage_limit/i.test(code)) return true; + const lower = message.toLowerCase(); + return QUOTA_MESSAGE_MARKERS.some((m) => lower.includes(m)); +} + +function looksLikeRateLimit( + typeName: string | undefined, + code: string | undefined, + message: string, +): boolean { + if (typeName !== undefined && RATE_LIMIT_TYPE_NAMES.has(typeName)) return true; + if (code !== undefined && /rate_limit/i.test(code)) return true; + const lower = message.toLowerCase(); + return RATE_LIMIT_MESSAGE_MARKERS.some((m) => lower.includes(m)); +} + +function userMessageFor( + kind: GoErrorKind, + original: string, + retryAfterSec: number | undefined, +): string { + if (kind === "quota_exhausted") { + const reset = + retryAfterSec !== undefined && retryAfterSec > 0 + ? ` Resets in ~${formatReset(retryAfterSec)}.` + : ""; + return ( + (original.length > 0 + ? original + : "OpenCode Go usage limit reached. Use free Zen models or wait for the window to reset.") + + reset + ); + } + if (kind === "rate_limit") { + const wait = + retryAfterSec !== undefined && retryAfterSec > 0 + ? ` Retry after ~${formatReset(retryAfterSec)}.` + : " Retry shortly."; + return ( + (original.length > 0 ? original : "OpenCode Go rate limit exceeded.") + wait + ); + } + if (kind === "unauthorized") { + return original.length > 0 + ? original + : "OpenCode Go authentication failed. Reconnect from /model (c or Ctrl+A)."; + } + return original.length > 0 ? original : "OpenCode Go request failed."; +} + +function formatReset(sec: number): string { + if (sec < 60) return `${String(Math.ceil(sec))}s`; + if (sec < 3600) return `${String(Math.ceil(sec / 60))}m`; + if (sec < 86400) { + const h = Math.floor(sec / 3600); + const m = Math.ceil((sec % 3600) / 60); + return m > 0 ? `${String(h)}h ${String(m)}m` : `${String(h)}h`; + } + const d = Math.floor(sec / 86400); + const h = Math.ceil((sec % 86400) / 3600); + return h > 0 ? `${String(d)}d ${String(h)}h` : `${String(d)}d`; +} + +/** + * Parse a Go gateway error body + status. Returns undefined when the payload + * does not look Go-specific so callers can fall through to generic handling. + */ +export function parseGoAPIError(args: { + statusCode: number; + body: unknown; + headers?: Headers | Record; +}): ParsedGoAPIError | undefined { + const { statusCode, body, headers } = args; + const extracted = extractErrorNode(body); + const { typeName, code, message, workspace, root } = extracted; + const retryAfterSec = parseRetryAfterSec(headers, root); + + const quota = looksLikeQuota(typeName, code, message); + const rateLimit = !quota && looksLikeRateLimit(typeName, code, message); + + // Quota before auth: the gateway has returned 403 with usage-limit bodies. + // Clear quota markers must not be swallowed as unauthorized. + // 400 is intentional — the gateway has been observed returning 400 for limit hits. + if (quota && (statusCode === 429 || statusCode === 402 || statusCode === 400 || statusCode === 403)) { + return { + kind: "quota_exhausted", + category: "quota_exhausted", + message: userMessageFor("quota_exhausted", message, retryAfterSec), + statusCode, + ...(typeName !== undefined ? { typeName } : {}), + ...(code !== undefined ? { code } : {}), + ...(retryAfterSec !== undefined ? { retryAfterSec } : {}), + ...(workspace !== undefined ? { workspace } : {}), + }; + } + + // Provider / console rate limit (retryable). Same 400 quirk. + if (rateLimit && (statusCode === 429 || statusCode === 400 || statusCode === 503)) { + return { + kind: "rate_limit", + category: "retryable", + message: userMessageFor("rate_limit", message, retryAfterSec), + statusCode, + ...(typeName !== undefined ? { typeName } : {}), + ...(code !== undefined ? { code } : {}), + ...(retryAfterSec !== undefined ? { retryAfterSec } : {}), + ...(workspace !== undefined ? { workspace } : {}), + }; + } + + // Auth after quota/rate-limit so marker-bearing 403s are not misclassified. + if (statusCode === 401 || statusCode === 403) { + return { + kind: "unauthorized", + category: "auth", + message: userMessageFor("unauthorized", message, undefined), + statusCode, + ...(typeName !== undefined ? { typeName } : {}), + ...(code !== undefined ? { code } : {}), + ...(workspace !== undefined ? { workspace } : {}), + }; + } + + // Bare 429 without body markers — treat as retryable rate limit. + // Callers must only invoke parseGoAPIError for known-Go contexts; bare 429 + // from other proxies stays outside this path. + if (statusCode === 429) { + return { + kind: "rate_limit", + category: "retryable", + message: userMessageFor("rate_limit", message, retryAfterSec), + statusCode, + ...(typeName !== undefined ? { typeName } : {}), + ...(code !== undefined ? { code } : {}), + ...(retryAfterSec !== undefined ? { retryAfterSec } : {}), + ...(workspace !== undefined ? { workspace } : {}), + }; + } + + if (statusCode === 404) { + return { + kind: "unavailable", + category: "fatal", + message: message.length > 0 ? message : "OpenCode Go endpoint not found.", + statusCode, + ...(typeName !== undefined ? { typeName } : {}), + }; + } + + // Recognized Go type name on an unexpected status — still surface it. + if (typeName !== undefined && (QUOTA_TYPE_NAMES.has(typeName) || RATE_LIMIT_TYPE_NAMES.has(typeName))) { + const kind: GoErrorKind = QUOTA_TYPE_NAMES.has(typeName) ? "quota_exhausted" : "rate_limit"; + return { + kind, + category: kind === "quota_exhausted" ? "quota_exhausted" : "retryable", + message: userMessageFor(kind, message, retryAfterSec), + statusCode, + typeName, + ...(code !== undefined ? { code } : {}), + ...(retryAfterSec !== undefined ? { retryAfterSec } : {}), + ...(workspace !== undefined ? { workspace } : {}), + }; + } + + return undefined; +} + +/** True when the URL or base looks like the OpenCode Go gateway. */ +export function isOpenCodeGoURL(urlOrBase: string | undefined): boolean { + if (urlOrBase === undefined || urlOrBase.length === 0) return false; + return /opencode\.ai\/zen\/go/i.test(urlOrBase); +} diff --git a/packages/opencode-go/src/index.ts b/packages/opencode-go/src/index.ts new file mode 100644 index 000000000..bcec25fb0 --- /dev/null +++ b/packages/opencode-go/src/index.ts @@ -0,0 +1,32 @@ +export { + OPENCODE_GO_ANTHROPIC_BASE_URL, + OPENCODE_GO_AUTH_HINT, + OPENCODE_GO_BASE_URL, + OPENCODE_GO_DISPLAY_NAME, + OPENCODE_GO_MODELS_PATH, + OPENCODE_GO_PROVIDER_ID, + OPENCODE_GO_USAGE_PATH, +} from "./constants.js"; + +export { + OPENCODE_GO_DEFAULT_MODEL, + OPENCODE_GO_MODEL_IDS, + OPENCODE_GO_MODELS, + isKnownGoModel, + protocolForGoModel, + type GoModel, + type GoModelId, + type GoProtocol, +} from "./models.js"; + +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 { + isOpenCodeGoURL, + parseGoAPIError, + type GoErrorCategory, + type GoErrorKind, + type ParsedGoAPIError, +} from "./errors.js"; diff --git a/packages/opencode-go/src/models.ts b/packages/opencode-go/src/models.ts new file mode 100644 index 000000000..4c866eb0a --- /dev/null +++ b/packages/opencode-go/src/models.ts @@ -0,0 +1,51 @@ +// Pre-seeded OpenCode Go catalog with per-model protocol metadata. +// Source of truth for endpoints: https://opencode.ai/docs/go/ +// Model list may lag the live /zen/go/v1/models response; keep ids stable. + +export type GoProtocol = "chat-completions" | "responses" | "messages"; + +export type GoModel = { + id: string; + name: string; + protocol: GoProtocol; +}; + +export const OPENCODE_GO_MODELS = [ + { id: "grok-4.5", name: "Grok 4.5", protocol: "chat-completions" }, + { id: "gpt-5.6-luna", name: "GPT 5.6 Luna", protocol: "responses" }, + { id: "glm-5.2", name: "GLM-5.2", protocol: "chat-completions" }, + { id: "glm-5.1", name: "GLM-5.1", protocol: "chat-completions" }, + { id: "kimi-k3", name: "Kimi K3", protocol: "chat-completions" }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", protocol: "chat-completions" }, + { id: "kimi-k2.6", name: "Kimi K2.6", protocol: "chat-completions" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", protocol: "chat-completions" }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", protocol: "chat-completions" }, + { id: "mimo-v2.5", name: "MiMo-V2.5", protocol: "chat-completions" }, + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", protocol: "chat-completions" }, + { id: "minimax-m3", name: "MiniMax M3", protocol: "messages" }, + { id: "minimax-m2.7", name: "MiniMax M2.7", protocol: "messages" }, + { id: "minimax-m2.5", name: "MiniMax M2.5", protocol: "messages" }, + { id: "qwen3.8-max", name: "Qwen3.8 Max", protocol: "messages" }, + { id: "qwen3.7-max", name: "Qwen3.7 Max", protocol: "messages" }, + { id: "qwen3.7-plus", name: "Qwen3.7 Plus", protocol: "messages" }, + { id: "qwen3.6-plus", name: "Qwen3.6 Plus", protocol: "messages" }, + { id: "hy3", name: "Hy3", protocol: "chat-completions" }, +] as const satisfies readonly GoModel[]; + +export type GoModelId = (typeof OPENCODE_GO_MODELS)[number]["id"]; + +export const OPENCODE_GO_MODEL_IDS: readonly string[] = OPENCODE_GO_MODELS.map((m) => m.id); + +export const OPENCODE_GO_DEFAULT_MODEL: GoModelId = "kimi-k2.7-code"; + +const PROTOCOL_BY_ID = new Map( + OPENCODE_GO_MODELS.map((m) => [m.id, m.protocol]), +); + +export function protocolForGoModel(modelId: string): GoProtocol | undefined { + return PROTOCOL_BY_ID.get(modelId); +} + +export function isKnownGoModel(modelId: string): boolean { + return PROTOCOL_BY_ID.has(modelId); +} diff --git a/packages/opencode-go/src/usage.ts b/packages/opencode-go/src/usage.ts new file mode 100644 index 000000000..91be9f2f9 --- /dev/null +++ b/packages/opencode-go/src/usage.ts @@ -0,0 +1,108 @@ +import { OPENCODE_GO_BASE_URL, OPENCODE_GO_USAGE_PATH } from "./constants.js"; + +export type GoUsageWindow = { + usageDollars?: number; + limitDollars?: number; + usagePercent?: number; + resetInSec?: number; +}; + +export type GoUsage = { + rolling5h?: GoUsageWindow; + weekly?: GoUsageWindow; + monthly?: GoUsageWindow; + /** Raw status when the endpoint is missing or auth fails. */ + status: "ok" | "unavailable" | "unauthorized" | "error"; + message?: string; +}; + +/** Minimal fetch shape so tests can inject stubs without matching full DOM fetch. */ +export type GoFetch = ( + input: string, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => Promise; + +function asWindow(value: unknown): GoUsageWindow | undefined { + if (value === null || typeof value !== "object") return undefined; + const o = value as Record; + const out: GoUsageWindow = {}; + if (typeof o["usageDollars"] === "number") out.usageDollars = o["usageDollars"]; + if (typeof o["limitDollars"] === "number") out.limitDollars = o["limitDollars"]; + if (typeof o["usagePercent"] === "number") out.usagePercent = o["usagePercent"]; + if (typeof o["resetInSec"] === "number") out.resetInSec = o["resetInSec"]; + return out; +} + +/** + * Best-effort Go subscription usage fetch. + * Upstream usage API may be absent (404) — callers must degrade cleanly. + */ +export async function fetchGoUsage( + apiKey: string, + opts?: { fetchImpl?: GoFetch; signal?: AbortSignal }, +): Promise { + const fetchImpl: GoFetch = + opts?.fetchImpl ?? + ((input, init) => globalThis.fetch(input, init as RequestInit)); + const url = `${OPENCODE_GO_BASE_URL}${OPENCODE_GO_USAGE_PATH}`; + try { + const init: { + method: string; + headers: Record; + signal?: AbortSignal; + } = { + method: "GET", + headers: { + authorization: `Bearer ${apiKey}`, + accept: "application/json", + }, + }; + if (opts?.signal !== undefined) { + init.signal = opts.signal; + } + const res = await fetchImpl(url, init); + if (res.status === 401 || res.status === 403) { + return { status: "unauthorized", message: `usage HTTP ${String(res.status)}` }; + } + if (res.status === 404) { + return { status: "unavailable", message: "usage endpoint not available" }; + } + if (!res.ok) { + return { status: "error", message: `usage HTTP ${String(res.status)}` }; + } + const body: unknown = await res.json(); + if (body === null || typeof body !== "object") { + return { status: "error", message: "usage response was not an object" }; + } + const o = body as Record; + const result: GoUsage = { status: "ok" }; + const rolling5h = asWindow(o["rolling5h"]); + const weekly = asWindow(o["weekly"]); + const monthly = asWindow(o["monthly"]); + if (rolling5h !== undefined) result.rolling5h = rolling5h; + if (weekly !== undefined) result.weekly = weekly; + if (monthly !== undefined) result.monthly = monthly; + return result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { status: "error", message }; + } +} + +export function formatGoUsage(usage: GoUsage): string { + if (usage.status !== "ok") { + if (usage.status === "unavailable") return "Go usage unavailable"; + if (usage.status === "unauthorized") return "Go usage: auth failed"; + return usage.message !== undefined ? `Go usage: ${usage.message}` : "Go usage error"; + } + const w = usage.rolling5h ?? usage.weekly ?? usage.monthly; + if (w === undefined) return "Go usage ok"; + const pct = + w.usagePercent !== undefined + ? `${Math.round(w.usagePercent)}%` + : w.usageDollars !== undefined && w.limitDollars !== undefined + ? `$${w.usageDollars.toFixed(2)}/$${w.limitDollars.toFixed(0)}` + : "ok"; + const window = usage.rolling5h !== undefined ? "5h" : usage.weekly !== undefined ? "week" : "month"; + return `Go ${window} ${pct}`; +} diff --git a/src/config/index.ts b/src/config/index.ts index cbeda3427..b91c4c57e 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -29,7 +29,12 @@ import { } from "../provider/codex-responses-adapter.js"; import { GROK_RESPONSES_PROVIDER, GROK_USER_ID_OPTION } from "../provider/grok-responses-adapter.js"; import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js"; +import { OPENAI_RESPONSES_PROVIDER } from "../provider/openai-responses-adapter.js"; import { xaiUserIdFromAccessToken } from "../auth/xai/session.js"; +import { + OPENCODE_GO_BASE_URL, + resolveGoEndpoint, +} from "../../packages/opencode-go/src/index.js"; import { globalSettingsPath, @@ -119,6 +124,12 @@ export type ProviderCatalogEntry = { // inject the x-bf-vk header (in addition to Authorization). The flag is // also used to enable /models auto-discovery scoped to the key. bifrostVirtualKey?: boolean; + // Anthropic Messages API (x-api-key). Used by first-class Anthropic and by + // OpenCode Go models that speak the messages protocol. + anthropic?: boolean; + // OpenCode Go multi-protocol provider. Per-model routing picks + // openai-compatible, openai-responses, or anthropic at source-build time. + opencodeGo?: boolean; }; // Build the InferenceSource for a Codex OAuth profile. Routes to the @@ -193,6 +204,61 @@ export function buildBifrostSource(fields: { }; } +// Anthropic Messages API (native anthropic provider in intx-inference). +export function buildAnthropicSource(fields: { + id: string; + baseURL: string; + apiKey?: string; + model: string; +}): InferenceSource { + return { + id: fields.id, + provider: "anthropic", + baseURL: fields.baseURL.replace(/\/+$/, ""), + apiKey: fields.apiKey !== undefined && fields.apiKey.length > 0 ? fields.apiKey : KEYLESS_API_KEY, + model: fields.model, + defaults: { maxTokens: SOURCE_MAX_TOKENS }, + }; +} + +// OpenCode Go: per-model protocol routing (chat completions / responses / messages). +export function buildGoSource(fields: { + id: string; + apiKey?: string; + model: string; + reasoningEffort?: ReasoningEffort; +}): InferenceSource { + const endpoint = resolveGoEndpoint(fields.model); + const apiKey = + fields.apiKey !== undefined && fields.apiKey.length > 0 ? fields.apiKey : KEYLESS_API_KEY; + if (endpoint.adapter === "anthropic") { + return buildAnthropicSource({ + id: fields.id, + baseURL: endpoint.baseURL, + apiKey, + model: fields.model, + }); + } + if (endpoint.adapter === "openai-responses") { + return { + id: fields.id, + provider: OPENAI_RESPONSES_PROVIDER, + baseURL: endpoint.baseURL, + apiKey, + model: fields.model, + defaults: { maxTokens: SOURCE_MAX_TOKENS }, + }; + } + // chat-completions (default) + return buildOpenAISource({ + id: fields.id, + baseURL: endpoint.baseURL.length > 0 ? endpoint.baseURL : OPENCODE_GO_BASE_URL, + apiKey, + model: fields.model, + ...(fields.reasoningEffort !== undefined ? { reasoningEffort: fields.reasoningEffort } : {}), + }); +} + export type Config = { configured: true; apiKey: string; @@ -523,14 +589,22 @@ 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). + const baseURL = + entry.anthropic === true || entry.opencodeGo === true + ? entry.baseURL.replace(/\/+$/, "") + : normalizeOpenAICompatibleBaseURL(entry.baseURL); return { - baseURL: normalizeOpenAICompatibleBaseURL(entry.baseURL), + baseURL, ...(entry.keyless === true ? { keyless: true } : {}), ...(entry.apiKey !== undefined && entry.apiKey.length > 0 ? { apiKey: entry.apiKey } : {}), models: entry.models, ...(entry.defaultModel !== undefined ? { defaultModel: entry.defaultModel } : {}), ...(entry.free !== undefined ? { free: entry.free } : {}), ...(entry.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), + ...(entry.anthropic === true ? { anthropic: true } : {}), + ...(entry.opencodeGo === true ? { opencodeGo: true } : {}), }; } @@ -572,13 +646,18 @@ export function buildProviderCatalog( if (settings !== null && Object.keys(settings.providers).length > 0) { return Object.entries(settings.providers).map(([name, p]): ProviderCatalogEntry => ({ name, - baseURL: normalizeOpenAICompatibleBaseURL(p.baseURL), + 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 [ diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index 2a3227716..c30164ebe 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -3,6 +3,8 @@ import type { InferenceSource } from "@intx/types/runtime"; import { buildBifrostSource, buildCodexSource, + buildGoSource, + buildAnthropicSource, buildOpenAISource, buildXaiSource, type ProviderCatalogEntry, @@ -124,6 +126,30 @@ export function buildInferenceSourceForRef( model: ref.model, }); } + if (entry?.opencodeGo === true || providerSettings?.opencodeGo === true) { + return buildGoSource({ + id: ref.provider, + ...(entry?.apiKey !== undefined + ? { apiKey: entry.apiKey } + : providerSettings?.apiKey !== undefined + ? { apiKey: providerSettings.apiKey } + : {}), + model: ref.model, + ...(effort !== undefined ? { reasoningEffort: effort } : {}), + }); + } + if (entry?.anthropic === true || providerSettings?.anthropic === true) { + return buildAnthropicSource({ + id: ref.provider, + baseURL, + ...(entry?.apiKey !== undefined + ? { apiKey: entry.apiKey } + : providerSettings?.apiKey !== undefined + ? { apiKey: providerSettings.apiKey } + : {}), + model: ref.model, + }); + } if (entry?.bifrostVirtualKey === true) { const src = buildBifrostSource({ id: ref.provider, diff --git a/src/config/providers.ts b/src/config/providers.ts index f7243f20b..415501b8e 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -9,6 +9,8 @@ export type ProviderSubmission = { defaultModel?: string; keyless?: boolean; bifrostVirtualKey?: boolean; + anthropic?: boolean; + opencodeGo?: boolean; }; export type ProviderEntryResult = @@ -34,6 +36,10 @@ export function buildProviderEntry( if (!keyless && (apiKey === undefined || apiKey.length === 0)) { 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). + const anthropic = submission.anthropic === true || existing?.anthropic === true; + const opencodeGo = submission.opencodeGo === true || existing?.opencodeGo === true; const entry: ProviderCatalogEntry = { name: submission.name, baseURL: submission.baseURL, @@ -41,11 +47,13 @@ export function buildProviderEntry( ...(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 } : {}), + ...(anthropic ? { anthropic: true } : {}), + ...(opencodeGo ? { opencodeGo: true } : {}), }; const catalog = currentCatalog .filter((p) => p.name !== submission.name && p.name !== submission.originalName) diff --git a/src/config/settings.ts b/src/config/settings.ts index d70d69f07..1083116b0 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -34,6 +34,10 @@ export type ProviderSettings = { // adapter (which injects the x-bf-vk header) and enables model // auto-discovery via the Bifrost /v1/models endpoint. bifrostVirtualKey?: boolean; + // Anthropic Messages API provider (x-api-key auth). + anthropic?: boolean; + // OpenCode Go multi-protocol provider; per-model adapter selection. + opencodeGo?: boolean; }; export type ProviderTier = "fast" | "standard" | "clever"; @@ -344,6 +348,8 @@ const ProviderSettingsSchema = type({ "free?": "boolean", "contextWindow?": "number", "bifrostVirtualKey?": "boolean", + "anthropic?": "boolean", + "opencodeGo?": "boolean", }); const TierProviderRefSchema = type({ diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index f6939c243..718e27da5 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -70,4 +70,113 @@ describe("normalizeInferenceErrorForRetry", () => { const err = { category: "fatal" as const, message: "bad request" }; expect(normalizeInferenceErrorForRetry(err)).toEqual(err); }); -}); \ No newline at end of file + + test("maps GoUsageLimitError 429 to quota_exhausted", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "retryable", + message: "rate limited", + statusCode: 429, + raw: { + type: "error", + error: { type: "GoUsageLimitError", message: "subscription quota exceeded" }, + }, + retryAfterMs: 60_000, + }); + expect(normalized.category).toBe("quota_exhausted"); + expect(normalized.message.toLowerCase()).toContain("quota"); + }); + + test("maps provider rate limit 400 quirk to retryable", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "bad request", + statusCode: 400, + raw: { + error: { + message: "Error from provider (Console Go): Provider rate limit exceeded", + type: "rate_limit_error", + code: "provider_rate_limit_exceeded", + }, + }, + }); + expect(normalized.category).toBe("retryable"); + expect(normalized.message.toLowerCase()).toMatch(/rate limit/); + }); + + test("maps GoUsageLimitError on 400 to quota_exhausted", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "bad request", + statusCode: 400, + raw: { + type: "error", + error: { type: "GoUsageLimitError", message: "weekly limit hit" }, + }, + }); + expect(normalized.category).toBe("quota_exhausted"); + }); + + test("does not reclassify non-Go provider_rate_limit_exceeded bodies", () => { + const err = { + category: "fatal" as const, + message: "rate limited", + statusCode: 400, + raw: { + error: { + message: "Provider rate limit exceeded", + type: "rate_limit_error", + code: "provider_rate_limit_exceeded", + }, + }, + }; + expect(normalizeInferenceErrorForRetry(err)).toEqual(err); + }); + + test("known-Go bare 429 without Console Go markers reclassifies as rate_limit", () => { + // intx defaults 429 → quota_exhausted; without body markers the old path + // left that category in place. Known-Go context must reclassify. + const bare = { + category: "quota_exhausted" as const, + message: "Too Many Requests", + statusCode: 429, + raw: { error: { message: "Too Many Requests" } }, + }; + + // Without Go context, leave intx's classification alone. + expect(normalizeInferenceErrorForRetry(bare)).toEqual(bare); + + const viaRequestURL = normalizeInferenceErrorForRetry({ + ...bare, + requestURL: "https://opencode.ai/zen/go/v1/chat/completions", + }); + expect(viaRequestURL.category).toBe("retryable"); + // Bare 429 keeps the original message and appends a short retry hint. + expect(viaRequestURL.message.toLowerCase()).toMatch(/too many requests|rate limit/); + + const viaProviderId = normalizeInferenceErrorForRetry({ + ...bare, + providerId: "opencode-go", + }); + expect(viaProviderId.category).toBe("retryable"); + + const viaFlag = normalizeInferenceErrorForRetry({ + ...bare, + opencodeGo: true, + }); + expect(viaFlag.category).toBe("retryable"); + }); + + test("403 with usage-limit body reclassifies as quota_exhausted", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "credential_failure", + message: "forbidden", + statusCode: 403, + raw: { + type: "error", + error: { type: "GoUsageLimitError", message: "subscription usage limit reached" }, + }, + }); + expect(normalized.category).toBe("quota_exhausted"); + expect(normalized.message.toLowerCase()).toMatch(/usage limit|quota/); + }); +}); diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 0fb216499..2998baf1c 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -1,12 +1,33 @@ import type { InferenceError } from "@intx/types/runtime"; +import { + isOpenCodeGoURL, + OPENCODE_GO_PROVIDER_ID, + parseGoAPIError, +} from "../packages/opencode-go/src/index.js"; export type InferenceErrorLike = { category: string; message?: string; statusCode?: number; raw?: unknown; + retryAfterMs?: number; + /** Optional request base/url when known — used to scope Go error reclassification. */ + requestURL?: string; + /** Provider catalog id when known (e.g. opencode-go). */ + providerId?: string; + /** Explicit OpenCode Go provider flag when known. */ + opencodeGo?: boolean; }; +/** Optional Go context callers may attach so bare 429s reclassify without body markers. */ +export type OpenCodeGoErrorContext = { + requestURL?: string; + providerId?: string; + opencodeGo?: boolean; +}; + +export type InferenceErrorWithGoContext = InferenceError & OpenCodeGoErrorContext; + const GATEWAY_OVERLOAD_STATUS_CODES = new Set([502, 503, 504]); const GATEWAY_OVERLOAD_TEXT_MARKERS = [ @@ -81,11 +102,93 @@ export function isGatewayOverloadInferenceError(error: InferenceErrorLike): bool return false; } +function tryParseJSON(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +/** + * True when the error is known to come from OpenCode Go — via explicit + * provider context (id / flag / request URL) or Go-specific body markers. + * Do not match bare `provider_rate_limit_exceeded` alone; other proxies use it. + */ +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 (isOpenCodeGoURL(error.requestURL)) return true; + if (isOpenCodeGoURL(rawText)) return true; + return /GoUsageLimitError|FreeUsageLimitError|BlackUsageLimitError|Console Go|opencode\.ai\/zen\/go/i.test( + `${messageText}\n${rawText}`, + ); +} + +/** + * Reclassify OpenCode Go quota / rate-limit / auth failures when the request is + * known to be Go (provider id / opencodeGo / requestURL) or the body carries + * Go-specific error types (including HTTP 400/403 mis-status). + * + * intx defaults bare 429 → quota_exhausted; for known-Go contexts a bare 429 + * reclassifies as retryable rate_limit so short limits are not treated as + * long-window quota exhaustion. + */ +export function normalizeOpenCodeGoInferenceError( + error: InferenceErrorWithGoContext, +): InferenceError { + const statusCode = error.statusCode; + if (statusCode === undefined) return error; + + const rawText = stringFromRaw(error.raw); + const messageText = error.message ?? ""; + if (!isKnownOpenCodeGoError(error, rawText, messageText)) return error; + + const bodyFromRaw = + error.raw !== undefined && typeof error.raw === "object" + ? error.raw + : tryParseJSON(rawText.length > 0 ? rawText : messageText); + // Empty body is fine for known-Go bare 429/403 reclassification. + const body = + bodyFromRaw !== undefined + ? bodyFromRaw + : messageText.length > 0 + ? { error: { message: messageText } } + : {}; + + const parsed = parseGoAPIError({ statusCode, body }); + if (parsed === undefined) return error; + + const category = + parsed.category === "auth" + ? ("credential_failure" as const) + : parsed.category; + + const retryAfterMs = + parsed.retryAfterSec !== undefined + ? parsed.retryAfterSec * 1000 + : error.retryAfterMs; + + return { + category, + message: parsed.message, + statusCode: parsed.statusCode, + ...(error.raw !== undefined ? { raw: error.raw } : {}), + ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), + }; +} + /** * Reclassify gateway overload errors so the default retry policy treats them as - * transient instead of aborting on protocol_mismatch. + * transient instead of aborting on protocol_mismatch. Also normalizes OpenCode + * Go quota/rate-limit shapes (including HTTP 400 mis-status). */ -export function normalizeInferenceErrorForRetry(error: InferenceError): InferenceError { +export function normalizeInferenceErrorForRetry( + error: InferenceErrorWithGoContext, +): InferenceError { + const goNormalized = normalizeOpenCodeGoInferenceError(error); + if (goNormalized !== error) return goNormalized; + if (!isGatewayOverloadInferenceError(error)) return error; if (error.category === "retryable" || error.category === "timeout") return error; @@ -101,4 +204,4 @@ export function normalizeInferenceErrorForRetry(error: InferenceError): Inferenc export function gatewayOverloadUserMessage(error: InferenceErrorLike): string { if (!isGatewayOverloadInferenceError(error)) return error.message ?? "Inference error"; return GATEWAY_OVERLOAD_USER_MESSAGE; -} \ No newline at end of file +} diff --git a/src/provider/inference-dependencies.ts b/src/provider/inference-dependencies.ts index d3355e04a..dda245548 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -4,16 +4,15 @@ import * as openaiCompatible from "./openai-compatible-adapter.js"; import * as codexResponses from "./codex-responses-adapter.js"; import * as grokResponses from "./grok-responses-adapter.js"; import * as bifrostAdapter from "./bifrost-adapter.js"; +import * as openaiResponses from "./openai-responses-adapter.js"; import { CODEX_RESPONSES_PROVIDER } from "./codex-responses-adapter.js"; import { GROK_RESPONSES_PROVIDER } from "./grok-responses-adapter.js"; import { BIFROST_PROVIDER } from "./bifrost-adapter.js"; +import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses-adapter.js"; -// Corbits Code ships three first-party adapters on top of the built-in provider -// set: a providerOptions-aware "openai-compatible" override, plus the Codex and -// Grok responses adapters. The interchange runtime resolves adapters through an -// injected registry rather than a mutable global, so we describe ours as a -// manifest and feed it a local importer — the registry keeps its dynamic-import -// contract while these stay statically linked into the bundle. +// Corbits Code ships first-party adapters on top of the built-in provider set: +// openai-compatible override, Codex/Grok responses, Bifrost, and generic +// openai-responses (OpenCode Go gpt-* Luna family). const manifest: AdapterManifest = [ { provider: "openai-compatible", @@ -35,13 +34,19 @@ const manifest: AdapterManifest = [ specifier: "bifrost-adapter", export: "createBifrostAdapter", }, + { + provider: OPENAI_RESPONSES_PROVIDER, + specifier: "openai-responses-adapter", + export: "createOpenAIResponsesAdapter", + }, ]; const localModules: Record = { "openai-compatible-adapter": openaiCompatible, "codex-responses-adapter": codexResponses, "grok-responses-adapter": grokResponses, - "bifrost-adapter": bifrostAdapter, + "bifrost-adapter": bifrostAdapter, + "openai-responses-adapter": openaiResponses, }; let cached: Promise | undefined; diff --git a/src/provider/openai-responses-adapter.ts b/src/provider/openai-responses-adapter.ts new file mode 100644 index 000000000..e68ce9494 --- /dev/null +++ b/src/provider/openai-responses-adapter.ts @@ -0,0 +1,169 @@ +import { + BEARER_CREDENTIAL_SENTINEL, + type BuiltRequest, + type ProviderAdapter, +} from "@intx/inference"; +import type { + ContentBlock, + ConversationTurn, + InferenceOptions, + LastCycleSource, +} from "@intx/types/runtime"; +import { + createResponsesBlockIndexer, + isResponsesStreamTerminal, + parseResponse, +} from "./codex-responses-adapter.js"; + +// Generic OpenAI Responses API adapter (POST /responses). Used by OpenCode Go +// models that speak Responses rather than Chat Completions (e.g. gpt-5.6-luna). +// Shares the Codex/Grok SSE parser; only the request shape and path differ from +// Chat Completions and from the Grok-specific header set. + +export const OPENAI_RESPONSES_PROVIDER = "openai-responses"; + +type ResponsesInputContentPart = + | { type: "input_text"; text: string } + | { type: "input_image"; image_url: string }; + +type ResponsesInputItem = + | { type: "message"; role: "user" | "assistant" | "system"; content: string | ResponsesInputContentPart[] } + | { type: "function_call"; name: string; arguments: string; call_id: string } + | { type: "function_call_output"; call_id: string; output: string } + | { type: "reasoning"; summary: never[]; encrypted_content: string }; + +function toolResultText(block: Extract): string { + const parts: string[] = []; + for (const c of block.content) { + if (c.type === "text") parts.push(c.text); + else parts.push(`[unsupported ${c.type} content omitted]`); + } + return parts.join(""); +} + +function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] { + const items: ResponsesInputItem[] = []; + const role = turn.role; + const parts: ResponsesInputContentPart[] = []; + let hasImage = false; + + const flushMessage = (): void => { + if (parts.length === 0) return; + items.push({ + type: "message", + role, + content: hasImage ? [...parts] : parts.map((part) => (part.type === "input_text" ? part.text : "")).join(""), + }); + parts.length = 0; + hasImage = false; + }; + + for (const block of turn.content) { + if (block.type === "text") { + parts.push({ type: "input_text", text: block.text }); + } else if (block.type === "image") { + if (block.source.kind === "base64") { + hasImage = true; + parts.push({ + type: "input_image", + image_url: `data:${block.source.mimeType};base64,${block.source.data}`, + }); + } else if (block.source.kind === "url") { + hasImage = true; + parts.push({ type: "input_image", image_url: block.source.url }); + } else { + parts.push({ + type: "input_text", + text: `[Unsupported image reference omitted: ${block.source.reference}]`, + }); + } + } else if (block.type === "tool_call") { + flushMessage(); + items.push({ + type: "function_call", + name: block.name, + arguments: JSON.stringify(block.arguments ?? {}), + call_id: block.id, + }); + } else if (block.type === "tool_result") { + flushMessage(); + items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) }); + } else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) { + flushMessage(); + items.push({ type: "reasoning", summary: [], encrypted_content: block.signature }); + } + } + flushMessage(); + return items; +} + +function toResponsesTools(options: InferenceOptions): unknown[] | undefined { + if (options.tools === undefined || options.tools.length === 0) return undefined; + return options.tools.map((t) => ({ + type: "function", + name: t.name, + description: t.description, + parameters: t.inputSchema, + })); +} + +function dedupeToolOutputs(items: ResponsesInputItem[]): ResponsesInputItem[] { + const seen = new Set(); + const deduped: ResponsesInputItem[] = []; + for (const item of items) { + if (item.type === "function_call_output") { + if (seen.has(item.call_id)) continue; + seen.add(item.call_id); + } + deduped.push(item); + } + return deduped; +} + +function buildRequest( + messages: ConversationTurn[], + model: string, + options: InferenceOptions, +): BuiltRequest { + const conversation = dedupeToolOutputs(messages.flatMap(toResponsesItems)); + const systemMessage: ResponsesInputItem | undefined = + options.systemPrompt !== undefined + ? { type: "message", role: "system", content: options.systemPrompt } + : undefined; + const input = systemMessage !== undefined ? [systemMessage, ...conversation] : conversation; + const tools = toResponsesTools(options); + + const body: Record = { + model, + input, + store: false, + stream: true, + include: ["reasoning.encrypted_content"], + reasoning: { summary: "auto" }, + }; + if (tools !== undefined) { + body["tools"] = tools; + body["tool_choice"] = "auto"; + } + if (options.maxTokens !== undefined) body["max_output_tokens"] = options.maxTokens; + if (options.temperature !== undefined) body["temperature"] = options.temperature; + + return { + url: "/responses", + headers: { + "content-type": "application/json", + accept: "text/event-stream", + authorization: BEARER_CREDENTIAL_SENTINEL, + }, + body: JSON.stringify(body), + }; +} + +export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter { + const indexer = createResponsesBlockIndexer(); + return { + buildRequest, + parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER), + isStreamTerminal: isResponsesStreamTerminal, + }; +} diff --git a/src/subagent/inference-auth-failure.ts b/src/subagent/inference-auth-failure.ts index bfbb6fff3..d505277b6 100644 --- a/src/subagent/inference-auth-failure.ts +++ b/src/subagent/inference-auth-failure.ts @@ -26,6 +26,6 @@ export function formatSubAgentTaskAuthFailureMessage( // No "Error:" prefix — SessionStore.fail and tool-result surfaces add their own. return ( `sub-agent "${description}" could not run inference (${providerLabel} profile "${profile}"). ` + - `${detailSentence} Re-authenticate the profile (login modal or /login) and retry the task.` + `${detailSentence} Re-authenticate the profile from /model (c or Ctrl+A to Connect) and retry the task.` ); } \ No newline at end of file diff --git a/src/tui/app.tsx b/src/tui/app.tsx index c81775fa7..3355321df 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -56,6 +56,11 @@ import { copyTargets, transcriptMarkdown, type CopyTarget } from "./copy.js"; import { useProviderManager } from "./hooks/use-provider-manager.js"; import { fetchCodexUsage, formatCodexUsage } from "../auth/codex/usage.js"; import { fetchXaiUsage, formatXaiUsage } from "../auth/xai/usage.js"; +import { + fetchGoUsage, + formatGoUsage, + OPENCODE_GO_PROVIDER_ID, +} from "../../packages/opencode-go/src/index.js"; import { useLayoutGeometry } from "./hooks/use-layout-geometry.js"; import { listCommands } from "./commands/registry.js"; import type { AgentProfile } from "../agent/profiles.js"; @@ -349,6 +354,7 @@ export function App({ const [enteredSessionId, setEnteredSessionId] = useState(null); const [agentModalOpen, setAgentModalOpen] = useState(false); const [agentModalUsage, setAgentModalUsage] = useState(null); + const [goSubscriptionLabel, setGoSubscriptionLabel] = useState(undefined); const [permissionsOpen, setPermissionsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); // Mount-only seeds from runner props. Runner does not re-render App when these @@ -445,6 +451,32 @@ export function App({ setConfiguredTiers(tiers); }, [tiers]); + // OpenCode Go subscription usage in the status bar when Go is the active + // 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) { + setGoSubscriptionLabel(undefined); + return; + } + const entry = providerCatalog.find((p) => p.name === provider); + const apiKey = entry?.apiKey; + if (apiKey === undefined || apiKey.length === 0) { + setGoSubscriptionLabel(undefined); + return; + } + const controller = new AbortController(); + void fetchGoUsage(apiKey, { signal: controller.signal }).then((usage) => { + if (controller.signal.aborted) return; + if (usage.status === "ok") { + setGoSubscriptionLabel(formatGoUsage(usage)); + } else { + setGoSubscriptionLabel(undefined); + } + }); + return () => controller.abort(); + }, [provider, providerCatalog]); + const { unauthedProviders, setUnauthedProviders, @@ -1278,7 +1310,6 @@ if (workPrimary && !wasWorkPrimary.current) { onClosePlugins={() => setPluginsOpen(false)} cwd={cwd} loginModal={loginModal} - onSelectLoginProvider={(provider) => setLoginModal(provider)} onCloseLoginModal={() => { setLoginModal(null); setAutoLoginProfile(undefined); }} xaiProfileNames={xaiProfileNames} codexProfileNames={codexProfileNames} @@ -1405,6 +1436,7 @@ if (workPrimary && !wasWorkPrimary.current) { cwd={cwd} gitBranch={gitBranch} columns={columns} + {...(goSubscriptionLabel !== undefined ? { subscriptionLabel: goSubscriptionLabel } : {})} {...formatStatusBarSegments(getCostSummary())} /> diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 876d58d86..b239003ad 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -33,6 +33,10 @@ describe("removed commands", () => { it("/scope is not registered (slash commands live under enabled command plugins, e.g. a namespaced /ns sub)", () => { expect(getCommand("scope")).toBeUndefined(); }); + + it("/login is not registered (connect from /model)", () => { + expect(getCommand("login")).toBeUndefined(); + }); }); describe("removed approval command", () => { diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 1a82ba0e0..63a79fc7b 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -119,24 +119,9 @@ registerCommand({ handler: (_args, _ctx) => ({ type: "overlay", overlay: "plugins" }), }); -registerCommand({ - name: "login", - description: "Sign in with Codex or xAI OAuth and manage profiles", - subcommands: [ - { name: "codex", description: "Sign in with a ChatGPT Plus/Pro subscription" }, - { name: "xai", description: "Sign in with a SuperGrok or X Premium+ subscription" }, - { name: "grok", description: "Alias for xai" }, - ], - handler: (args, _ctx) => { - // With no argument, the bare command opens a provider picker so the user - // can choose Codex or xAI before naming the profile. An explicit argument - // skips the picker and goes straight to that provider's login modal. - const provider = args.trim().toLowerCase(); - if (provider === "") return { type: "modal", modal: "login" }; - if (provider === "xai" || provider === "grok") return { type: "modal", modal: "xai-login" }; - return { type: "modal", modal: "codex-login" }; - }, -}); +// Models-first connect: providers are connected from /model (Ctrl+A / c), not a +// standalone /login picker. Keep codex/xai login modals reachable only via +// Connect or re-auth on an expired profile. // signalClear rotates to a fresh session: the on-screen transcript and run // telemetry are reset and the agent is rebuilt against a new state directory, diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index 9a3a8012e..47c8038e6 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -28,7 +28,7 @@ export type CommandResult = | { type: "send"; text: string } | { type: "view"; view: "tasks" } | { type: "overlay"; overlay: "help" | "permissions" | "plugins" | "settings" } - | { type: "modal"; modal: "agent" | "codex-login" | "xai-login" | "login" } + | { type: "modal"; modal: "agent" | "codex-login" | "xai-login" } | { type: "workflow"; name: string; args?: string } | { type: "paste-image" } | { type: "tier"; tier: ProviderTier } diff --git a/src/tui/components/agent-modal.test.ts b/src/tui/components/agent-modal.test.ts index c503c092a..75e08a3cb 100644 --- a/src/tui/components/agent-modal.test.ts +++ b/src/tui/components/agent-modal.test.ts @@ -1,5 +1,11 @@ import { describe, test, expect } from "bun:test"; -import { toAgentProviders, validateProviderForm, type ProviderFormValues } from "./agent-modal.js"; +import { + seedConnectForm, + toAgentProviders, + validateProviderForm, + type ProviderFormValues, +} from "./agent-modal.js"; +import { FIRST_CLASS_PROVIDERS } from "../../../packages/first-class-providers/src/index.js"; describe("toAgentProviders", () => { test("projects editable provider fields and drops API keys", () => { @@ -89,7 +95,7 @@ describe("validateProviderForm", () => { } }); - test("trims leading and trailing spaces on text fields at save", () => { +test("trims leading and trailing spaces on text fields at save", () => { const result = validateProviderForm( form({ name: " firepass ", @@ -111,4 +117,109 @@ describe("validateProviderForm", () => { }, }); }); + + test("persists anthropic and opencodeGo flags from connect extras", () => { + const anthropic = validateProviderForm(form({ name: "anthropic" }), undefined, { + anthropic: true, + }); + expect(anthropic.ok).toBe(true); + if (anthropic.ok) { + expect(anthropic.submission.anthropic).toBe(true); + expect(anthropic.submission.opencodeGo).toBeUndefined(); + } + + const go = validateProviderForm( + form({ name: "opencode-go", apiKey: "sk-go-longenough" }), + undefined, + { opencodeGo: true }, + ); + expect(go.ok).toBe(true); + if (go.ok) { + expect(go.submission.opencodeGo).toBe(true); + } + }); + + test("edit submission re-asserts protocol flags from provider extras", () => { + // Mirrors enterEditForm seeding connectDraft from AgentProvider flags. + const goEdit = validateProviderForm( + form({ + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + apiKey: "", + models: "kimi-k2.7-code, minimax-m3", + defaultModel: "kimi-k2.7-code", + }), + "opencode-go", + { opencodeGo: true }, + ); + expect(goEdit.ok).toBe(true); + if (goEdit.ok) { + expect(goEdit.submission.originalName).toBe("opencode-go"); + expect(goEdit.submission.opencodeGo).toBe(true); + } + + const anthropicEdit = validateProviderForm( + form({ name: "anthropic", baseURL: "https://api.anthropic.com" }), + "anthropic", + { anthropic: true }, + ); + expect(anthropicEdit.ok).toBe(true); + if (anthropicEdit.ok) { + expect(anthropicEdit.submission.anthropic).toBe(true); + } + }); + + test("rejects invalid OpenCode Go API keys when opencodeGo is set", () => { + expect( + validateProviderForm(form({ name: "opencode-go", apiKey: "short" }), undefined, { + opencodeGo: true, + }), + ).toEqual({ ok: false, error: "API key looks too short" }); + + expect( + validateProviderForm(form({ name: "opencode-go", apiKey: "has space" }), undefined, { + opencodeGo: true, + }), + ).toEqual({ ok: false, error: "API key must not contain whitespace" }); + }); +}); + +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"); + + test("creates a new provider submission path when catalog is empty", () => { + const seed = seedConnectForm(goDef, undefined); + expect(seed.editingProvider).toBeUndefined(); + expect(seed.formValues.name).toBe("opencode-go"); + expect(seed.connectDraft.opencodeGo).toBe(true); + expect(seed.formValues.apiKey).toBe(""); + }); + + test("treats re-Connect as edit so save upserts instead of name-conflict", () => { + const seed = seedConnectForm(goDef, { + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + models: ["kimi-k2.7-code"], + defaultModel: "kimi-k2.7-code", + opencodeGo: true, + }); + 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"); + expect(seed.formValues.apiKey).toBe(""); + + const validated = validateProviderForm( + { ...seed.formValues, apiKey: "sk-go-rotated-key-long" }, + seed.editingProvider, + seed.connectDraft, + ); + expect(validated.ok).toBe(true); + if (validated.ok) { + expect(validated.submission.originalName).toBe("opencode-go"); + expect(validated.submission.opencodeGo).toBe(true); + expect(validated.submission.apiKey).toBe("sk-go-rotated-key-long"); + } + }); }); diff --git a/src/tui/components/agent-modal.tsx b/src/tui/components/agent-modal.tsx index 5fce4c04f..e4a487335 100644 --- a/src/tui/components/agent-modal.tsx +++ b/src/tui/components/agent-modal.tsx @@ -1,6 +1,6 @@ import { Box, Text, useInput } from "ink"; import type { ReactNode } from "react"; -import { useState } from "react"; +import { 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"; @@ -14,6 +14,11 @@ import { formContentWidth, wrapHelpSegments, } from "./form-reflow.js"; +import { + FIRST_CLASS_PROVIDERS, + type FirstClassProviderDef, + validateGoApiKey, +} from "../../../packages/first-class-providers/src/index.js"; // Effort display: undefined means "no override" (field omitted); "none" is // OpenAI's explicit disable-reasoning value. Both read as "off". @@ -51,6 +56,8 @@ export type AgentProvider = { codexProfile?: string; xaiProfile?: string; bifrostVirtualKey?: boolean; + anthropic?: boolean; + opencodeGo?: boolean; }; export type { ProviderSubmission, ProviderSubmission as ProviderFormSubmission }; @@ -59,6 +66,7 @@ export type ProviderFormField = "name" | "baseURL" | "keyless" | "apiKey" | "mod export type ProviderFormValues = Record; type Step = | "provider" + | "connect" | "model" | "effort" | "form" @@ -103,6 +111,8 @@ export function toAgentProviders( codexProfile?: string; xaiProfile?: string; bifrostVirtualKey?: boolean; + anthropic?: boolean; + opencodeGo?: boolean; }>, ): AgentProvider[] { return entries.map((p) => ({ @@ -114,6 +124,8 @@ export function toAgentProviders( ...(p.codexProfile !== undefined ? { codexProfile: p.codexProfile } : {}), ...(p.xaiProfile !== undefined ? { xaiProfile: p.xaiProfile } : {}), ...(p.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), + ...(p.anthropic === true ? { anthropic: true } : {}), + ...(p.opencodeGo === true ? { opencodeGo: true } : {}), })); } @@ -161,6 +173,49 @@ 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". */ +export function seedConnectForm( + def: FirstClassProviderDef, + existing: AgentProvider | undefined, +): { + editingProvider: string | undefined; + formValues: ProviderFormValues; + connectDraft: { anthropic: boolean; opencodeGo: boolean }; +} { + const base: ProviderFormValues = { + name: def.id, + baseURL: def.baseURL ?? "", + keyless: "no", + apiKey: "", + 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; + return { + editingProvider: existing?.name, + formValues, + connectDraft: { + anthropic: def.anthropic === true || existing?.anthropic === true, + opencodeGo: def.id === "opencode-go" || existing?.opencodeGo === true, + }, + }; +} + function parseModels(raw: string): string[] { return raw .split(",") @@ -171,6 +226,7 @@ function parseModels(raw: string): string[] { export function validateProviderForm( values: ProviderFormValues, originalName: string | undefined, + extras?: { anthropic?: boolean; opencodeGo?: boolean }, ): { ok: true; submission: ProviderSubmission } | { ok: false; error: string } { const name = values.name.trim(); const baseURL = values.baseURL.trim(); @@ -188,6 +244,10 @@ export function validateProviderForm( if (defaultModel.length > 0 && !models.includes(defaultModel)) { return { ok: false, error: "Default model must be listed in models" }; } + if (extras?.opencodeGo === true && apiKey.length > 0) { + const goKey = validateGoApiKey(apiKey); + if (!goKey.ok) return { ok: false, error: goKey.error }; + } return { ok: true, @@ -199,6 +259,8 @@ export function validateProviderForm( ...(apiKey.length > 0 ? { apiKey } : {}), models, ...(defaultModel.length > 0 ? { defaultModel } : {}), + ...(extras?.anthropic === true ? { anthropic: true } : {}), + ...(extras?.opencodeGo === true ? { opencodeGo: true } : {}), }, }; } @@ -288,6 +350,8 @@ export function AgentModal({ const [profileFormValues, setProfileFormValues] = useState(() => initialProfileFormValues(undefined)); const [editingProfileId, setEditingProfileId] = useState(undefined); const [profileFormError, setProfileFormError] = useState(null); + const [connectIndex, setConnectIndex] = useState(0); + const connectDraft = useRef<{ anthropic: boolean; opencodeGo: boolean } | null>(null); const selectedProvider = providers[providerIndex]; const models = selectedProvider?.models ?? []; @@ -328,6 +392,7 @@ export function AgentModal({ }; const enterAddForm = (): void => { + connectDraft.current = null; setEditingProvider(undefined); setFormValues(initialFormValues(undefined)); setFormIndex(0); @@ -335,9 +400,37 @@ export function AgentModal({ setStep("form"); }; + const enterConnectStep = (): void => { + setConnectIndex(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 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 + setFormError(null); + setStep("form"); + }; + const enterEditForm = (): void => { const provider = providers[providerIndex]; if (provider === undefined) return; + // Preserve protocol flags across edit/re-key so Go/Anthropic routing survives. + connectDraft.current = { + anthropic: provider.anthropic === true, + opencodeGo: provider.opencodeGo === true, + }; setEditingProvider(provider.name); setFormValues(initialFormValues(provider)); setFormIndex(0); @@ -346,7 +439,17 @@ export function AgentModal({ }; const submitForm = (): void => { - const result = validateProviderForm(formValues, editingProvider); + const draft = connectDraft.current; + const result = validateProviderForm( + formValues, + editingProvider, + draft !== null + ? { + ...(draft.anthropic ? { anthropic: true } : {}), + ...(draft.opencodeGo ? { opencodeGo: true } : {}), + } + : undefined, + ); if (!result.ok) { setFormError(result.error); return; @@ -356,6 +459,7 @@ export function AgentModal({ setFormError(saved.error); return; } + connectDraft.current = null; if (pendingTierAssign !== null) { setStep("tiers"); } else { @@ -457,6 +561,14 @@ export function AgentModal({ enterAddForm(); return; } + if (key.ctrl && input === "a") { + enterConnectStep(); + return; + } + if (input === "c") { + enterConnectStep(); + return; + } if (input === "e") { enterEditForm(); return; @@ -478,6 +590,27 @@ export function AgentModal({ return; } + if (step === "connect") { + if (key.upArrow) { + setConnectIndex((i) => (i > 0 ? i - 1 : FIRST_CLASS_PROVIDERS.length - 1)); + return; + } + if (key.downArrow) { + setConnectIndex((i) => (i < FIRST_CLASS_PROVIDERS.length - 1 ? i + 1 : 0)); + return; + } + if (key.return) { + const def = FIRST_CLASS_PROVIDERS[connectIndex]; + if (def !== undefined) enterConnectForm(def); + return; + } + if (key.escape) { + setStep("provider"); + return; + } + return; + } + if (step === "profiles") { if (key.upArrow) { setProfileIndex((i) => (i > 0 ? i - 1 : profiles.length - 1)); @@ -792,7 +925,9 @@ export function AgentModal({ const helpText = ((): string | null => { switch (step) { case "provider": - return "Up/Down navigate · Enter models · a add · e edit · x remove · t tiers · p profiles · Esc close"; + return "Up/Down navigate · Enter models · c/Ctrl+A connect · a advanced · e edit · x remove · t tiers · p profiles · Esc close"; + case "connect": + return "Up/Down navigate · Enter connect · Esc back"; case "tiers": return "Up/Down navigate · Enter add · e edit chain · m mode · c clear · Esc back"; case "tier-chain": @@ -876,6 +1011,36 @@ const helpText = ((): string | null => { )} + {step === "connect" && ( + + Connect a first-class provider + {FIRST_CLASS_PROVIDERS.map((def, i) => { + const isCursor = i === connectIndex; + return ( + + + + {isCursor ? ">" : " "} + + + {def.label} + + + ({def.auth === "oauth" ? "OAuth" : "API key"}) + + + {isCursor && def.authHint !== undefined && ( + + {" "} + {def.authHint} + + )} + + ); + })} + + )} + {step === "tier-chain" && tierChainFocus !== null && ( diff --git a/src/tui/components/overlay-stack.tsx b/src/tui/components/overlay-stack.tsx index 64fe5c609..24cb95f63 100644 --- a/src/tui/components/overlay-stack.tsx +++ b/src/tui/components/overlay-stack.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from "react"; import { PermissionsManager } from "./permissions-manager.js"; import { SettingsOverlay, type CompactionMode } from "./settings-overlay.js"; import { PluginsManager, type PluginsAdmin } from "./plugins-manager.js"; -import { LoginProviderPicker } from "./login-provider-picker.js"; import { CodexLoginModal } from "./codex-login-modal.js"; import type { ScopedApproval } from "../../permission/admin.js"; import type { LoginModal } from "../hooks/use-provider-auth.js"; @@ -40,7 +39,6 @@ export type OverlayStackProps = { cwd: string; loginModal: LoginModal; - onSelectLoginProvider: (provider: LoginModal) => void; onCloseLoginModal: () => void; xaiProfileNames: string[]; codexProfileNames: string[]; @@ -92,12 +90,6 @@ export function OverlayStack(props: OverlayStackProps): ReactNode { {props.pluginsOpen && props.pluginsAdmin !== undefined && ( )} - {props.loginModal === "choose" && ( - - )} {(props.loginModal === "codex" || props.loginModal === "xai") && ( MCP > -// model/cwd/branch > cost > context. Only the cwd part is truncated — model -// and branch names stay intact; if the cwd cannot absorb the overflow the -// whole model/cwd/branch segment is dropped. +// (highest to lowest, dropped first when narrow): brand+timer > MCP > +// model/cwd/branch > subscription > cost > context. Only the cwd part is +// truncated — model and branch names stay intact; if the cwd cannot absorb +// the overflow the whole model/cwd/branch segment is dropped. export function planStatusBarLayout(args: StatusBarLayoutArgs): StatusBarLayout { let cwd = args.cwd; let segment = joinModelCwdBranch(args.model, cwd, args.gitBranch); + let showSubscription = args.subscriptionLabel !== undefined; let showCost = args.costLabel !== undefined; let showContext = args.contextLabel !== undefined; @@ -124,6 +130,7 @@ export function planStatusBarLayout(args: StatusBarLayoutArgs): StatusBarLayout BRAND, args.agentsText, segment, + showSubscription ? args.subscriptionLabel : undefined, showCost ? args.costLabel : undefined, showContext ? args.contextLabel : undefined, args.mcpText, @@ -131,6 +138,7 @@ export function planStatusBarLayout(args: StatusBarLayoutArgs): StatusBarLayout if (overflow() > 0 && showContext) showContext = false; if (overflow() > 0 && showCost) showCost = false; + if (overflow() > 0 && showSubscription) showSubscription = false; if (overflow() > 0 && segment !== undefined) { const cwdBudget = cwd !== undefined ? cwd.length - overflow() : 0; if (cwd !== undefined && cwdBudget >= MIN_TRUNCATED_CWD) { @@ -143,6 +151,7 @@ export function planStatusBarLayout(args: StatusBarLayoutArgs): StatusBarLayout return { ...(segment !== undefined ? { modelCwdBranchText: segment } : {}), + showSubscription, showCost, showContext, }; @@ -170,13 +179,14 @@ export function StatusBar({ cwd, gitBranch, columns, + subscriptionLabel, }: StatusBarProps): ReactNode { const agentsText = completedAgentsLabel !== undefined && completedAgentsLabel.length > 0 ? completedAgentsLabel : undefined; const mcpText = mcpCount > 0 ? `MCP ✓ ${mcpCount}` : undefined; - const { modelCwdBranchText, showCost, showContext } = planStatusBarLayout({ + const { modelCwdBranchText, showCost, showContext, showSubscription } = planStatusBarLayout({ columns: columns ?? 120, ...(agentsText !== undefined ? { agentsText } : {}), ...(mcpText !== undefined ? { mcpText } : {}), @@ -185,6 +195,7 @@ export function StatusBar({ ...(gitBranch != null ? { gitBranch } : {}), ...(costLabel !== undefined ? { costLabel } : {}), ...(contextLabel !== undefined ? { contextLabel } : {}), + ...(subscriptionLabel !== undefined ? { subscriptionLabel } : {}), }); const meterTone = contextMeterTone(contextPercentUsed); const meterRole = contextMeterRole(meterTone); @@ -200,6 +211,9 @@ export function StatusBar({ {modelCwdBranchText !== undefined && ( {modelCwdBranchText} )} + {showSubscription && subscriptionLabel !== undefined && ( + {subscriptionLabel} + )} {showCost && costLabel !== undefined && ( {costLabel} )} diff --git a/src/tui/hooks/use-command-dispatch.ts b/src/tui/hooks/use-command-dispatch.ts index a14537dbe..3f0c34186 100644 --- a/src/tui/hooks/use-command-dispatch.ts +++ b/src/tui/hooks/use-command-dispatch.ts @@ -156,9 +156,8 @@ export function useCommandDispatch({ setAgentModalUsage(null); } } - if (result.type === "modal" && (result.modal === "codex-login" || result.modal === "xai-login" || result.modal === "login")) { - if (result.modal === "login") setLoginModal("choose"); - else setLoginModal(result.modal === "xai-login" ? "xai" : "codex"); + if (result.type === "modal" && (result.modal === "codex-login" || result.modal === "xai-login")) { + setLoginModal(result.modal === "xai-login" ? "xai" : "codex"); } if (result.type === "paste-image") { handlePasteImage(); diff --git a/src/tui/hooks/use-provider-auth.ts b/src/tui/hooks/use-provider-auth.ts index 8ef5ea089..b4a5a1907 100644 --- a/src/tui/hooks/use-provider-auth.ts +++ b/src/tui/hooks/use-provider-auth.ts @@ -15,7 +15,7 @@ import { LOG_NAMESPACE_ROOT } from "../../branding.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "tui", "provider-auth"]); -export type LoginModal = "codex" | "xai" | "choose" | null; +export type LoginModal = "codex" | "xai" | null; export type UseProviderAuthArgs = { provider: string; @@ -88,8 +88,11 @@ export function useProviderAuth({ setAutoLoginProfile(codexName); setLoginModal("codex"); } else { + // API-key providers: no OAuth modal — surface the failure as a command message. setAutoLoginProfile(undefined); - setLoginModal("choose"); + setCommandMessage( + `Authentication failed for provider "${provider}". Reconnect from /model (c or Ctrl+A).`, + ); } }; diff --git a/src/tui/keymap-table.ts b/src/tui/keymap-table.ts index d6eec151e..5775306dc 100644 --- a/src/tui/keymap-table.ts +++ b/src/tui/keymap-table.ts @@ -26,6 +26,6 @@ export const SHORTCUTS: ShortcutEntry[] = [ export const SLASH_COMMANDS: ShortcutEntry[] = [ { keys: "/help", description: "Show the help overlay" }, { keys: "/tasks", description: "Show the tasks panel" }, - { keys: "/agent", description: "Configure provider, model, tiers, and profiles" }, + { keys: "/model", description: "Connect providers, pick model, tiers, profiles" }, { keys: "/permissions", description: "View and revoke remembered approvals" }, ]; diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 1b848d1aa..13006da08 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -129,6 +129,7 @@ test("loadSettings cannot silently drop a known optional key", async () => { web: "plug-a", hiddenCommands: ["/help"], onboarded: true, + lastChangelogVersion: "0.1.0", compactionMode: "pruning" as const, maxConcurrentSubAgents: 3, subagentMaxTurns: 20, @@ -137,6 +138,7 @@ test("loadSettings cannot silently drop a known optional key", async () => { shell: { timeoutMs: 1000, maxTimeoutMs: 5000 }, tools: { timeoutMs: 2000, waitForApproval: false }, telemetry: { enabled: false, installationId: "id", noticeShown: true }, + otel: { endpoint: "http://localhost:4318", serviceName: "corbits-test" }, }; await writeFile(globalPath, JSON.stringify(fixture)); const loaded = await loadSettings(globalPath); diff --git a/tests/unit/inference-sources.test.ts b/tests/unit/inference-sources.test.ts index a8fe8959a..06521db7f 100644 --- a/tests/unit/inference-sources.test.ts +++ b/tests/unit/inference-sources.test.ts @@ -72,7 +72,7 @@ test("prefer mode appends settings providers as fallback tail", () => { test("buildInferenceSourceForRef uses bifrost provider when flag set", () => { const source = buildInferenceSourceForRef( { provider: "bifrost", model: "gpt-4o" }, - { settings: { providers: {} }, catalog: [...catalog] }, + { sessionId: "s1", catalog: [...catalog] }, undefined, ); expect(source?.provider).toBe("bifrost"); @@ -87,7 +87,7 @@ test("buildInferenceSourceForRef applies tier leg reasoning effort", () => { }; const source = buildInferenceSourceForRef( { provider: "openai", model: "gpt-5", reasoningEffort: "high" }, - { settings, catalog: [...catalog] }, + { sessionId: "s1", catalog: [...catalog] }, settings, ); expect(source?.defaults?.providerOptions).toEqual({ reasoning_effort: "high" }); @@ -150,6 +150,66 @@ test("formatTierChain shows reasoning effort on legs", () => { expect(label).toContain("gpt-5@high"); }); +test("buildInferenceSourceForRef routes OpenCode Go models by protocol", () => { + const goCatalog: ProviderCatalogEntry[] = [ + { + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + apiKey: "sk-go-test-key", + models: ["kimi-k2.7-code", "gpt-5.6-luna", "minimax-m3"], + defaultModel: "kimi-k2.7-code", + opencodeGo: true, + }, + ]; + const ctx = { sessionId: "go", catalog: goCatalog }; + + const chat = buildInferenceSourceForRef( + { provider: "opencode-go", model: "kimi-k2.7-code" }, + ctx, + undefined, + ); + expect(chat?.provider).toBe("openai-compatible"); + expect(chat?.baseURL).toBe("https://opencode.ai/zen/go/v1"); + expect(chat?.model).toBe("kimi-k2.7-code"); + + const responses = buildInferenceSourceForRef( + { provider: "opencode-go", model: "gpt-5.6-luna" }, + ctx, + undefined, + ); + expect(responses?.provider).toBe("openai-responses"); + expect(responses?.baseURL).toBe("https://opencode.ai/zen/go/v1"); + + const messages = buildInferenceSourceForRef( + { provider: "opencode-go", model: "minimax-m3" }, + ctx, + undefined, + ); + expect(messages?.provider).toBe("anthropic"); + expect(messages?.baseURL).toBe("https://opencode.ai/zen/go"); + expect(messages?.model).toBe("minimax-m3"); +}); + +test("buildInferenceSourceForRef uses anthropic provider when flag set", () => { + const anthropicCatalog: ProviderCatalogEntry[] = [ + { + name: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "sk-ant-test", + models: ["claude-sonnet-4-5"], + defaultModel: "claude-sonnet-4-5", + anthropic: true, + }, + ]; + const source = buildInferenceSourceForRef( + { provider: "anthropic", model: "claude-sonnet-4-5" }, + { sessionId: "a", catalog: anthropicCatalog }, + undefined, + ); + expect(source?.provider).toBe("anthropic"); + expect(source?.baseURL).toBe("https://api.anthropic.com"); +}); + test("removeTierLeg and moveTierLeg edit the chain", () => { const def = { mode: "prefer" as const, order: [{ provider: "a", model: "1" }, { provider: "b", model: "2" }] }; const one = removeTierLeg(def, 0); diff --git a/tests/unit/provider-protocol-flags.test.ts b/tests/unit/provider-protocol-flags.test.ts new file mode 100644 index 000000000..082e721c9 --- /dev/null +++ b/tests/unit/provider-protocol-flags.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { buildProviderEntry } from "../../src/config/providers.js"; +import type { ProviderCatalogEntry } from "../../src/config/index.js"; + +const goEntry = (): ProviderCatalogEntry => ({ + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + apiKey: "sk-go-longenough", + models: ["kimi-k2.7-code", "minimax-m3"], + defaultModel: "kimi-k2.7-code", + opencodeGo: true, +}); + +const anthropicEntry = (): ProviderCatalogEntry => ({ + name: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "sk-ant-longenough", + models: ["claude-sonnet-4"], + defaultModel: "claude-sonnet-4", + anthropic: true, +}); + +describe("buildProviderEntry protocol flag preservation", () => { + test("preserves opencodeGo when editing without resubmitting the flag", () => { + const result = buildProviderEntry( + { + originalName: "opencode-go", + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + models: ["kimi-k2.7-code", "minimax-m3"], + defaultModel: "kimi-k2.7-code", + }, + [goEntry()], + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.opencodeGo).toBe(true); + }); + + test("preserves anthropic when editing without resubmitting the flag", () => { + const result = buildProviderEntry( + { + originalName: "anthropic", + name: "anthropic", + baseURL: "https://api.anthropic.com", + models: ["claude-sonnet-4"], + }, + [anthropicEntry()], + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.anthropic).toBe(true); + }); + + test("does not invent protocol flags for plain provider edits", () => { + const result = buildProviderEntry( + { + originalName: "openai", + name: "openai", + baseURL: "https://api.openai.com/v1", + models: ["gpt-4o"], + }, + [ + { + name: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "sk-oai", + models: ["gpt-4o"], + }, + ], + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.anthropic).toBeUndefined(); + expect(result.entry.opencodeGo).toBeUndefined(); + }); + + test("honors explicit connect flags on create", () => { + const result = buildProviderEntry( + { + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + apiKey: "sk-go-longenough", + models: ["kimi-k2.7-code"], + opencodeGo: true, + }, + [], + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.opencodeGo).toBe(true); + }); + + test("empty-key re-Connect preserves existing apiKey", () => { + // Re-Connect / edit without re-entering the key must keep the catalog secret. + const result = buildProviderEntry( + { + originalName: "opencode-go", + name: "opencode-go", + baseURL: "https://opencode.ai/zen/go/v1", + models: ["kimi-k2.7-code", "minimax-m3"], + defaultModel: "kimi-k2.7-code", + opencodeGo: true, + }, + [goEntry()], + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entry.apiKey).toBe("sk-go-longenough"); + expect(result.entry.opencodeGo).toBe(true); + }); +}); diff --git a/tests/unit/tui/status-bar.test.tsx b/tests/unit/tui/status-bar.test.tsx index 1b075bec3..78d9f37ea 100644 --- a/tests/unit/tui/status-bar.test.tsx +++ b/tests/unit/tui/status-bar.test.tsx @@ -169,6 +169,7 @@ test("planStatusBarLayout keeps everything when it fits", () => { modelCwdBranchText: "gpt-5 · ~/repo · main", showCost: true, showContext: true, + showSubscription: false, }); }); diff --git a/tsconfig.json b/tsconfig.json index fc7b7058f..6a2020297 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,15 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "noEmit": false, + "noEmit": true, "composite": false, - "outDir": "dist", - "rootDir": "src", "jsx": "react-jsx", "jsxImportSource": "react", - "paths": {} + "baseUrl": ".", + "paths": { + "@corbits/provider-opencode-go": ["./packages/opencode-go/src/index.ts"], + "@corbits/first-class-providers": ["./packages/first-class-providers/src/index.ts"] + } }, - "include": ["src/**/*.ts", "src/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx", "packages/**/*.ts"] }