diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62b2f42..18f00a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ If a command is unavailable on your platform, state exactly what was not run in Update documentation in the same pull request when behavior changes: -- `docs/wiki/` pages for user-visible behavior: `Feature-Guide.md`, `Command-Reference.md`, `Configuration.md`, `Updating.md`, `Troubleshooting.md` +- `docs/wiki/` pages for user-visible behavior: `Feature-Guide.md`, `Providers.md`, `Command-Reference.md`, `Configuration.md`, `Updating.md`, `Troubleshooting.md` - `README.md` when the quick start or installation flow is affected - `docs/wiki/Architecture-Overview.md` for module, storage, or data-flow changes - `docs/wiki/Developer-Onboarding.md` for engineering workflow changes diff --git a/README.md b/README.md index ce50893..c38c114 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ codex-switch launch # start Codex with the best account ## What it does - Saves, imports, renames, switches, and recoverably deletes Codex profiles. +- Saves custom API providers (OpenRouter and other Responses-compatible endpoints) and launches Codex with them without writing to `~/.codex`: + + ```bash + codex-switch provider add openrouter \ + --base-url https://openrouter.ai/api/v1 \ + --model openai/gpt-5.3-codex + codex-switch launch openrouter + ``` - Displays the main and model-specific quota pools in CLI and TUI views. - Selects an eligible account with adaptive, pace-aware scoring, and launches Codex with it. - Supports reset cards, quota warmup, JSON output, proxies, and a Beta background daemon (LaunchAgent, systemd, or Windows Task Scheduler; tune `cache_refresh_interval_secs` and `auto_warmup`). @@ -55,7 +63,7 @@ codex-switch launch # start Codex with the best account ## Documentation -The **[GitHub Wiki](https://github.com/xjoker/codex-switch/wiki)** is the complete documentation — getting started, feature guide, command reference, configuration, updating and channels, troubleshooting, FAQ, and the contributor guides (architecture, onboarding). Its sources live in [`docs/wiki/`](docs/wiki) and are reviewed with the code. +The **[GitHub Wiki](https://github.com/xjoker/codex-switch/wiki)** is the complete documentation — getting started, feature guide, custom API providers, command reference, configuration, updating and channels, troubleshooting, FAQ, and the contributor guides (architecture, onboarding). Its sources live in [`docs/wiki/`](docs/wiki) and are reviewed with the code. Maintainer documents: [release process](docs/RELEASE.md) · [changelog](docs/CHANGELOG.md) · [contributing](CONTRIBUTING.md). diff --git a/README_CN.md b/README_CN.md index abb02ec..dce907b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -44,6 +44,14 @@ codex-switch launch # 用最佳账号启动 Codex ## 功能一览 - 保存、导入、重命名、切换和可恢复地删除 Codex 账号。 +- 保存自定义 API 提供方(OpenRouter 等兼容 Responses 协议的接口),并通过 `launch` 启动 Codex,不写入 `~/.codex`: + + ```bash + codex-switch provider add openrouter \ + --base-url https://openrouter.ai/api/v1 \ + --model openai/gpt-5.3-codex + codex-switch launch openrouter + ``` - CLI 与 TUI 展示主额度池和每个模型的独立额度池。 - 自适应配速感知评分自动选号,并可直接用它启动 Codex。 - 支持重置卡、配额预热、JSON 输出、代理,以及 Beta 后台守护进程(macOS LaunchAgent / Linux systemd / Windows 任务计划程序 Task Scheduler;可调 `cache_refresh_interval_secs` 与 `auto_warmup`)。 @@ -55,7 +63,7 @@ codex-switch launch # 用最佳账号启动 Codex ## 文档 -**[GitHub Wiki](https://github.com/xjoker/codex-switch/wiki)** 是完整文档:开始使用、功能指南、命令参考、配置、更新与通道、故障排查、FAQ 以及贡献者指南。中文读者从 [中文指南](https://github.com/xjoker/codex-switch/wiki/Chinese-Guide) 开始;行为细节以英文页面为准。 +**[GitHub Wiki](https://github.com/xjoker/codex-switch/wiki)** 是完整文档:开始使用、功能指南、自定义 API 提供方、命令参考、配置、更新与通道、故障排查、FAQ 以及贡献者指南。中文读者从 [中文指南](https://github.com/xjoker/codex-switch/wiki/Chinese-Guide) 开始;行为细节以英文页面为准。 维护者文档:[发布流程](docs/RELEASE.md) · [更新日志](docs/CHANGELOG.md) · [贡献指南](CONTRIBUTING.md)。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 746eae8..c24c20d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **Custom API providers** — Save an OpenRouter-style endpoint under `$CODEX_SWITCH_HOME/providers/` and start Codex with `codex-switch launch `. The API key is read from a hidden prompt or `--api-key-stdin`, stored mode `0600`, and injected into the child environment; it never appears on the command line, and `$CODEX_HOME` is not written. `use` and auto-select stay ChatGPT-only. The TUI adds a Providers tab (`Tab` to switch; `a` / `d` to add or remove). Codex currently speaks only `wire_api = "responses"`, so Chat Completions-only vendors (including DeepSeek's official API) must be reached through a Responses-capable gateway. See [Custom API providers](wiki/Providers.md). + ## v20260811.3.0 — 2026-08-11 - **Reset Card details refresh without blocking the account table** — Main Usage results render immediately while card details refresh in a serialized background queue. The Cards column shows a cyan refresh marker during work and a yellow waiting marker during HTTP 429 cooldown, while preserving the last known unexpired cards. diff --git a/docs/README.md b/docs/README.md index 57a7250..aead8aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Reader-facing documentation lives in [`docs/wiki/`](wiki) and is published autom |---|---|---| | New user | [Getting started](wiki/Getting-Started.md) | [Feature guide](wiki/Feature-Guide.md) | | Operator | [Configuration](wiki/Configuration.md) | [Troubleshooting](wiki/Troubleshooting.md) | +| Custom API / OpenRouter | [Custom API providers](wiki/Providers.md) | [Command reference](wiki/Command-Reference.md) | | Contributor | [Contributing](../CONTRIBUTING.md) | [Developer onboarding](wiki/Developer-Onboarding.md) | | Maintainer | [Architecture overview](wiki/Architecture-Overview.md) | [Release process](RELEASE.md) | | Release reader | [Changelog](CHANGELOG.md) | [GitHub Releases](https://github.com/xjoker/codex-switch/releases) | diff --git a/docs/wiki/Architecture-Overview.md b/docs/wiki/Architecture-Overview.md index 145c519..77ede97 100644 --- a/docs/wiki/Architecture-Overview.md +++ b/docs/wiki/Architecture-Overview.md @@ -1,6 +1,6 @@ # Architecture overview -`codex-switch` is a single Rust binary. It owns saved profile state under `CODEX_SWITCH_HOME` and coordinates access to the live Codex authentication file under `CODEX_HOME`. +`codex-switch` is a single Rust binary. It owns saved profile and custom-provider state under `CODEX_SWITCH_HOME` and coordinates access to the live Codex authentication file under `CODEX_HOME`. ## System boundaries @@ -9,17 +9,21 @@ flowchart LR User[CLI or TUI user] --> Dispatch[Command dispatch] Service[Platform service manager] --> Daemon[Background daemon] Dispatch --> Profiles[Profile and lock layer] + Dispatch --> Providers[Custom API providers] Dispatch --> Usage[Usage, refresh, models, reset cards] Dispatch --> Login[OAuth login] Dispatch --> Update[Self-update] Daemon --> Profiles Daemon --> Usage Profiles <--> CSHome[CODEX_SWITCH_HOME] + Providers --> CSHome + Providers --> CodexLaunch[Codex CLI -c overlay] Profiles <--> CodexAuth[CODEX_HOME/auth.json] Usage --> OpenAI[Authenticated OpenAI services] Login --> OpenAI Update --> Releases[GitHub Releases] Codex[Codex CLI] --> CodexAuth + CodexLaunch --> Codex ``` The application treats local files, command-line input, environment variables, OAuth callbacks, HTTP responses, and release assets as trust boundaries. Internal module calls rely on Rust types and established invariants. @@ -41,6 +45,14 @@ Configuration is loaded once from `config.toml`. An existing unreadable or inval Profile identity prefers `account_id` and falls back to email when required for locally authenticated operations. Imports are intentionally create-only: Usage API access proves workspace membership, but a Team workspace ID can belong to several users and cannot authorize overwriting an existing profile. Tokens refreshed while a profile is active are written to both the saved profile and the live auth file under the same switching discipline. A rotated import that loses verifiable identity is written under `recovery/`, outside the selectable profile tree. +## Custom API providers + +[`src/provider.rs`](https://github.com/xjoker/codex-switch/blob/dev/src/provider.rs) owns third-party API provider profiles (OpenRouter and other Responses-compatible endpoints). Each profile is a TOML file under `$CODEX_SWITCH_HOME/providers//provider.toml` (directory `0700`, file `0600`). It carries a Codex `model_providers.` definition plus a bearer key; it has no `auth.json`. + +[`src/commands/launch.rs`](https://github.com/xjoker/codex-switch/blob/dev/src/commands/launch.rs) takes a separate path when the named alias is a provider: it does not stage `$CODEX_HOME/auth.json`. The profile is translated into `codex -c …` overrides that define and select the provider, and the key is injected into the child process environment under `env_key` — never onto the command line. Because `-c` layers on the user's base `$CODEX_HOME/config.toml`, MCP servers and other Codex settings survive. Auto-select (`launch` with no alias) and `use` stay ChatGPT-only. + +The TUI isolates the two kinds of profile on separate tabs so quota/scoring bindings never mix with provider add/remove. See [Custom API providers](Providers). + ## Usage, refresh, and selection The [`src/usage/`](https://github.com/xjoker/codex-switch/tree/dev/src/usage) module is split by responsibility: @@ -59,7 +71,7 @@ Selection has two phases. Eligibility excludes candidates with missing authorita ## TUI and output contracts -[`src/tui/`](https://github.com/xjoker/codex-switch/tree/dev/src/tui) separates application state, key bindings, menus, popups, and rendering. Network or filesystem actions suspend or update the terminal deliberately rather than running inside rendering functions. +[`src/tui/`](https://github.com/xjoker/codex-switch/tree/dev/src/tui) separates application state, key bindings, menus, popups, and rendering. Network or filesystem actions suspend or update the terminal deliberately rather than running inside rendering functions. Accounts and custom providers occupy separate tabs so quota/scoring keys never mix with provider add/remove. [`src/output.rs`](https://github.com/xjoker/codex-switch/blob/dev/src/output.rs) owns JSON response types and human formatting. In JSON mode stdout must contain only structured output; human diagnostics and progress are routed to stderr. This separation is part of the automation contract and is covered by integration tests. @@ -86,6 +98,7 @@ PID-file cleanup verifies lock ownership before removal. Removing a path while a | `$CODEX_HOME/auth.json` | Live authentication read by Codex CLI | | `$CODEX_HOME/config.toml` | Codex configuration, including file-store requirement | | `$CODEX_SWITCH_HOME/profiles//auth.json` | Saved account credentials | +| `$CODEX_SWITCH_HOME/providers//provider.toml` | Custom API provider definition and key | | `$CODEX_SWITCH_HOME/current` | Current alias marker | | `$CODEX_SWITCH_HOME/deleted-profiles/` | Recoverable profile archives | | `$CODEX_SWITCH_HOME/cache.json` | Usage, workspace metadata, and rejected-credential cache | @@ -106,3 +119,4 @@ Release artifacts are built only by GitHub Actions for six platform/architecture - Set up the repository with [Developer onboarding](Developer-Onboarding). - Review test and pull-request requirements in [Contributing](Contributing). +- Custom API provider storage and launch overlay: [Custom API providers](Providers). diff --git a/docs/wiki/Chinese-Guide.md b/docs/wiki/Chinese-Guide.md index 602161a..b56da5c 100644 --- a/docs/wiki/Chinese-Guide.md +++ b/docs/wiki/Chinese-Guide.md @@ -43,6 +43,7 @@ codex-switch tui - [开始使用](Getting-Started) — 安装、登录和首次启动 - [功能指南](Feature-Guide) — 主要工作流与安全边界 +- [自定义 API 提供方](Providers) — OpenRouter 等第三方接口(英文页) - [命令参考](Command-Reference) — 全部命令、全局选项和 TUI 快捷键 - [配置](Configuration) — 路径、代理、daemon 与 launch 设置 - [更新](Updating) — 更新方式、通道切换和旧版本迁移 diff --git a/docs/wiki/Command-Reference.md b/docs/wiki/Command-Reference.md index f7e1f7f..91e98e6 100644 --- a/docs/wiki/Command-Reference.md +++ b/docs/wiki/Command-Reference.md @@ -10,7 +10,11 @@ The installed binary remains authoritative: use `codex-switch --help` and `codex | `import [alias]` | Validate and import one `auth.json`, or recursively scan a directory for JSON files. The alias applies to single-file imports only; directories auto-assign aliases. An account that is already saved (same file, or same `account_id` and email) is skipped instead of duplicated, so its single-use refresh token is not spent. | | `list [-f]` | Show profiles, usage, and availability; `-f` / `--force` bypasses the cache. | | `use [alias] [--consume-card]` | Switch explicitly, or omit the alias to auto-select with the unified scoring algorithm. When the pool is exhausted, `--consume-card` consumes the earliest-expiring reset card to revive an account (auto-select only; ignored when an alias is given). | -| `launch [alias] [--consume-card] -- [args]` | Start Codex with the best (or specified) profile's auth. Everything after `--` is passed through to Codex. | +| `launch [alias] [--consume-card] -- [args]` | Start Codex with the best (or specified) ChatGPT profile's auth, or with a custom API provider when `alias` names one. Everything after `--` is passed through to Codex. Auto-select (no alias) is ChatGPT-only. | +| `provider add --base-url --model ` | Save a custom API provider. The API key is read from a hidden prompt, or from stdin with `--api-key-stdin` — never from argv. | +| `provider list` | List saved providers (no keys). | +| `provider show ` | Show one provider; the key is redacted. | +| `provider remove [-y]` | Delete a provider and its stored key; `-y` / `--yes` skips the prompt. Non-interactive and `--json` runs require `--yes`. | | `reset-card [-y]` | Consume the earliest-expiring reset card for a profile after confirmation; `-y` / `--yes` skips the prompt. | | `warmup [alias]` | Send a minimal request to activate the quota-window countdown for one or all profiles. | | `rename ` | Rename a saved profile. | @@ -28,7 +32,7 @@ The installed binary remains authoritative: use `codex-switch --help` and `codex | Option | Environment variable | Behavior | |---|---|---| -| `--json` | — | Compact structured output (supported by `list`, `use`, `reset-card`, `rename`, `delete`, `login`, `import`, `self-update`, `daemon status`). | +| `--json` | — | Compact structured output (supported by `list`, `use`, `reset-card`, `rename`, `delete`, `login`, `import`, `self-update`, `daemon status`, `provider add`, `provider list`, `provider show`, `provider remove`). | | `--json-pretty` | — | Indented structured output. | | `--proxy ` | `CS_PROXY` | Override proxy configuration for this process; supports `http(s)://`, `socks4://`, `socks5://`, and `socks5h://` (remote DNS). | | `--color ` | `CS_COLOR` | Control terminal color. `NO_COLOR` disables color regardless of this option. | @@ -39,8 +43,8 @@ The installed binary remains authoritative: use `codex-switch --help` and `codex - Structured data is written to stdout; progress and diagnostics are written to stderr. - JSON and other non-interactive execution never consumes a reset card or deletes a profile without an explicit opt-in flag. -- `launch` treats everything after `--` as Codex CLI arguments. -- A manual `use` affects the next Codex process. Restart an already-running Codex process to load the new `auth.json`. +- `launch` treats everything after `--` as Codex CLI arguments. When `alias` names a custom provider, Codex is started with `-c` overrides and the key in the child environment; `$CODEX_HOME/auth.json` is not swapped. +- A manual `use` affects the next Codex process and accepts ChatGPT profile aliases only. Restart an already-running Codex process to load the new `auth.json`. - Update checks are manual except for the one check performed when the TUI starts. Examples: @@ -49,11 +53,25 @@ Examples: codex-switch --json list codex-switch --json use work codex-switch launch work -- --model gpt-5.4 +codex-switch provider add openrouter --base-url https://openrouter.ai/api/v1 --model openai/gpt-5.3-codex +codex-switch launch openrouter codex-switch self-update --check ``` +## Provider + +`provider add` required flags are `--base-url` and `--model`. Optional `--name` defaults to the alias; `--env-key` defaults to `CODEX_SWITCH__KEY`; `--wire-api` defaults to `responses` (the only protocol current Codex accepts). `--reasoning EFFORT` saves `model_reasoning_effort=EFFORT` (for thinking models) and `--no-web-search` saves `web_search=disabled`; `--set KEY=VALUE` (repeatable) saves any other `codex -c` override. All are applied on every launch and passed to Codex verbatim (only the `KEY=VALUE` shape is checked); an explicit `--set` wins over a convenience flag for the same key. `--api-key-stdin` is required when there is no interactive terminal. + +The alias must not collide with a ChatGPT profile, another provider, or Codex's reserved ids `openai`, `ollama`, and `lmstudio`. Removal is immediate and is not archived under `deleted-profiles/`. + +See [Custom API providers](Providers) for OpenRouter, DeepSeek-via-gateway, storage, and the no-argv key contract. + ## TUI shortcuts +Two tabs: **Accounts** and **Providers**. `Tab` / `Shift+Tab` switches between them. `q` and `h` are global. + +### Accounts tab + `Enter` opens the scrollable detail and action menu for the selected account; if accounts are marked, it opens the batch menu instead. | Key | Action | @@ -79,10 +97,24 @@ codex-switch self-update --check | `Esc` | Clear filter/marks or close the current popup | | `q` | Quit | +### Providers tab + +| Key | Action | +|---|---| +| `j` / `k` or `↑` / `↓` | Navigate | +| `a` | Add a provider (alias → base URL → model → API key; the key is masked) | +| `d` | Remove the selected provider (confirmation required) | +| `Tab` | Switch to Accounts | +| `h` | Show help | +| `q` | Quit | + +The Providers table never renders the stored key. Launching a provider is CLI-only (`codex-switch launch `). + Destructive or consumptive actions always require confirmation. ## Next steps - See how these commands combine into workflows in the [Feature guide](Feature-Guide). +- Custom API endpoints, OpenRouter, and key handling: [Custom API providers](Providers). - Adjust defaults, proxy, and daemon behavior in [Configuration](Configuration). - Check update channels and flags in [Updating](Updating). diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index c451370..0b3af4d 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -1,6 +1,6 @@ # Configuration -`codex-switch` uses `~/.codex-switch` by default. Set `CODEX_SWITCH_HOME` to relocate its profiles, cache, locks, logs, and daemon state. This does not change Codex's own home; set `CODEX_HOME` for that. +`codex-switch` uses `~/.codex-switch` by default. Set `CODEX_SWITCH_HOME` to relocate its profiles, custom providers, cache, locks, logs, and daemon state. This does not change Codex's own home; set `CODEX_HOME` for that. Configuration is optional: a missing `config.toml` means defaults. An existing but unreadable or invalid file fails fast with its path instead of being silently ignored. @@ -30,6 +30,7 @@ Accounts are added by logging in with `codex-switch login` or by importing an ex |---|---| | `$CODEX_HOME/auth.json` | Live authentication read by Codex. | | `$CODEX_SWITCH_HOME/profiles//auth.json` | Saved profile authentication. | +| `$CODEX_SWITCH_HOME/providers//provider.toml` | Custom API provider definition and key (directory `0700`, file `0600`). | | `$CODEX_SWITCH_HOME/deleted-profiles/` | Recoverable deleted profiles. | | `$CODEX_SWITCH_HOME/current` | Current alias marker. | | `$CODEX_SWITCH_HOME/cache.json` | Per-profile usage cache. | @@ -128,5 +129,6 @@ Every command writes diagnostic logs to `$CODEX_SWITCH_HOME/logs/`, one file per ## Next steps - See what these settings control in the [Feature guide](Feature-Guide). +- Custom API provider storage and launch overlay: [Custom API providers](Providers). - Look up the flags that override configuration in the [Command reference](Command-Reference). - Diagnose configuration errors with [Troubleshooting](Troubleshooting). diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 130fafc..3014fed 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -11,7 +11,7 @@ Contributions normally target `dev`; `master` tracks stable releases. The short - Preserve the safety contracts: JSON stdout, atomic file writes, cross-process locks, recoverable deletion, and the file-backed Codex credential-store requirement. - For a substantial feature or architecture change, open an issue first so the boundary can be agreed before implementation. -Never attach credentials, auth files, personal account metadata, or unredacted debug output — in code, tests, issues, or pull requests. Read the full [contribution guidelines](https://github.com/xjoker/codex-switch/blob/dev/CONTRIBUTING.md) before opening a pull request. +Never attach credentials, auth files, provider API keys, personal account metadata, or unredacted debug output — in code, tests, issues, or pull requests. Read the full [contribution guidelines](https://github.com/xjoker/codex-switch/blob/dev/CONTRIBUTING.md) before opening a pull request. ## Next steps diff --git a/docs/wiki/Developer-Onboarding.md b/docs/wiki/Developer-Onboarding.md index bfedce5..c38bfbc 100644 --- a/docs/wiki/Developer-Onboarding.md +++ b/docs/wiki/Developer-Onboarding.md @@ -26,6 +26,7 @@ Development and pull requests normally target `dev`. The `master` branch represe |---|---|---| | CLI shape | `src/cli.rs`, `src/commands/` | CLI integration tests and `--help` smoke test | | Authentication or storage | `src/auth.rs`, `src/profile.rs` | Unit tests plus isolated-home integration tests | +| Custom API providers | `src/provider.rs`, `src/commands/provider.rs`, launch provider path in `src/commands/launch.rs`, TUI tabs in `src/tui/` | Isolated-home unit tests; the key must never appear in argv | | Usage parsing/API | `src/usage/api.rs`, `src/usage/parse.rs` | Mock HTTP and parser tests | | Account selection | `src/usage/scoring.rs`, `src/commands/profile.rs` | Pure scoring tests and end-to-end scoring tests | | TUI behavior | `src/tui/` | State/render unit tests and terminal smoke test | @@ -95,7 +96,7 @@ GitHub Actions repeats the core checks on Linux, macOS, and Windows. A local pas Behavior changes must update the closest reader-facing document in the same pull request: -- User-visible commands or behavior: the relevant `docs/wiki/` page ([Feature guide](Feature-Guide), [Command reference](Command-Reference), [Configuration](Configuration), [Updating](Updating), [Troubleshooting](Troubleshooting)) and `README.md` when the quick start is affected +- User-visible commands or behavior: the relevant `docs/wiki/` page ([Feature guide](Feature-Guide), [Custom API providers](Providers), [Command reference](Command-Reference), [Configuration](Configuration), [Updating](Updating), [Troubleshooting](Troubleshooting)) and `README.md` when the quick start is affected - Module boundaries or data flow: [Architecture overview](Architecture-Overview) (`docs/wiki/Architecture-Overview.md`) - Contributor workflow: [`CONTRIBUTING.md`](https://github.com/xjoker/codex-switch/blob/dev/CONTRIBUTING.md) or this page - Release behavior: [`docs/RELEASE.md`](https://github.com/xjoker/codex-switch/blob/dev/docs/RELEASE.md) diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index 0cded25..000fdb9 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -10,7 +10,19 @@ No. Codex reads authentication at startup. Restart Codex, or use `codex-switch l ## Where is account data stored? -Saved profiles and application state default to `~/.codex-switch`; the live Codex file defaults to `~/.codex/auth.json`. `CODEX_SWITCH_HOME` and `CODEX_HOME` relocate them independently. +Saved profiles and application state default to `~/.codex-switch`; the live Codex file defaults to `~/.codex/auth.json`. Custom API providers live under `~/.codex-switch/providers//provider.toml`. `CODEX_SWITCH_HOME` and `CODEX_HOME` relocate them independently. + +## Can I point Codex at DeepSeek (or another Chat Completions API) directly? + +No. Current Codex only accepts `wire_api = "responses"`. DeepSeek's official API is Chat Completions. Save an OpenRouter (or other Responses-capable gateway) provider and set `--model` to that gateway's slug. See [Custom API providers](Providers). + +## Does `codex-switch use` switch a custom API provider? + +No. `use` only stages a ChatGPT `auth.json` for the next Codex process. A provider is applied only by `codex-switch launch `, for that one Codex invocation. A later bare `codex` run is unchanged. + +## Is a custom provider's API key put on the command line? + +No. `provider add` reads it from a hidden prompt or `--api-key-stdin`. Launch injects it into the Codex child environment under a codex-switch-owned variable. `list`, `show`, JSON, and the TUI print a redacted form only. ## Is profile deletion permanent? @@ -40,4 +52,5 @@ These Wiki pages are generated from [`docs/wiki/` on the `dev` branch](https://g - New installation: [Getting started](Getting-Started). - Daily workflows: [Feature guide](Feature-Guide). +- Custom API providers: [Custom API providers](Providers). - Errors and recovery: [Troubleshooting](Troubleshooting). diff --git a/docs/wiki/Feature-Guide.md b/docs/wiki/Feature-Guide.md index 1376105..8f2239c 100644 --- a/docs/wiki/Feature-Guide.md +++ b/docs/wiki/Feature-Guide.md @@ -45,6 +45,8 @@ The usage model includes the main 5-hour and 7-day windows, additional model-spe Normal reads refresh only stale entries. Use `list -f` or the TUI refresh action when a fresh network read is required. +The TUI has two tabs: **Accounts** (ChatGPT OAuth, quota, scoring) and **Providers** (custom API endpoints). `Tab` / `Shift+Tab` switches between them. Account keys (`Enter`, `W`, mark, filter) apply only on Accounts. + The TUI account detail page is a single scrollable column with identity and organization labels, token expiry times in the local timezone, every quota pool with a pace marker, available reset cards, and the models the account may use. Model names and reasoning-effort capabilities are discovered from the authenticated service at runtime, not hardcoded. The full shortcut list is in the [command reference](Command-Reference#tui-shortcuts) and under `h` inside the TUI. ## Select an account @@ -81,6 +83,21 @@ codex-switch launch -- --full-auto The launch lock serializes overlapping launch sessions. The restore delay is configurable (`launch.restore_delay_secs`) because Codex does not expose an authentication-read handshake. +## Launch Codex with a custom API provider + +A provider profile is a third-party API endpoint plus a bearer key, stored under `$CODEX_SWITCH_HOME/providers/` rather than as a ChatGPT `auth.json`. Typical case: OpenRouter. + +```bash +codex-switch provider add openrouter \ + --base-url https://openrouter.ai/api/v1 \ + --model openai/gpt-5.3-codex +codex-switch launch openrouter +``` + +`launch ` does not swap `$CODEX_HOME/auth.json`. It starts Codex with `-c` overrides that define and select the provider, and injects the key into the child environment only. MCP servers and other settings in `$CODEX_HOME/config.toml` stay in effect. Auto-select (`launch` with no alias) and `use` remain ChatGPT-only. + +Some models need extra Codex request settings: models that reject the built-in `web_search` server tool need `-c web_search=disabled`, and thinking models need `-c model_reasoning_effort=medium`. The API key is read from a hidden prompt (or `--api-key-stdin`), never from argv. Full workflow, DeepSeek-via-OpenRouter, model-specific settings, TUI add/remove, and the security contract are in [Custom API providers](Providers). + ## Recover exhausted accounts When the whole candidate pool is exhausted, an interactive `use` or `launch` can offer to consume the earliest-expiring reset card. Automation must opt in explicitly: @@ -131,10 +148,11 @@ codex-switch self-update Most non-interactive commands support `--json` or `--json-pretty`. Structured output stays on stdout; progress and diagnostic messages use stderr. Commands that can consume a reset card or delete a profile require explicit non-interactive confirmation. -Never publish profile files, `auth.json`, unredacted debug output, proxy credentials, account IDs, email addresses, or workspace names. +Never publish profile files, `auth.json`, provider API keys, unredacted debug output, proxy credentials, account IDs, email addresses, or workspace names. ## Next steps - Need an exact command, flag, or TUI shortcut? Open the [Command reference](Command-Reference). +- Launching Codex against OpenRouter or another custom API? Open [Custom API providers](Providers). - Tune paths, proxy, daemon, and launch behavior in [Configuration](Configuration). - Something failed? Start with [Troubleshooting](Troubleshooting). diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 64b985f..da2a3c7 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -86,10 +86,11 @@ codex-switch launch # select, start Codex, restore auth afterwards Saved profiles, cache, configuration, and daemon state default to `~/.codex-switch` (`%USERPROFILE%\.codex-switch` on Windows). The live Codex file stays at `$CODEX_HOME/auth.json`. See [Configuration](Configuration) for every path and setting. -Never share profile files, `auth.json`, tokens, proxy credentials, or unredacted `--debug` output. +Never share profile files, `auth.json`, tokens, provider API keys, proxy credentials, or unredacted `--debug` output. ## Next steps - Learn account, quota, launch, and daemon workflows in the [Feature guide](Feature-Guide). +- Launch Codex against OpenRouter or another custom API: [Custom API providers](Providers). - Look up exact commands and TUI shortcuts in the [Command reference](Command-Reference). - Keep the binary current with [Updating](Updating). diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 4da100d..5e369f6 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -16,6 +16,7 @@ |---|---| | Install codex-switch and add my first account | [Getting started](Getting-Started) | | Manage accounts, watch quota, select, launch, or run the daemon | [Feature guide](Feature-Guide) | +| Launch Codex against OpenRouter or another custom API | [Custom API providers](Providers) | | Look up an exact command, flag, or TUI shortcut | [Command reference](Command-Reference) | | Configure paths, proxy, cache, daemon, or launch behavior | [Configuration](Configuration) | | Update the binary or move between release channels | [Updating](Updating) | @@ -33,4 +34,4 @@ These Wiki pages are the user and contributor documentation for `codex-switch`. Their sources live in [`docs/wiki/` on the `dev` branch](https://github.com/xjoker/codex-switch/tree/dev/docs/wiki), are reviewed in pull requests with the code, and are published here automatically. Maintainer-only material stays in the repository: the [release process](https://github.com/xjoker/codex-switch/blob/dev/docs/RELEASE.md) and the [changelog](https://github.com/xjoker/codex-switch/blob/dev/docs/CHANGELOG.md). Stable installers and binaries come from [GitHub Releases](https://github.com/xjoker/codex-switch/releases). -Do not publish auth files, profile files, tokens, unredacted debug output, proxy credentials, account IDs, email addresses, or workspace names. +Do not publish auth files, profile files, tokens, provider API keys, unredacted debug output, proxy credentials, account IDs, email addresses, or workspace names. diff --git a/docs/wiki/Providers.md b/docs/wiki/Providers.md new file mode 100644 index 0000000..32caefe --- /dev/null +++ b/docs/wiki/Providers.md @@ -0,0 +1,184 @@ +# Custom API providers + +A custom API provider is a saved third-party endpoint that `codex-switch launch` can hand to Codex CLI for one session. Typical case: OpenRouter, or another gateway that speaks Codex's Responses protocol. + +Unlike a ChatGPT account profile, a provider has no `auth.json` and no quota dashboard. It stores a model-provider definition plus a bearer API key under `$CODEX_SWITCH_HOME`, then at launch injects that definition as `codex -c …` overrides. Nothing is written to `~/.codex`. + +> Never put an API key on the command line. `provider add` reads it from a hidden prompt, or from stdin with `--api-key-stdin`. The key is stored mode `0600` and never printed, listed, or placed in argv. + +## Add a provider + +```bash +codex-switch provider add openrouter \ + --base-url https://openrouter.ai/api/v1 \ + --model openai/gpt-5.3-codex +``` + +The command then prompts for the API key without echoing it. For scripts, pass the key on stdin instead: + +```bash +printf '%s' "$OPENROUTER_API_KEY" | codex-switch provider add openrouter \ + --base-url https://openrouter.ai/api/v1 \ + --model openai/gpt-5.3-codex \ + --api-key-stdin +``` + +Optional flags: + +| Flag | Default | Purpose | +|---|---|---| +| `--name` | the alias | Human-readable name Codex shows | +| `--env-key` | `CODEX_SWITCH__KEY` | Environment variable Codex reads the key from at launch | +| `--wire-api` | `responses` | Codex wire protocol; current Codex only accepts `responses` | +| `--reasoning EFFORT` | none | Save `model_reasoning_effort=EFFORT` for thinking models (see below) | +| `--no-web-search` | off | Save `web_search=disabled` for models that reject the built-in tool | +| `--set KEY=VALUE` | none | Extra `codex -c` override saved with the provider and applied at launch (repeatable) | +| `--api-key-stdin` | off | Read the key from stdin instead of a hidden prompt | + +These save `codex -c KEY=VALUE` overrides with the provider, so a model-specific Codex setting is applied on every launch without retyping it after `--` (see [Model-specific request settings](#model-specific-request-settings)). `--reasoning` and `--no-web-search` are convenience shortcuts for the two most common settings; `--set` (repeatable) covers any other override. Values are passed to Codex verbatim — Codex, not codex-switch, decides which keys and values are valid — so only the `KEY=VALUE` shape is checked. An explicit `--set` wins over a convenience flag for the same key. + +The alias follows the same rules as a ChatGPT profile (ASCII letters, digits, `_`, `-`, `.`; at most 64 characters) and must not collide with an existing profile, an existing provider, or Codex's reserved ids `openai`, `ollama`, and `lmstudio`. + +Inspect and remove: + +```bash +codex-switch provider list +codex-switch provider show openrouter +codex-switch provider remove openrouter +``` + +`show` prints a redacted key (`…` plus the last four characters). Removal deletes the stored key immediately; unlike ChatGPT profile deletion, it is not archived under `deleted-profiles/`. Non-interactive and `--json` runs require `--yes`. + +`--json` is supported on `provider add`, `list`, `show`, and `remove`. JSON never includes the raw key. + +## Launch Codex with a provider + +Name the provider alias. Auto-select (`launch` with no alias) stays ChatGPT-only. + +```bash +codex-switch launch openrouter +codex-switch launch openrouter -- --full-auto +``` + +`launch` does **not** replace `$CODEX_HOME/auth.json`. It starts `codex` with `-c` overrides that define and select the provider, and injects the API key into the child process environment under `env_key`. Extra arguments after `--` are appended as Codex CLI flags. + +Some models need extra Codex request settings (disabling `web_search`, or setting a reasoning effort for thinking models) — see [Model-specific request settings](#model-specific-request-settings). + +Because `-c` layers on top of `$CODEX_HOME/config.toml`, MCP servers, skills, and other Codex settings in that file stay in effect for the session. + +`codex-switch use` does not accept a provider alias. A provider is applied only for the launched Codex process; a later bare `codex` invocation is unchanged. + +## OpenRouter and DeepSeek + +OpenRouter is the intended first provider: its `/api/v1` base URL plus a full model slug (including the vendor prefix) is what Codex expects. + +Codex currently speaks only `wire_api = "responses"`. DeepSeek's official API is Chat Completions, so pointing `--base-url` at DeepSeek directly will not work. Route DeepSeek (or any other Chat Completions-only vendor) through OpenRouter or another Responses-capable gateway, and set `--model` to that gateway's slug: + +```bash +codex-switch provider add deepseek \ + --base-url https://openrouter.ai/api/v1 \ + --model deepseek/deepseek-chat \ + --name "DeepSeek via OpenRouter" +``` + +Pick the slug from the gateway's catalog. If Codex rejects the model, the usual cause is a Chat Completions-only endpoint rather than a missing key. + +## Model-specific request settings + +Codex always sends the same Responses request shape (including its built-in `web_search` server tool). Whether a given model accepts it depends on the model, not on luck — the behavior is consistent per model, not intermittent. + +### web_search server tool + +Codex enables its built-in `web_search` server tool by default. Some models accept or ignore it (verified: `deepseek/deepseek-v3.2`, `moonshotai/kimi-k2`, `minimax/minimax-m3:free` all return HTTP 200), while others reject it (verified: `openai/gpt-oss-20b` returns HTTP 400 `Server tool request failed`). If a model rejects it, turn it off at the top level of `$CODEX_HOME/config.toml`: + +```toml +web_search = "disabled" +``` + +per launch, since `launch` passes everything after `--` to Codex: + +```bash +codex-switch launch openrouter -- -c web_search=disabled +``` + +or saved once with the provider so every launch applies it: + +```bash +codex-switch provider add openrouter \ + --base-url https://openrouter.ai/api/v1 \ + --model openai/gpt-oss-20b \ + --no-web-search +``` + +(`--no-web-search` is shorthand for `--set web_search=disabled`.) + +### Reasoning ("thinking") models + +Codex defaults an unknown model to `reasoning effort: none`, which reads as reasoning disabled. Thinking models reject that with HTTP 400 `Reasoning is mandatory for this endpoint`. Give Codex a reasoning effort (verified with `deepseek/deepseek-r1-0528` and `moonshotai/kimi-k2-thinking`): + +```bash +codex-switch launch openrouter -- -c model_reasoning_effort=medium +``` + +or save it with the provider so it is always applied: + +```bash +codex-switch provider add r1 \ + --base-url https://openrouter.ai/api/v1 \ + --model deepseek/deepseek-r1-0528 \ + --reasoning medium +``` + +(`--reasoning medium` is shorthand for `--set model_reasoning_effort=medium`.) + +Effort values (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`; Codex also accepts `ultra`) come from the Codex version in use, so codex-switch does not restrict them — pass any value with `--reasoning` (or `--set`) and Codex reports if it is invalid. Plain chat models (`deepseek/deepseek-v3.2`, `moonshotai/kimi-k2`, `openai/gpt-4o-mini`) need no reasoning flag. Combine `--reasoning` and `--no-web-search` (or repeat `--set`) when a model needs both. + +## TUI + +`codex-switch tui` has two tabs: **Accounts** (ChatGPT OAuth, quota, scoring) and **Providers** (alias, name, model, base URL). Switch with `Tab` / `Shift+Tab`. + +On the Providers tab: + +| Key | Action | +|---|---| +| `j` / `k` or `↑` / `↓` | Navigate | +| `a` | Add a provider (alias → base URL → model → reasoning → web_search → API key) | +| `d` | Remove the selected provider (confirmation required) | +| `Tab` | Return to Accounts | +| `h` | Help | +| `q` | Quit | + +The reasoning step is a single choice (`←`/`→`, default `(skip)` saves nothing); the web_search step is a toggle (`Space`, default leaves it enabled). Both are saved into the provider's `codex_config`. The API-key step is masked (`*`). The stored key is never rendered in the table. The wizard does not set `--name`, `--env-key`, or `--wire-api`; those keep the CLI defaults (`name` = alias, derived `env_key`, `responses`). Use the CLI (`--set`) for any override other than reasoning and web_search. + +Launching a provider is CLI-only; the Providers tab does not start Codex. + +## Storage and security + +| Location | Purpose | +|---|---| +| `$CODEX_SWITCH_HOME/providers//provider.toml` | Provider definition and API key (directory `0700`, file `0600`) | + +Defaults to `~/.codex-switch/providers/`. Relocate the whole tree with `CODEX_SWITCH_HOME`; this still does not change where Codex reads `auth.json`. + +Security contract: + +- The key is never a CLI argument, so it does not appear in the process table as argv. +- At launch it exists only in the Codex child environment, under a codex-switch-owned variable (`CODEX_SWITCH__KEY` by default) rather than a vendor's conventional name, so a pre-exported `OPENAI_API_KEY` or `OPENROUTER_API_KEY` is not reused by accident. +- `list`, `show`, JSON output, and the TUI print a redacted form only. +- Launch writes nothing under `$CODEX_HOME`. ChatGPT `use` / `launch` locking and `auth.json` backup/restore do not apply. + +Do not commit `provider.toml`, paste keys into issues, or share unredacted `--debug` output. + +## What this does not do + +- Persist a provider for a subsequent bare `codex` run (`use` remains ChatGPT-only). +- Auto-select among providers, score them, or show quota / credits. +- Talk Chat Completions, or wrap a local proxy. +- Share an alias with a ChatGPT profile. + +## Next steps + +- Command flags and JSON shapes: [Command reference](Command-Reference#provider). +- ChatGPT account, quota, and `use` workflows: [Feature guide](Feature-Guide). +- Paths and `CODEX_SWITCH_HOME`: [Configuration](Configuration). +- Module and storage layout: [Architecture overview](Architecture-Overview). diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 225d50a..423055b 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -5,6 +5,12 @@ Start with the complete error message, its file path, and the command that produ | Symptom | Action | |---|---| | No saved profiles | Run `codex-switch login` or `codex-switch import `. | +| `Profile '' not found` on `use` or ChatGPT `launch` | That alias is not a ChatGPT profile. Custom API providers are launched with `codex-switch launch ` and listed by `codex-switch provider list`; `use` does not accept them. | +| Codex rejects a custom provider / Chat Completions error | Current Codex only speaks `wire_api = "responses"`. Point `--base-url` at a Responses-capable gateway (OpenRouter) rather than a Chat Completions-only vendor API. See [Custom API providers](Providers). | +| Custom provider fails with `Server tool request failed` (HTTP 400) | The model rejects Codex's built-in `web_search` server tool (some models accept it, some do not). Set `web_search = "disabled"` in `$CODEX_HOME/config.toml`, or launch with `-c web_search=disabled`. See [Model-specific request settings](Providers#model-specific-request-settings). | +| Custom provider fails with `Reasoning is mandatory for this endpoint` (HTTP 400) | The model is a thinking model but Codex defaulted it to no reasoning. Launch with `-c model_reasoning_effort=medium` (or `low`/`high`). See [Model-specific request settings](Providers#model-specific-request-settings). | +| `'' already names a ChatGPT profile` when adding a provider | Aliases are a single namespace. Choose a different provider alias. | +| A removed provider cannot be recovered | Provider deletion removes the stored key immediately; unlike ChatGPT profiles, it is not archived under `deleted-profiles/`. Re-add it with `provider add`. | | Credential store is not file-backed | Set `cli_auth_credentials_store = "file"` in `$CODEX_HOME/config.toml`. | | Headless login cannot open a browser | Run `codex-switch login --device`. | | Windows daemon installation is denied | Open PowerShell as Administrator and retry. | @@ -82,4 +88,5 @@ Include the operating system, terminal, `codex-switch --version`, exact command, - Check short behavior and security answers in the [FAQ](FAQ). - Review paths and settings in [Configuration](Configuration). +- Custom API provider launch and key handling: [Custom API providers](Providers). - If the problem remains, report the redacted reproduction in the [GitHub issue tracker](https://github.com/xjoker/codex-switch/issues). diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 453c247..312fc74 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -7,6 +7,7 @@ ## Use codex-switch - [Feature guide](Feature-Guide) +- [Custom API providers](Providers) - [Command reference](Command-Reference) - [Configuration](Configuration) - [Updating](Updating) diff --git a/src/cli.rs b/src/cli.rs index fd835da..a41ca46 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -52,6 +52,19 @@ pub enum ProviderCommand { /// Codex wire protocol (current Codex only supports "responses") #[arg(long, default_value = "responses")] wire_api: String, + /// Reasoning effort for thinking models, saved as + /// `model_reasoning_effort=` (common: none, minimal, low, medium, + /// high, xhigh, max; Codex also accepts ultra). Passed to Codex verbatim + #[arg(long, value_name = "EFFORT")] + reasoning: Option, + /// Disable Codex's built-in web_search server tool for models that reject + /// it (saved as `web_search=disabled`) + #[arg(long)] + no_web_search: bool, + /// Extra `codex -c KEY=VALUE` override to apply at launch (repeatable); + /// passed through verbatim, so any value Codex accepts works + #[arg(long = "set", value_name = "KEY=VALUE")] + set: Vec, /// Read the API key from stdin instead of an interactive hidden prompt #[arg(long)] api_key_stdin: bool, diff --git a/src/commands/launch.rs b/src/commands/launch.rs index d6dfb47..902f433 100644 --- a/src/commands/launch.rs +++ b/src/commands/launch.rs @@ -1,4 +1,5 @@ use crate::output::{print_json, user_println}; +use crate::provider::{self, ProviderProfile}; use crate::signals::ShutdownListener; use crate::{auth, config, profile}; use anyhow::{Context, Result}; @@ -37,6 +38,17 @@ pub(crate) async fn launch_cmd( ) -> Result<()> { use std::io::IsTerminal; + // A custom API provider profile takes a separate, simpler path: it has no + // OAuth auth.json to stage, so it never touches ~/.codex/auth.json. It is + // translated into `codex -c …` overrides with the key injected via the + // environment. Auto-select (no alias) stays ChatGPT-only. + if let Some(alias) = alias + && provider::exists(alias) + { + let profile = provider::load(alias)?; + return launch_provider(profile, args, json); + } + let mut revival_hint = None; let target_alias = match alias { Some(alias) => { @@ -65,15 +77,7 @@ pub(crate) async fn launch_cmd( user_println(&super::profile::revival_hint_message(hint)); } - match std::process::Command::new("codex") - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .output() - { - Ok(_) => {} - Err(_) => anyhow::bail!("codex not found in PATH. Install: npm install -g @openai/codex"), - } + ensure_codex_available()?; let codex_auth = auth::codex_auth_path()?; // Unique per-invocation backup name (PID + timestamp): prevents two @@ -191,14 +195,7 @@ pub(crate) async fn launch_cmd( // Wait for codex to exit let status = child.wait().context("waiting for codex")?; - // Compute exit code: prefer code(), fall back to 128+signal on Unix - #[cfg(unix)] - let exit_code = status.code().unwrap_or_else(|| { - use std::os::unix::process::ExitStatusExt; - status.signal().map(|s| 128 + s).unwrap_or(1) - }); - #[cfg(not(unix))] - let exit_code = status.code().unwrap_or(1); + let exit_code = child_exit_code(&status); if json { let mut payload = serde_json::json!({ @@ -223,6 +220,86 @@ pub(crate) async fn launch_cmd( Ok(()) } +/// Verify the `codex` binary is reachable before we stage anything or spawn it. +fn ensure_codex_available() -> Result<()> { + match std::process::Command::new("codex") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + { + Ok(_) => Ok(()), + Err(_) => anyhow::bail!("codex not found in PATH. Install: npm install -g @openai/codex"), + } +} + +/// Codex's exit code, mapping a Unix signal death to `128 + signal`. +fn child_exit_code(status: &std::process::ExitStatus) -> i32 { + #[cfg(unix)] + { + status.code().unwrap_or_else(|| { + use std::os::unix::process::ExitStatusExt; + status.signal().map(|s| 128 + s).unwrap_or(1) + }) + } + #[cfg(not(unix))] + { + status.code().unwrap_or(1) + } +} + +/// Launch Codex against a custom API provider profile. +/// +/// Unlike the ChatGPT path this stages nothing: the provider is applied as +/// `codex -c …` overrides (which layer over the user's base config, preserving +/// MCP servers and everything else) and the API key is injected into the child +/// process environment under the profile's `env_key`. Nothing is written to +/// `~/.codex`, so there is no backup/restore window to guard. +fn launch_provider(profile: ProviderProfile, args: Vec, json: bool) -> Result<()> { + ensure_codex_available()?; + + let (env_name, env_value) = profile.launch_env(); + let mut codex_args = profile.codex_config_args(); + codex_args.extend(args); + + if !json { + user_println(&format!( + "Launching codex with provider '{}' ({} / {})...", + profile.alias, profile.name, profile.model + )); + } + + let mut child = std::process::Command::new("codex") + .args(&codex_args) + .env(env_name, env_value) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .context("Failed to start codex")?; + + let status = child.wait().context("waiting for codex")?; + let exit_code = child_exit_code(&status); + + if json { + print_json(&serde_json::json!({ + "ok": status.success(), + "alias": profile.alias, + "action": "launched", + "provider": profile.provider_id, + "model": profile.model, + "exit_code": exit_code, + })); + } else { + user_println("codex exited"); + } + + if exit_code != 0 { + std::process::exit(exit_code); + } + Ok(()) +} + /// Snapshot the live auth.json into `backup` before it is overwritten by the /// staged profile. /// diff --git a/src/commands/provider.rs b/src/commands/provider.rs index 9ed8f8f..1b3a360 100644 --- a/src/commands/provider.rs +++ b/src/commands/provider.rs @@ -16,6 +16,9 @@ pub(crate) fn provider_cmd(cmd: ProviderCommand, json: bool) -> Result<()> { name, env_key, wire_api, + reasoning, + no_web_search, + set, api_key_stdin, } => add( alias, @@ -24,6 +27,7 @@ pub(crate) fn provider_cmd(cmd: ProviderCommand, json: bool) -> Result<()> { name, env_key, wire_api, + merge_codex_config(reasoning, no_web_search, set), api_key_stdin, json, ), @@ -41,6 +45,7 @@ fn add( name: Option, env_key: Option, wire_api: String, + codex_config: Vec, api_key_stdin: bool, json: bool, ) -> Result<()> { @@ -61,6 +66,7 @@ fn add( env_key: env_key.unwrap_or_else(|| provider::derive_env_key(&alias)), model, wire_api, + codex_config, api_key, alias: alias.clone(), }; @@ -86,6 +92,28 @@ fn add( Ok(()) } +/// Translate the convenience `--reasoning` / `--no-web-search` flags into +/// `codex -c KEY=VALUE` overrides, then append the raw `--set` overrides last so +/// an explicit `--set` wins over a convenience flag for the same key (Codex +/// takes the last `-c` when a key repeats). The convenience flags are just +/// shortcuts; any value is passed through, and `--set` remains the escape hatch +/// for arbitrary keys. +fn merge_codex_config( + reasoning: Option, + no_web_search: bool, + set: Vec, +) -> Vec { + let mut out = Vec::new(); + if let Some(effort) = reasoning { + out.push(format!("model_reasoning_effort={effort}")); + } + if no_web_search { + out.push("web_search=disabled".to_string()); + } + out.extend(set); + out +} + /// Read the API key without exposing it on the command line: from stdin in /// `--api-key-stdin` mode, otherwise from a hidden interactive prompt. Refuses /// to run non-interactively without `--api-key-stdin` rather than echoing. @@ -127,6 +155,7 @@ fn list(json: bool) -> Result<()> { "model": p.model, "wire_api": p.wire_api, "env_key": p.env_key, + "codex_config": p.codex_config, "has_key": !p.api_key.is_empty(), }) }) @@ -161,6 +190,7 @@ fn show(alias: &str, json: bool) -> Result<()> { "model": p.model, "wire_api": p.wire_api, "env_key": p.env_key, + "codex_config": p.codex_config, "key": p.redacted_key(), })); return Ok(()); @@ -172,6 +202,13 @@ fn show(alias: &str, json: bool) -> Result<()> { user_println(&format!("model {}", p.model)); user_println(&format!("wire_api {}", p.wire_api)); user_println(&format!("env_key {}", p.env_key)); + if p.codex_config.is_empty() { + user_println("codex_config (none)"); + } else { + for entry in &p.codex_config { + user_println(&format!("codex_config {entry}")); + } + } user_println(&format!("key {}", p.redacted_key())); Ok(()) } @@ -201,3 +238,51 @@ fn remove(alias: &str, yes: bool, json: bool) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::merge_codex_config; + + #[test] + fn convenience_flags_translate_to_codex_overrides() { + let out = merge_codex_config(Some("medium".to_string()), true, vec![]); + assert_eq!( + out, + vec![ + "model_reasoning_effort=medium".to_string(), + "web_search=disabled".to_string(), + ] + ); + } + + #[test] + fn no_flags_yield_no_overrides() { + assert!(merge_codex_config(None, false, vec![]).is_empty()); + } + + #[test] + fn explicit_set_is_appended_last_so_it_wins_over_a_convenience_flag() { + let out = merge_codex_config( + Some("high".to_string()), + false, + vec!["model_reasoning_effort=low".to_string()], + ); + // Both survive; Codex takes the last `-c` for a repeated key, so the + // explicit --set (last) wins. + assert_eq!( + out, + vec![ + "model_reasoning_effort=high".to_string(), + "model_reasoning_effort=low".to_string(), + ] + ); + } + + #[test] + fn an_unknown_reasoning_value_is_passed_through_unchecked() { + // The effort set is Codex's to define; codex-switch must not reject a + // value it does not recognize. + let out = merge_codex_config(Some("ultra".to_string()), false, vec![]); + assert_eq!(out, vec!["model_reasoning_effort=ultra".to_string()]); + } +} diff --git a/src/provider.rs b/src/provider.rs index 2660da1..7f0fd14 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -51,6 +51,13 @@ pub struct ProviderProfile { pub model: String, #[serde(default = "default_wire_api")] pub wire_api: String, + /// Extra `codex -c key=value` overrides applied at launch, stored verbatim as + /// `"key=value"` strings. Lets a provider carry model-specific Codex settings + /// (e.g. `web_search=disabled`, `model_reasoning_effort=medium`) so the user + /// need not retype them. Values pass through untouched; Codex — not + /// codex-switch — is the source of truth for which keys and values are valid. + #[serde(default)] + pub codex_config: Vec, /// Bearer API key. Secret: stored `0600`, injected as an env var at launch, /// and never printed or placed on the command line. pub api_key: String, @@ -155,6 +162,14 @@ impl ProviderProfile { if self.wire_api.trim().is_empty() { anyhow::bail!("wire_api cannot be empty"); } + for entry in &self.codex_config { + match entry.split_once('=') { + Some((key, _)) if !key.trim().is_empty() => {} + _ => anyhow::bail!( + "codex config override '{entry}' must be in KEY=VALUE form with a non-empty key" + ), + } + } if self.api_key.is_empty() { anyhow::bail!("api_key cannot be empty"); } @@ -165,6 +180,68 @@ impl ProviderProfile { pub fn redacted_key(&self) -> String { redact_key(&self.api_key) } + + /// The `codex -c …` override arguments that define and select this provider + /// for a single launch. These layer on top of the user's base + /// `~/.codex/config.toml` (so MCP servers and other settings are preserved) + /// and nothing is written to disk. + /// + /// The API key is intentionally **not** here — it is handed to Codex through + /// the environment (see [`launch_env`](Self::launch_env)) so it never appears + /// in argv or the process table. + pub fn codex_config_args(&self) -> Vec { + let id = &self.provider_id; + let mut pairs = vec![ + format!("model_providers.{id}.name={}", toml_string(&self.name)), + format!( + "model_providers.{id}.base_url={}", + toml_string(&self.base_url) + ), + format!( + "model_providers.{id}.env_key={}", + toml_string(&self.env_key) + ), + format!( + "model_providers.{id}.wire_api={}", + toml_string(&self.wire_api) + ), + format!("model_provider={}", toml_string(id)), + format!("model={}", toml_string(&self.model)), + ]; + // Provider-saved overrides layer on top, after the model is selected, and + // pass through verbatim (the user is responsible for their TOML form). + pairs.extend(self.codex_config.iter().cloned()); + pairs + .into_iter() + .flat_map(|kv| ["-c".to_string(), kv]) + .collect() + } + + /// The single environment override that hands Codex the API key under the + /// profile's `env_key`. Injected into the child process only. + pub fn launch_env(&self) -> (String, String) { + (self.env_key.clone(), self.api_key.clone()) + } +} + +/// Render a string as a TOML basic (quoted) string for a `codex -c key=value` +/// override, escaping the characters TOML requires. Codex parses the value part +/// as TOML, so a plain unquoted string would be misread (or rejected). +fn toml_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + out } /// Mask a secret for display: keep the last 4 characters when long enough, @@ -289,6 +366,7 @@ mod tests { env_key: derive_env_key(alias), model: "openai/gpt-5.3-codex".to_string(), wire_api: default_wire_api(), + codex_config: Vec::new(), api_key: "sk-secret-1234".to_string(), } } @@ -365,6 +443,124 @@ mod tests { assert!(remove("openrouter").is_err(), "removing twice must error"); } + #[test] + fn codex_config_args_define_and_select_the_provider_without_the_key() { + let p = sample("openrouter"); + let args = p.codex_config_args(); + let joined = args.join(" "); + + // Every override is introduced by its own `-c`. + assert_eq!(args.iter().filter(|a| a.as_str() == "-c").count(), 6); + assert!(joined.contains(r#"model_providers.openrouter.name="OpenRouter""#)); + assert!( + joined + .contains(r#"model_providers.openrouter.base_url="https://openrouter.ai/api/v1""#) + ); + assert!( + joined.contains(r#"model_providers.openrouter.env_key="CODEX_SWITCH_OPENROUTER_KEY""#) + ); + assert!(joined.contains(r#"model_providers.openrouter.wire_api="responses""#)); + assert!(joined.contains(r#"model_provider="openrouter""#)); + assert!(joined.contains(r#"model="openai/gpt-5.3-codex""#)); + + // The secret must never travel on the command line. + assert!( + !args.iter().any(|a| a.contains("sk-secret-1234")), + "the API key must never appear in argv" + ); + } + + #[test] + fn codex_config_overrides_are_appended_after_the_model_selection() { + let mut p = sample("openrouter"); + p.codex_config = vec![ + "web_search=disabled".to_string(), + "model_reasoning_effort=medium".to_string(), + ]; + let args = p.codex_config_args(); + + // Two extra `-c` overrides beyond the six that define/select the provider. + assert_eq!(args.iter().filter(|a| a.as_str() == "-c").count(), 8); + + let model_pos = args.iter().position(|a| a.starts_with("model=")).unwrap(); + let web_pos = args + .iter() + .position(|a| a == "web_search=disabled") + .unwrap(); + let reasoning_pos = args + .iter() + .position(|a| a == "model_reasoning_effort=medium") + .unwrap(); + assert!( + model_pos < web_pos && model_pos < reasoning_pos, + "overrides must layer on top of the model selection" + ); + // Passed through verbatim, not re-quoted as TOML strings. + assert!(args.iter().any(|a| a == "web_search=disabled")); + } + + #[test] + fn validate_rejects_a_codex_override_without_a_key() { + let mut missing_eq = sample("p"); + missing_eq.codex_config = vec!["web_search".to_string()]; + assert!( + missing_eq.validate().is_err(), + "an override without '=' must be rejected" + ); + + let mut empty_key = sample("p"); + empty_key.codex_config = vec!["=disabled".to_string()]; + assert!( + empty_key.validate().is_err(), + "an override with an empty key must be rejected" + ); + + let mut ok = sample("p"); + ok.codex_config = vec!["web_search=disabled".to_string()]; + assert!( + ok.validate().is_ok(), + "a KEY=VALUE override must be accepted" + ); + } + + #[test] + fn codex_config_survives_a_save_load_round_trip() { + let _home = TestHome::new(); + let mut profile = sample("openrouter"); + profile.codex_config = vec![ + "web_search=disabled".to_string(), + "model_reasoning_effort=medium".to_string(), + ]; + save(&profile).unwrap(); + + let loaded = load("openrouter").unwrap(); + assert_eq!( + loaded.codex_config, + vec![ + "web_search=disabled".to_string(), + "model_reasoning_effort=medium".to_string(), + ] + ); + } + + #[test] + fn launch_env_carries_the_key_under_the_derived_var() { + let p = sample("openrouter"); + assert_eq!( + p.launch_env(), + ( + "CODEX_SWITCH_OPENROUTER_KEY".to_string(), + "sk-secret-1234".to_string() + ) + ); + } + + #[test] + fn toml_string_quotes_and_escapes() { + assert_eq!(toml_string("OpenRouter"), r#""OpenRouter""#); + assert_eq!(toml_string(r#"a"b\c"#), r#""a\"b\\c""#); + } + #[cfg(unix)] #[test] fn saved_key_file_is_private() { diff --git a/src/tui/app.rs b/src/tui/app.rs index cc0d5ec..0687d88 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -157,6 +157,58 @@ pub enum ConfirmAction { credit_id: String, expires_at: String, }, + RemoveProvider(String), +} + +/// Reasoning-effort presets offered by the add-provider wizard. Index 0 skips +/// the override entirely; the rest are saved as `model_reasoning_effort=`. +/// These are convenience presets only — the CLI `--set`/`--reasoning` remain the +/// escape hatch for any other value Codex accepts (e.g. `ultra`). +pub const REASONING_CHOICES: [&str; 7] = + ["(skip)", "minimal", "low", "medium", "high", "xhigh", "max"]; + +/// Steps of the Providers-tab "add provider" wizard, in order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderAddStep { + Alias, + BaseUrl, + Model, + Reasoning, + WebSearch, + ApiKey, +} + +impl ProviderAddStep { + /// Short prompt shown in the status bar for the current step. + pub fn prompt(self) -> &'static str { + match self { + ProviderAddStep::Alias => "alias", + ProviderAddStep::BaseUrl => "base URL", + ProviderAddStep::Model => "model", + ProviderAddStep::Reasoning => "reasoning", + ProviderAddStep::WebSearch => "web_search", + ProviderAddStep::ApiKey => "API key", + } + } + + /// The API-key step is masked in the UI so the secret is never shown. + pub fn is_secret(self) -> bool { + matches!(self, ProviderAddStep::ApiKey) + } +} + +/// In-progress state of the multi-step add-provider wizard. +pub struct ProviderAddState { + pub step: ProviderAddStep, + pub input: String, + pub cursor: usize, + pub alias: String, + pub base_url: String, + pub model: String, + /// Index into [`REASONING_CHOICES`]; 0 means "skip" (no override). + pub reasoning_idx: usize, + /// Whether the wizard will save `web_search=disabled`. + pub no_web_search: bool, } pub struct RenameState { @@ -177,8 +229,27 @@ type ResetCardRefreshResult = ( Result<(Option, Vec), String>, ); +/// Which top-level TUI tab is active. Accounts (ChatGPT OAuth) and Providers +/// (third-party API + key) are isolated so their very different semantics +/// (quota/scoring vs base_url/key) and key bindings never mix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Accounts, + Providers, +} + pub struct App { pub accounts: Vec, + /// Custom API provider profiles (OpenRouter, etc.), shown on the Providers + /// tab; they carry no OAuth/usage and never join `accounts`. + pub providers: Vec, + /// Selected row within the Providers tab. + pub provider_selected: usize, + /// Active add-provider wizard, if the user is entering one. + pub provider_add: Option, + /// Active top-level tab. + pub active_tab: Tab, pub selected: usize, pub search: Option, pub search_active: bool, @@ -243,6 +314,10 @@ impl App { let cfg = crate::config::get(); App { accounts: vec![], + providers: vec![], + provider_selected: 0, + provider_add: None, + active_tab: Tab::default(), selected: 0, search: None, search_active: false, @@ -581,6 +656,246 @@ impl App { self.menu = Some(super::menu::MenuState::add()); } + /// Switch between the Accounts and Providers tabs. + pub fn toggle_tab(&mut self) { + self.active_tab = match self.active_tab { + Tab::Accounts => Tab::Providers, + Tab::Providers => Tab::Accounts, + }; + } + + pub fn provider_select_next(&mut self) { + if !self.providers.is_empty() && self.provider_selected + 1 < self.providers.len() { + self.provider_selected += 1; + } + } + + pub fn provider_select_prev(&mut self) { + if self.provider_selected > 0 { + self.provider_selected -= 1; + } + } + + /// Open the multi-step add-provider wizard (Providers tab, `a`). + pub fn open_provider_add(&mut self) { + self.provider_add = Some(ProviderAddState { + step: ProviderAddStep::Alias, + input: String::new(), + cursor: 0, + alias: String::new(), + base_url: String::new(), + model: String::new(), + reasoning_idx: 0, + no_web_search: false, + }); + } + + /// Ask to remove the selected provider (Providers tab, `d`). + pub fn request_remove_provider(&mut self) { + match self.providers.get(self.provider_selected) { + Some(p) => self.confirm = Some(ConfirmAction::RemoveProvider(p.alias.clone())), + None => self.set_status_error("No provider selected".to_string(), 3), + } + } + + /// Editing keys for the add-provider wizard (raw, case-sensitive input). + pub fn handle_provider_add_key(&mut self, code: KeyCode) { + match code { + KeyCode::Esc => { + self.provider_add = None; + return; + } + KeyCode::Enter => { + self.provider_add_commit_step(); + return; + } + _ => {} + } + let Some(state) = self.provider_add.as_mut() else { + return; + }; + // Choice steps navigate options rather than editing a text buffer. + match state.step { + ProviderAddStep::Reasoning => { + match code { + KeyCode::Up | KeyCode::Left if state.reasoning_idx > 0 => { + state.reasoning_idx -= 1; + } + KeyCode::Down | KeyCode::Right + if state.reasoning_idx + 1 < REASONING_CHOICES.len() => + { + state.reasoning_idx += 1; + } + _ => {} + } + return; + } + ProviderAddStep::WebSearch => { + if matches!( + code, + KeyCode::Up + | KeyCode::Down + | KeyCode::Left + | KeyCode::Right + | KeyCode::Char(' ') + ) { + state.no_web_search = !state.no_web_search; + } + return; + } + _ => {} + } + match code { + KeyCode::Backspace if state.cursor > 0 => { + state.cursor -= 1; + let byte_pos = char_to_byte(&state.input, state.cursor); + state.input.remove(byte_pos); + } + KeyCode::Delete => { + let char_count = state.input.chars().count(); + if state.cursor < char_count { + let byte_pos = char_to_byte(&state.input, state.cursor); + state.input.remove(byte_pos); + } + } + KeyCode::Left if state.cursor > 0 => state.cursor -= 1, + KeyCode::Right => { + let char_count = state.input.chars().count(); + if state.cursor < char_count { + state.cursor += 1; + } + } + KeyCode::Home => state.cursor = 0, + KeyCode::End => state.cursor = state.input.chars().count(), + KeyCode::Char(c) => { + let byte_pos = char_to_byte(&state.input, state.cursor); + state.input.insert(byte_pos, c); + state.cursor += 1; + } + _ => {} + } + } + + /// Validate and advance the current wizard step; finalize on the last one. + fn provider_add_commit_step(&mut self) { + let (step, value) = { + let Some(state) = self.provider_add.as_ref() else { + return; + }; + (state.step, state.input.trim().to_string()) + }; + match step { + ProviderAddStep::Alias => { + if value.is_empty() { + self.set_status_error("Alias cannot be empty".to_string(), 3); + return; + } + if let Err(err) = validate_alias(&value) { + self.set_status_error(format!("Invalid alias: {err}"), 3); + return; + } + if crate::provider::exists(&value) || self.accounts.iter().any(|a| a.alias == value) + { + self.set_status_error(format!("'{value}' already exists"), 3); + return; + } + if let Some(state) = self.provider_add.as_mut() { + state.alias = value; + state.step = ProviderAddStep::BaseUrl; + state.input.clear(); + state.cursor = 0; + } + } + ProviderAddStep::BaseUrl => { + if !(value.starts_with("http://") || value.starts_with("https://")) { + self.set_status_error( + "base URL must start with http:// or https://".to_string(), + 3, + ); + return; + } + if let Some(state) = self.provider_add.as_mut() { + state.base_url = value; + state.step = ProviderAddStep::Model; + state.input.clear(); + state.cursor = 0; + } + } + ProviderAddStep::Model => { + if value.is_empty() { + self.set_status_error("Model cannot be empty".to_string(), 3); + return; + } + if let Some(state) = self.provider_add.as_mut() { + state.model = value; + state.step = ProviderAddStep::Reasoning; + state.input.clear(); + state.cursor = 0; + } + } + ProviderAddStep::Reasoning => { + if let Some(state) = self.provider_add.as_mut() { + state.step = ProviderAddStep::WebSearch; + state.input.clear(); + state.cursor = 0; + } + } + ProviderAddStep::WebSearch => { + if let Some(state) = self.provider_add.as_mut() { + state.step = ProviderAddStep::ApiKey; + state.input.clear(); + state.cursor = 0; + } + } + ProviderAddStep::ApiKey => { + if value.is_empty() { + self.set_status_error("API key cannot be empty".to_string(), 3); + return; + } + let Some(state) = self.provider_add.take() else { + return; + }; + let mut codex_config = Vec::new(); + if state.reasoning_idx > 0 { + codex_config.push(format!( + "model_reasoning_effort={}", + REASONING_CHOICES[state.reasoning_idx] + )); + } + if state.no_web_search { + codex_config.push("web_search=disabled".to_string()); + } + let profile = crate::provider::ProviderProfile { + provider_id: crate::provider::sanitize_provider_id(&state.alias), + name: state.alias.clone(), + base_url: state.base_url, + env_key: crate::provider::derive_env_key(&state.alias), + model: state.model, + wire_api: "responses".to_string(), + codex_config, + api_key: value, + alias: state.alias.clone(), + }; + match profile + .validate() + .and_then(|()| crate::provider::save(&profile)) + { + Ok(()) => { + self.set_status(format!("Added provider '{}'", profile.alias), 4); + self.active_tab = Tab::Providers; + self.load_profiles(); + if let Some(idx) = + self.providers.iter().position(|p| p.alias == profile.alias) + { + self.provider_selected = idx; + } + } + Err(e) => self.set_status_error(format!("Add provider failed: {e}"), 6), + } + } + } + } + pub fn open_relogin_flow_menu(&mut self, alias: String, email: Option) { self.menu = Some(super::menu::MenuState::relogin_flow(alias, email)); } @@ -690,6 +1005,14 @@ impl App { }) }) .collect(); + self.providers = crate::provider::list_providers() + .unwrap_or_default() + .into_iter() + .filter_map(|alias| crate::provider::load(&alias).ok()) + .collect(); + if self.provider_selected >= self.providers.len() { + self.provider_selected = self.providers.len().saturating_sub(1); + } self.marked .retain(|alias| self.accounts.iter().any(|account| &account.alias == alias)); // A reload can follow credential replacement for an existing alias. @@ -1418,6 +1741,13 @@ impl App { } Err(e) => self.set_status_error(format!("Delete failed: {e}"), 5), }, + ConfirmAction::RemoveProvider(alias) => match crate::provider::remove(&alias) { + Ok(()) => { + self.set_status(format!("Removed provider {alias}"), 3); + self.load_profiles(); + } + Err(e) => self.set_status_error(format!("Remove provider failed: {e}"), 5), + }, ConfirmAction::BatchDelete(aliases) => { let mut ok = 0usize; let mut errors: Vec = Vec::new(); @@ -1884,12 +2214,19 @@ async fn run_app(terminal: &mut DefaultTerminal) -> Result<()> { app.handle_search_key(key.code); continue; } + // The add-provider wizard needs raw, case-sensitive keystrokes + // (aliases, URLs, and keys are all case-sensitive). + if app.provider_add.is_some() { + app.handle_provider_add_key(key.code); + continue; + } // Capital 'W' is a distinct global binding (toggle auto-warmup), // separate from menu 'w' (per-account warmup). Detect it before // case normalization so it survives the lowercase dispatch below. // Only meaningful in the main view (no popup/menu/confirm overlay). if matches!(key.code, KeyCode::Char('W')) + && app.active_tab == Tab::Accounts && app.help_popup.is_none() && app.menu.is_none() && app.confirm.is_none() @@ -1927,47 +2264,61 @@ async fn run_app(terminal: &mut DefaultTerminal) -> Result<()> { match code { KeyCode::Char('q') => break, - KeyCode::Esc => { - if app.search.is_some() { - app.search = None; - app.update_view(); - } else if !app.marked.is_empty() { - app.clear_marks(); - } - } - KeyCode::Down | KeyCode::Char('j') if app.selected + 1 < app.view_indices.len() => { - app.selected += 1; - } - KeyCode::Up | KeyCode::Char('k') if app.selected > 0 => { - app.selected -= 1; - } - KeyCode::Enter => { - if app.marked.is_empty() { - app.open_account_menu(); - } else { - app.open_batch_menu(); - } - } - KeyCode::Char('a') => app.open_add_menu(), - KeyCode::Char('r') => app.refresh(Refresh::Forced), - KeyCode::Char('t') => app.toggle_auto_refresh(), - KeyCode::Char('i') => app.toggle_detail_panel(), - KeyCode::Char('s') => app.cycle_sort(), KeyCode::Char('h') => app.open_help(), - KeyCode::Char(' ') => app.toggle_mark(), - KeyCode::Char('/') => { - if let Some(search) = &mut app.search { - search.cursor = search.query.chars().count(); - } else { - app.search = Some(SearchState { - query: String::new(), - cursor: 0, - }); - app.update_view(); - } - app.search_active = true; - } - _ => {} + KeyCode::Tab | KeyCode::BackTab => app.toggle_tab(), + _ => match app.active_tab { + Tab::Accounts => match code { + KeyCode::Esc => { + if app.search.is_some() { + app.search = None; + app.update_view(); + } else if !app.marked.is_empty() { + app.clear_marks(); + } + } + KeyCode::Down | KeyCode::Char('j') + if app.selected + 1 < app.view_indices.len() => + { + app.selected += 1; + } + KeyCode::Up | KeyCode::Char('k') if app.selected > 0 => { + app.selected -= 1; + } + KeyCode::Enter => { + if app.marked.is_empty() { + app.open_account_menu(); + } else { + app.open_batch_menu(); + } + } + KeyCode::Char('a') => app.open_add_menu(), + KeyCode::Char('r') => app.refresh(Refresh::Forced), + KeyCode::Char('t') => app.toggle_auto_refresh(), + KeyCode::Char('i') => app.toggle_detail_panel(), + KeyCode::Char('s') => app.cycle_sort(), + KeyCode::Char(' ') => app.toggle_mark(), + KeyCode::Char('/') => { + if let Some(search) = &mut app.search { + search.cursor = search.query.chars().count(); + } else { + app.search = Some(SearchState { + query: String::new(), + cursor: 0, + }); + app.update_view(); + } + app.search_active = true; + } + _ => {} + }, + Tab::Providers => match code { + KeyCode::Down | KeyCode::Char('j') => app.provider_select_next(), + KeyCode::Up | KeyCode::Char('k') => app.provider_select_prev(), + KeyCode::Char('a') => app.open_provider_add(), + KeyCode::Char('d') => app.request_remove_provider(), + _ => {} + }, + }, } } } @@ -2340,11 +2691,180 @@ mod tests { finish_login_or_cancel, finish_refresh_then_commit, refresh_fetches_loaded_usage, refresh_forces_negative_caches, reset_card_failure_from_outcome, retained_usage_by_alias, }; + use super::{ConfirmAction, ProviderAddStep, Tab}; use crate::{ jwt::{AccountInfo, OrgInfo}, usage::{Refresh, ResetCredit, UsageInfo}, warmup::ModelEntry, }; + use crossterm::event::KeyCode; + + /// Isolate `CODEX_SWITCH_HOME`/`CODEX_HOME` for tests that touch provider + /// storage. Serialized via the shared env lock so it can't race sibling + /// tests that also relocate these variables. + struct EnvHome { + _lock: std::sync::MutexGuard<'static, ()>, + _dir: tempfile::TempDir, + prev_cs: Option, + prev_ch: Option, + } + + impl EnvHome { + fn new() -> Self { + let lock = crate::profile::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let prev_cs = std::env::var_os("CODEX_SWITCH_HOME"); + let prev_ch = std::env::var_os("CODEX_HOME"); + unsafe { + std::env::set_var("CODEX_SWITCH_HOME", dir.path()); + std::env::set_var("CODEX_HOME", dir.path().join("codex")); + } + Self { + _lock: lock, + _dir: dir, + prev_cs, + prev_ch, + } + } + } + + impl Drop for EnvHome { + fn drop(&mut self) { + unsafe { + match &self.prev_cs { + Some(v) => std::env::set_var("CODEX_SWITCH_HOME", v), + None => std::env::remove_var("CODEX_SWITCH_HOME"), + } + match &self.prev_ch { + Some(v) => std::env::set_var("CODEX_HOME", v), + None => std::env::remove_var("CODEX_HOME"), + } + } + } + } + + fn type_str(app: &mut App, s: &str) { + for c in s.chars() { + app.handle_provider_add_key(KeyCode::Char(c)); + } + } + + #[test] + fn provider_add_wizard_collects_fields_and_saves() { + let _home = EnvHome::new(); + let mut app = App::new(); + app.open_provider_add(); + + type_str(&mut app, "myrouter"); + app.handle_provider_add_key(KeyCode::Enter); + type_str(&mut app, "https://openrouter.ai/api/v1"); + app.handle_provider_add_key(KeyCode::Enter); + type_str(&mut app, "openai/gpt-5.3-codex"); + app.handle_provider_add_key(KeyCode::Enter); + // Reasoning step: leave on default "(skip)"; web_search step: leave default. + app.handle_provider_add_key(KeyCode::Enter); + app.handle_provider_add_key(KeyCode::Enter); + type_str(&mut app, "sk-secret-xyz"); + app.handle_provider_add_key(KeyCode::Enter); + + assert!( + app.provider_add.is_none(), + "wizard should close after the final step" + ); + let p = crate::provider::load("myrouter").expect("provider must be saved"); + assert_eq!(p.base_url, "https://openrouter.ai/api/v1"); + assert_eq!(p.model, "openai/gpt-5.3-codex"); + assert_eq!(p.env_key, "CODEX_SWITCH_MYROUTER_KEY"); + assert_eq!(p.api_key, "sk-secret-xyz"); + assert_eq!(p.wire_api, "responses"); + assert!( + p.codex_config.is_empty(), + "skipping both choice steps saves no overrides" + ); + assert_eq!(app.active_tab, Tab::Providers); + assert!(app.providers.iter().any(|x| x.alias == "myrouter")); + } + + #[test] + fn provider_add_wizard_saves_reasoning_and_web_search_choices() { + let _home = EnvHome::new(); + let mut app = App::new(); + app.open_provider_add(); + + type_str(&mut app, "thinker"); + app.handle_provider_add_key(KeyCode::Enter); + type_str(&mut app, "https://openrouter.ai/api/v1"); + app.handle_provider_add_key(KeyCode::Enter); + type_str(&mut app, "deepseek/deepseek-r1-0528"); + app.handle_provider_add_key(KeyCode::Enter); + // Reasoning step: move from "(skip)" to "medium" (index 3). + app.handle_provider_add_key(KeyCode::Right); + app.handle_provider_add_key(KeyCode::Right); + app.handle_provider_add_key(KeyCode::Right); + app.handle_provider_add_key(KeyCode::Enter); + // web_search step: toggle to disabled. + app.handle_provider_add_key(KeyCode::Char(' ')); + app.handle_provider_add_key(KeyCode::Enter); + type_str(&mut app, "sk-secret-xyz"); + app.handle_provider_add_key(KeyCode::Enter); + + let p = crate::provider::load("thinker").expect("provider must be saved"); + assert_eq!( + p.codex_config, + vec![ + "model_reasoning_effort=medium".to_string(), + "web_search=disabled".to_string(), + ] + ); + } + + #[test] + fn provider_add_wizard_stays_on_step_for_a_bad_base_url() { + let _home = EnvHome::new(); + let mut app = App::new(); + app.open_provider_add(); + type_str(&mut app, "r"); + app.handle_provider_add_key(KeyCode::Enter); // alias accepted -> base URL step + type_str(&mut app, "ftp://nope"); + app.handle_provider_add_key(KeyCode::Enter); // rejected + + let state = app.provider_add.as_ref().expect("wizard stays open"); + assert_eq!(state.step, ProviderAddStep::BaseUrl); + } + + #[test] + fn request_and_confirm_remove_provider_deletes_it() { + let _home = EnvHome::new(); + let profile = crate::provider::ProviderProfile { + alias: "gone".into(), + provider_id: "gone".into(), + name: "Gone".into(), + base_url: "https://example.com/v1".into(), + env_key: "CODEX_SWITCH_GONE_KEY".into(), + model: "m".into(), + wire_api: "responses".into(), + codex_config: Vec::new(), + api_key: "k".into(), + }; + crate::provider::save(&profile).unwrap(); + + let mut app = App::new(); + app.load_profiles(); + app.active_tab = Tab::Providers; + app.provider_selected = 0; + assert!(app.providers.iter().any(|x| x.alias == "gone")); + + app.request_remove_provider(); + assert!( + matches!(&app.confirm, Some(ConfirmAction::RemoveProvider(a)) if a == "gone"), + "remove must ask for confirmation first" + ); + app.confirm_action(); + assert!(!crate::provider::exists("gone")); + assert!(app.providers.iter().all(|x| x.alias != "gone")); + } #[test] fn cancelled_batch_counts_the_current_account_as_attempted() { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 5402a1a..1fe4e3a 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -6,7 +6,7 @@ use ratatui::{ widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, }; -use super::app::{App, UsageStatus}; +use super::app::{App, Tab, UsageStatus}; use super::keymap; use super::popup; use crate::jwt::PlanKind; @@ -47,28 +47,40 @@ pub fn render(f: &mut Frame, app: &mut App) { let status_height = status_bar_height(app, area.width); - let detail_height = if app.detail_visible { - detail_panel_height(app).min( - area.height - .saturating_sub(status_height as u16) - .saturating_sub(6), - ) - } else { - 0 - }; let vertical = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(6), // account list - Constraint::Length(detail_height), // detail panel + Constraint::Length(1), // tab bar + Constraint::Min(6), // active tab content Constraint::Length(status_height as u16), // status bar ]) .split(area); - render_account_table(f, app, vertical[0]); - if app.detail_visible { - render_detail_panel(f, app, vertical[1]); + render_tab_bar(f, app, vertical[0]); + + match app.active_tab { + Tab::Accounts => { + let content = vertical[1]; + let detail_height = if app.detail_visible { + detail_panel_height(app).min(content.height.saturating_sub(6)) + } else { + 0 + }; + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(6), // account list + Constraint::Length(detail_height), // detail panel + ]) + .split(content); + render_account_table(f, app, rows[0]); + if app.detail_visible { + render_detail_panel(f, app, rows[1]); + } + } + Tab::Providers => render_providers_tab(f, app, vertical[1]), } + render_status_bar(f, app, vertical[2]); // Overlays (rendered last, on top of everything). @@ -782,6 +794,107 @@ pub(super) fn reset_cards_color(u: &UsageInfo) -> Color { } } +/// Top tab bar: Accounts (ChatGPT OAuth) vs Providers (third-party API+key). +fn render_tab_bar(f: &mut Frame, app: &App, area: Rect) { + let active = base().fg(BG).bg(C_CYAN).add_modifier(Modifier::BOLD); + let inactive = base().fg(C_GRAY); + let (accounts_style, providers_style) = match app.active_tab { + Tab::Accounts => (active, inactive), + Tab::Providers => (inactive, active), + }; + let line = Line::from(vec![ + Span::styled( + format!(" Accounts ({}) ", app.accounts.len()), + accounts_style, + ), + Span::raw(" "), + Span::styled( + format!(" Providers ({}) ", app.providers.len()), + providers_style, + ), + Span::styled(" Tab to switch", base().fg(DIM)), + ]); + f.render_widget(Paragraph::new(line).style(base()), area); +} + +/// Providers tab: read-only list of configured custom API providers. The stored +/// API key is never rendered. +fn render_providers_tab(f: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .title(" Custom providers ") + .borders(Borders::ALL) + .border_style(base().fg(C_BLUE)) + .style(base()); + let inner = block.inner(area); + f.render_widget(block, area); + + if app.providers.is_empty() { + let hint = Paragraph::new(Line::from(vec![ + Span::styled("No custom providers. Add one with ", base().fg(DIM)), + Span::styled( + "codex-switch provider add", + base().fg(C_YELLOW).add_modifier(Modifier::BOLD), + ), + Span::styled(".", base().fg(DIM)), + ])) + .style(base()); + f.render_widget(hint, inner); + return; + } + + let header = Row::new(vec![ + Cell::from(" "), + Cell::from("Alias"), + Cell::from("Name"), + Cell::from("Model"), + Cell::from("Base URL"), + ]) + .style(base().fg(C_CYAN).add_modifier(Modifier::BOLD)); + + let rows: Vec = app + .providers + .iter() + .enumerate() + .map(|(i, p)| { + let selected = i == app.provider_selected; + let text_style = if selected { + base().fg(C_WHITE).add_modifier(Modifier::BOLD) + } else { + base().fg(C_GRAY) + }; + Row::new(vec![ + Cell::from(if selected { "\u{25b6}" } else { " " }).style(base().fg(C_GREEN)), + Cell::from(p.alias.clone()).style(text_style), + Cell::from(p.name.clone()).style(text_style), + Cell::from(p.model.clone()).style(base().fg(C_CYAN)), + Cell::from(p.base_url.clone()).style(base().fg(DIM)), + ]) + .height(1) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(2), + Constraint::Length(20), + Constraint::Length(24), + Constraint::Length(28), + Constraint::Min(20), + ], + ) + .header(header) + .row_highlight_style( + Style::default() + .bg(C_HIGHLIGHT_BG) + .add_modifier(Modifier::BOLD), + ) + .style(base()); + + let mut state = TableState::default().with_selected(app.provider_selected); + f.render_stateful_widget(table, inner, &mut state); +} + /// Compact pay-per-use credits balance for the table column. Mirrors the CLI /// `print_usage_line` wording: "unlimited" for unmetered accounts, a dollar /// amount when a balance is reported, and "--" when the account does not use @@ -816,6 +929,58 @@ pub(super) fn credits_table_color(u: &UsageInfo) -> Color { } fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { + // Add-provider wizard prompt takes top priority. + if let Some(state) = &app.provider_add { + use super::app::{ProviderAddStep, REASONING_CHOICES}; + let label = Span::styled( + format!(" Add provider [{}]: ", state.step.prompt()), + base().fg(C_CYAN).add_modifier(Modifier::BOLD), + ); + let line = match state.step { + ProviderAddStep::Reasoning => { + let choice = REASONING_CHOICES[state.reasoning_idx]; + Line::from(vec![ + label, + Span::styled( + format!("< {choice} >"), + base().fg(C_WHITE).add_modifier(Modifier::BOLD), + ), + Span::styled(" (←/→ choose, Enter next / Esc cancel)", base().fg(DIM)), + ]) + } + ProviderAddStep::WebSearch => { + let value = if state.no_web_search { + "disabled" + } else { + "enabled (default)" + }; + Line::from(vec![ + label, + Span::styled( + format!("[{value}]"), + base().fg(C_WHITE).add_modifier(Modifier::BOLD), + ), + Span::styled(" (Space toggle, Enter next / Esc cancel)", base().fg(DIM)), + ]) + } + _ => { + let shown = if state.step.is_secret() { + "*".repeat(state.input.chars().count()) + } else { + state.input.clone() + }; + Line::from(vec![ + label, + Span::styled(shown, base().fg(C_WHITE).add_modifier(Modifier::BOLD)), + Span::styled("#", base().fg(C_GRAY)), + Span::styled(" (Enter next / Esc cancel)", base().fg(DIM)), + ]) + } + }; + f.render_widget(Paragraph::new(line).style(base()), area); + return; + } + // Rename input takes top priority if let Some(rs) = &app.rename { let line = Line::from(vec![ @@ -844,6 +1009,9 @@ fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { "Confirm reset card for '{alias}' expiring {expires_at}: y to use, any other key cancels" ) } + super::app::ConfirmAction::RemoveProvider(alias) => { + format!("Remove provider '{alias}'? (y/n)") + } }; let line = Line::from(Span::styled( msg, @@ -887,6 +1055,28 @@ fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { Span::styled(" to clear", base().fg(DIM)), ]); f.render_widget(Paragraph::new(line).style(base()), area); + } else if app.active_tab == Tab::Providers { + let key = + |k: &'static str| Span::styled(k, base().fg(C_YELLOW).add_modifier(Modifier::BOLD)); + let dim = |t: &'static str| Span::styled(t, base().fg(DIM)); + let line = Line::from(vec![ + dim(" "), + key("j"), + dim("/"), + key("k"), + dim(" nav \u{2502} "), + key("a"), + dim(" add \u{2502} "), + key("d"), + dim(" remove \u{2502} "), + key("Tab"), + dim(" accounts \u{2502} "), + key("h"), + dim(" help \u{2502} "), + key("q"), + dim(" quit"), + ]); + f.render_widget(Paragraph::new(line).style(base()), area); } else { let lines = build_help_lines(area.width as usize); f.render_widget(Paragraph::new(lines).style(base()), area); @@ -1230,12 +1420,16 @@ fn format_auto_refresh_remaining(secs: u64) -> String { fn status_bar_height(app: &App, width: u16) -> usize { if app.status_msg.is_some() || app.rename.is_some() + || app.provider_add.is_some() || app.confirm.is_some() || app.search_active || !app.marked.is_empty() { return 1; } + if app.active_tab == Tab::Providers { + return 1; + } build_help_lines(width as usize).len() } @@ -1272,6 +1466,42 @@ mod tests { assert_eq!(status_message_color(true), C_RED); } + #[test] + fn providers_tab_lists_custom_providers_without_the_key() { + let mut app = App::new(); + app.active_tab = crate::tui::app::Tab::Providers; + app.providers.push(crate::provider::ProviderProfile { + alias: "openrouter".into(), + provider_id: "openrouter".into(), + name: "OpenRouter".into(), + base_url: "https://openrouter.ai/api/v1".into(), + env_key: "CODEX_SWITCH_OPENROUTER_KEY".into(), + model: "openai/gpt-5.3-codex".into(), + wire_api: "responses".into(), + codex_config: Vec::new(), + api_key: "sk-secret-1234".into(), + }); + + let backend = TestBackend::new(120, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| super::render(f, &mut app)).unwrap(); + + let joined = (0..30) + .map(|y| row_text(terminal.backend(), y)) + .collect::>() + .join("\n"); + assert!( + joined.contains("Custom providers"), + "the panel header must render:\n{joined}" + ); + assert!(joined.contains("openrouter")); + assert!(joined.contains("https://openrouter.ai/api/v1")); + assert!( + !joined.contains("sk-secret-1234"), + "the API key must never render in the panel" + ); + } + #[test] fn reset_card_column_distinguishes_refreshing_and_cooling_down() { let usage = UsageInfo::default(); diff --git a/tests/test_distribution_contract.rs b/tests/test_distribution_contract.rs index 75a80b0..b42c8f7 100644 --- a/tests/test_distribution_contract.rs +++ b/tests/test_distribution_contract.rs @@ -366,6 +366,7 @@ fn wiki_navigation_is_task_oriented_and_progressive() { "FAQ.md", "Feature-Guide.md", "Getting-Started.md", + "Providers.md", "Troubleshooting.md", "Updating.md", ] {