Compare commits

..

24 Commits

Author SHA1 Message Date
SaladDay 0acce5477a Merge main and harden managed Codex account flow 2026-07-18 10:06:58 +00:00
滅ü 997be22bfa fix(tray): detect system locale for first-run language instead of hardcoding zh (#4355)
The tray menu used a hardcoded zh (Simplified Chinese) fallback whenever
settings.language was unset (i.e. first install). On systems whose UI language
resolves to Traditional Chinese / Japanese / English via the frontend's
navigator-based detection, the tray therefore showed Simplified Chinese until
the user manually switched language once.

Detect the OS locale via the sys-locale crate and map it to a supported tray
language, mirroring the frontend getInitialLanguage precedence
(zh-TW/HK/MO/Hant -> zh-TW, other zh -> zh, ja -> ja, en -> en, otherwise zh).
This keeps the tray consistent with the UI from the first launch with no timing
window. An explicitly stored settings.language still takes precedence, so user
choices are never overridden. Adds unit tests for the locale mapping.

Co-authored-by: LaiYueTing <LaiYueTing@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-07-17 21:35:10 +08:00
Allen Xu edea624a27 fix(skills): preserve deleted default repositories (#5356)
* fix(skills): preserve deleted default repositories

* fix(skills): persist default repo initialization state
2026-07-17 15:51:29 +08:00
Thefool 1c0ee0c58a feat(grokbuild): add first-class Grok Build support (#5453)
* feat(grokbuild): add backend integration

* feat(grokbuild): add provider and management UI

* test(grokbuild): cover configuration and integrations

* fix(grokbuild): address backend review feedback

* fix(grokbuild): complete UI review feedback

* feat(grokbuild): add CLI lifecycle management

* fix(grokbuild): align provider icon fallback

* test(grokbuild): cover provider icon persistence
2026-07-17 15:50:50 +08:00
zayoka f6e37ed994 fix(ci): run backend checks on Windows/macOS and repair platform-gated tests (#5138)
* fix(ci): run backend checks on Windows/macOS and repair platform-gated tests

The backend CI job ran only on ubuntu-22.04, so every #[cfg(target_os =
"windows")] / macOS path — tests and clippy lints alike — was never
compiled or run. As a result main shipped 8 failing Windows unit tests and
a hard `cargo clippy -- -D warnings` failure on Windows, all invisible to
CI because it only builds and tests Linux.

Changes:

- ci: run the backend job on a {ubuntu-22.04, windows-latest, macos-latest}
  matrix (fail-fast: false); gate the apt install step on runner.os ==
  'Linux' and use `shell: bash` for the dist placeholder so it works on all
  three runners.

- misc.rs: fix 6 stale `anchored_upgrade_windows` tests. Commit 5092fe51
  removed codex from `prefers_official_update`, so codex now anchors to the
  package manager with no `codex update ||` prefix; update the five
  package-manager expectations accordingly, and retarget the no-sibling
  "official-update-without-fallback" test to `claude` (still in the list) so
  that branch stays covered instead of being silently dropped.

- codex_state_db.rs / codex_history_migration.rs: the two sqlite_home tests
  built TOML basic strings from Windows paths, whose backslashes are invalid
  escape sequences and made the override fail to parse; switch to TOML
  literal (single-quoted) strings so the path is taken verbatim.

- clippy (13 Windows-only warnings): move `use std::io::Write` into the
  #[cfg(unix)] block that actually uses it; convert 3 unneeded `return`s in
  Windows-gated tail positions to expressions; mark 9 POSIX-shell helpers
  #[cfg_attr(windows, allow(dead_code))] since they are used only by the
  macOS/Linux terminal launchers.

Verified locally (Windows 11, Rust 1.95.0):
  cargo test --lib                        → 1748 passed; 0 failed (was 1740/8)
  cargo clippy -- -D warnings             → clean (was 13 errors)
  cargo fmt --check                       → clean

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(skills): resolve home via get_home_dir so tests isolate on Windows

services/skill.rs called dirs::home_dir() directly in five places. On
Windows dirs::home_dir() resolves through the Known Folder API and
ignores HOME/USERPROFILE, so the CC_SWITCH_TEST_HOME override set by the
test harness never applied: the skill_sync integration tests scanned the
runner's real user profile, found no skills, and failed (the first panic
then poisoned the shared test mutex, cascading to all seven). On Unix
the harness also sets HOME, which dirs::home_dir() honors, so the suite
passed there by accident.

Route all five call sites through crate::config::get_home_dir(), the
crate-wide home resolver that prefers CC_SWITCH_TEST_HOME. The three
now-unreachable GET_HOME_DIR_FAILED error branches are dropped:
get_home_dir() is infallible, matching every other config-dir resolver.
Production behavior is unchanged (CC_SWITCH_TEST_HOME is unset outside
tests, so get_home_dir falls through to dirs::home_dir).

Add a regression test that discriminates on every platform by pointing
CC_SWITCH_TEST_HOME at a temp dir different from $HOME; it is marked
#[serial_test::serial] to stay mutually exclusive with the other tests
mutating the same process-global variable. Verified the test fails
against the old code on macOS with the same failure mode as Windows CI.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-07-15 10:33:22 +08:00
Jason 1cc52c7e73 docs(readme): add SubRouter sponsor entry across four locales 2026-07-14 22:46:19 +08:00
SaladDay 6d316c0bda fix(codex): preserve streamed tool call identity and order (#5310)
Keep non-empty call IDs across continuation deltas and release parallel tool calls in Chat index order when identity fields arrive late. Preserve valid sparse and later calls during finalization.
2026-07-14 20:01:10 +08:00
Ryan2128 9ca1a41f58 fix: normalize function parameters type to "object" for strict OpenAI-compatible providers (#4706)
* fix: normalize function parameters type to "object" for strict OpenAI-compatible providers

Some Responses tools carry parameters with `type: null` (e.g.
codex_app__automation_update), causing HTTP 400 from strict
OpenAI-compatible providers like DeepSeek that require
`{"type": "object", "properties": {...}}`.

This adds normalize_function_parameters() to ensure the type
field is always "object" in both branches of
responses_function_tool_to_chat_tool.

Closes #4705

* style: fix cargo fmt issues

* fix: handle null function parameters
2026-07-14 19:23:41 +08:00
Jason c8b0d60c2d docs(guides): add Codex + Claude local routing guide in three languages
Step-by-step guide for the v3.17.0 native Anthropic Messages upstream:
add a custom Codex provider pointed at a Claude-family /v1/messages
gateway, pick the anthropic upstream format, enable local routing, and
verify the chain. Covers the auth-field choice (Bearer vs x-api-key),
the Claude Code impersonation toggle, the 8192 max-output fallback, and
a FAQ entry for gateways that restrict keys to Claude Code only.

Includes four UI screenshots captured from the real 3.17.0 app with
sample data, and links the guide from the v3.17.0 release notes in all
three languages.
2026-07-14 11:53:55 +08:00
Jason 7e73a1ffb2 fix(i18n): add missing proxyReasonAnthropicMessages key across locales
The routing-required toast for Anthropic-format Codex providers only had
a Chinese defaultValue in code; non-Chinese locales showed a mixed-language
message. Add the reason string to zh/en/ja/zh-TW following the phrasing of
the sibling proxyReason* keys.
2026-07-14 11:53:39 +08:00
SaladDay d6ebc24cb3 fix(codex-oauth): keep managed token in restore backup; fix failing tests
CI caught three test failures from the previous commit. Root cause: the Live
backup (proxy_live_backup) is local restore state that is replayed to
~/.codex/auth.json when proxy takeover ends, so it must contain the real auth to
restore a working login. Stripping it (the earlier "don't leak into backup"
change) broke restore and over-stripped a user's native login.

- Revert the backup auth stripping: remove sanitize_codex_backup_auth from the
  initial/strict snapshot paths and the forced auth={} in
  update_live_backup_from_provider_inner. The managed token belongs in the
  restore backup (the refresh_token is already persisted by CodexOAuthManager, so
  this is not a new exposure).
- Restore the ownership marker (account_id + access_token fingerprint) so backup
  cleanup can tell our managed write from a user's native `codex login` of the
  same account. extract now also tolerates the full-bundle shape (refresh_token +
  last_refresh) so it fingerprints ①'s refreshable writes. clear_codex_auth_in_backup
  and clear_codex_live_auth_for_managed_account use the marker again; the
  account_id-only helper is dropped.
- Fix the adopt unit test: adopt now invalidates the cached access token, so the
  test asserts the stored refresh_token/id_token were updated and the cache was
  cleared instead of reading it back through get_valid_token_bundle (which would
  trigger a network refresh).

Token stays out of the exported provider settings_config (backfill strip) — only
the local restore backup keeps it, matching pre-existing behavior.
2026-07-01 15:43:49 +00:00
SaladDay c72bd49213 fix(codex-oauth): make managed accounts safe & usable with the bare CLI
Addresses review feedback on #3879. These backend changes are deeply
intertwined across shared files, so they are committed together.

Refreshable managed auth (bare `codex` support):
- Write a full, native-shaped auth.json bundle (tokens.refresh_token + top-level
  last_refresh) instead of an access-only token, so the Codex CLI can self-refresh
  and the managed account keeps working past ~1h without a proxy.
- last_refresh reflects the access token's real obtained-at time (not write time),
  so the CLI doesn't treat a cached token as freshly refreshed.
- Read back the CLI-rotated refresh_token from ~/.codex/auth.json before writing
  (account_id-matched, chatgpt-mode guarded) so re-switching doesn't clobber the
  CLI's valid login with a stale refresh_token.

Never persist managed tokens at rest:
- Backfill of a managed provider always replaces live auth with the stored
  placeholder (even on marker mismatch), so native tokens can't leak into the
  provider's DB config.
- Sanitize managed auth out of Live backups on the initial/strict snapshot paths
  and in update_live_backup_from_provider_inner before serialization.

Concurrency / blocking:
- Drop the redundant outer Arc<RwLock<CodexOAuthManager>> (all methods are &self)
  so token refresh no longer holds a coarse lock across the network; give OAuth
  token/device requests a 30s timeout instead of the shared 600s client default.
- Add a persistence lock and linearize the access-token cache under a consistent
  accounts -> access_tokens order (existence-checked reads/writes; remove/clear
  clear the cache atomically) to prevent stale or resurrected cache entries.

Switch ordering:
- Preflight the managed token before committing the current provider on
  switch/add/update, so a failed token fetch can't leave DB/UI pointing at a
  provider whose live config was never written.

Cleanup:
- Remove the now-dead content-fingerprint marker machinery; managed writes are
  now shape-identical to a native login, so ownership is judged by account_id.

Known, documented limitations (B-scheme, narrow & recoverable): a remove+re-login
of the same account within an in-flight refresh window (ABA), a login authorized
after logout, and switch-away cleanup being unable to distinguish our write from a
user's native login of the same account.
2026-07-01 15:19:12 +00:00
SaladDay d3df16b5d1 fix(providers): don't clear managed OAuth binding on status-query failure
useManagedAuth now exposes isStatusSuccess. The account-clear effect in the
Codex/Copilot selector sections only unbinds when the status query has
*successfully* loaded and the bound account is genuinely gone. Previously a
failed or still-pending query yielded an empty accounts array, which silently
cleared the selection; the subsequent save then dropped providerType/authBinding
and turned a managed provider into a broken unbound config.

Addresses review feedback on #3879.
2026-07-01 15:18:53 +00:00
SaladDay eca472dbf7 fix(i18n): add missing managed-account selector keys
The managed OAuth account selector referenced codexOauth.chatgptAccount,
codexOauth.manageAccounts, copilot.githubAccount, copilot.manageAccounts and
providerForm.providerKeyStatusLoading with Chinese defaultValue fallbacks, so
the en/ja/zh-TW UIs rendered Chinese text. Add the translations across all four
locales.

Addresses review feedback on #3879.
2026-07-01 15:18:45 +00:00
Jason ef731c9919 Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector 2026-07-01 17:04:59 +08:00
SaladDay 3928c590a9 style(codex): satisfy prettier format check for reauth UI 2026-06-23 17:05:27 +00:00
SaladDay fc5fa85807 feat(codex): write managed ChatGPT id_token into live auth + reauth prompt
Bind the selected ChatGPT account's id_token into the Codex official
managed auth.json so it matches a native browser login for the fields
that drive behavior (auth_mode, account/plan/email via id_token claims).

- Persist id_token on CodexAccountData; capture at login and on refresh
  (empty string treated as missing). Add get_valid_token_and_id_token_for_account.
- codex_managed_oauth_live_auth now writes tokens.id_token when available.
- Keep the managed-vs-native safety marker intact: extract_codex_managed_oauth_auth
  tolerates id_token but still rejects native logins (which carry refresh_token /
  top-level last_refresh), so cc-switch never clears a real browser login.
  refresh_token and last_refresh are intentionally NOT written for this reason.
- Surface reauth_required (account has no stored id_token) through
  GitHubAccount -> ManagedAuthAccount -> TS. Legacy accounts get a styled
  amber prompt + one-click re-login (device flow) in CodexOAuthSection,
  flagged in both the selector dropdown and the select-mode hint.
- i18n: reauth strings added for en / ja / zh / zh-TW.

Verified: pnpm typecheck + build:renderer green; cross-model review of Rust
correctness, the managed-vs-native safety invariant, and the id_token lifecycle.
2026-06-23 16:59:24 +00:00
SaladDay 0aeca9a235 Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector
# Conflicts:
#	src/components/providers/forms/ProviderForm.tsx
2026-06-23 15:54:43 +00:00
Jason 71d3128db7 fix(i18n): localize Codex managed-account none option label
ProviderForm passed a hardcoded Chinese string as codexOauthNoneOptionLabel,
so en/ja/zh-TW users saw Chinese text in the Codex account selector's
"none" option. Add codexOauth.noneOptionLabel to all four locales and
resolve it via t().
2026-06-15 20:55:21 +08:00
Jason 44cc8245ab fix(provider-form): show Codex FAST mode toggle in account-select mode
The FAST mode switch was gated on mode === "manage", but the Claude and
Claude Desktop Codex OAuth forms render CodexOAuthSection in select mode
while still passing onFastModeChange. The toggle was therefore hidden:
new providers couldn't enable FAST mode and existing ones couldn't turn
it off, even though the backend still honors meta.codexFastMode. Gate the
toggle on onFastModeChange presence instead, restoring the pre-refactor
behavior without affecting the manage panel or native Codex form (which
do not pass the handler).
2026-06-15 20:55:14 +08:00
Jason 7cbdae9eca Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector
# Conflicts:
#	src-tauri/src/services/provider/live.rs
#	src-tauri/src/services/proxy.rs
2026-06-15 15:04:01 +08:00
saladday 84801c7ed2 Clarify unmanaged Codex login option 2026-06-08 12:43:32 +08:00
saladday 39ecccdbdf Consolidate managed account entry points 2026-06-08 12:43:32 +08:00
saladday 7480cec0ba Add managed ChatGPT account binding for Codex 2026-06-08 04:39:53 +08:00
153 changed files with 10457 additions and 858 deletions
+7 -1
View File
@@ -55,7 +55,11 @@ jobs:
backend:
name: Backend Checks
runs-on: ubuntu-22.04
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -66,6 +70,7 @@ jobs:
components: rustfmt, clippy
- name: Install Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
@@ -87,6 +92,7 @@ jobs:
restore-keys: ${{ runner.os }}-cargo-
- name: Create frontend dist placeholder
shell: bash
run: mkdir -p dist
- name: Check Rust formatting
+18 -13
View File
@@ -2,7 +2,7 @@
# CC Switch
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -69,6 +69,11 @@ Unlike typical API relay services, TeamoRouter aggregates hundreds of official m
TeamoRouter also offers enterprise features including centralized billing, team management, BYOK, smart routing, usage analytics, dynamic provider optimization, and dedicated support. For an even simpler experience, Teamo Desktop lets you use Claude Code, Codex, Gemini CLI, and other popular AI agents with one-click setup—no API key management or manual gateway configuration required. Register via <a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">this link</a> as a new user to receive 10% off your first top-up.</td>
</tr>
<tr>
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
<td>Thanks to ZetaAPI for sponsoring this project! ZetaAPI focuses on real model fidelity, no watered-down responses, no quality degradation, and pricing as low as 35% of official rates. The platform does not mix traffic, secretly replace models with lower-quality alternatives, or use fake model routing. It supports Claude Code, Codex, Gemini, ChatGPT, and other mainstream AI models, helping users significantly reduce API costs while maintaining reliable model quality. At the same time, ZetaAPI provides enterprise-grade SLA-backed stability, standard API compatibility, one API key for multiple models, fast integration, and pay-as-you-go billing, making it suitable for AI products, coding agents, internal business tools, customer service systems, content generation, and automation workflows. If any model is verified to be inconsistent with its stated quality, ZetaAPI backs it with a 10x compensation guarantee, giving users a more stable, transparent, and trustworthy experience. Register via <a href="https://zetaapi.ai/go/ccs">this link</a> and use the promo code CC-SWITCH during your first recharge to enjoy an exclusive 10% discount on your first top-up, just for CC Switch users!</td>
</tr>
<tr>
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a fullmodal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, longtask execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, endtoend complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
@@ -169,11 +174,6 @@ TeamoRouter also offers enterprise features including centralized billing, team
<td>Thanks to Fenno.ai for sponsoring this project! Fenno.ai is a stable and efficient API relay service provider, currently focused on Codex relay. It is compatible with the OpenAI and Anthropic protocols and can be flexibly used from Codex, Claude Code, OpenCode, and other mainstream coding tools. It reliably supports enterprise-grade workloads of hundreds of billions of tokens per day, with corporate (B2B) settlement and invoicing for both domestic and overseas entities. Fenno.ai offers an exclusive benefit for CC Switch users: subscribe via <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">this link</a> to the incredible ¥9.9 Coding Plan worth $150 in credits, and earn up to 20% in referral rewards — invite more, earn more!</td>
</tr>
<tr>
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
<td>Thanks to ZetaAPI for sponsoring this project! ZetaAPI focuses on real model fidelity, no watered-down responses, no quality degradation, and pricing as low as 35% of official rates. The platform does not mix traffic, secretly replace models with lower-quality alternatives, or use fake model routing. It supports Claude Code, Codex, Gemini, ChatGPT, and other mainstream AI models, helping users significantly reduce API costs while maintaining reliable model quality. At the same time, ZetaAPI provides enterprise-grade SLA-backed stability, standard API compatibility, one API key for multiple models, fast integration, and pay-as-you-go billing, making it suitable for AI products, coding agents, internal business tools, customer service systems, content generation, and automation workflows. If any model is verified to be inconsistent with its stated quality, ZetaAPI backs it with a 10x compensation guarantee, giving users a more stable, transparent, and trustworthy experience. Register via <a href="https://zetaapi.ai/go/ccs">this link</a> and use the promo code CC-SWITCH during your first recharge to enjoy an exclusive 10% discount on your first top-up, just for CC Switch users!</td>
</tr>
<tr>
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
<td>Thanks to <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> for sponsoring this project! NekoCode provides developers with a stable, efficient, and reliable API relay service for Claude, Codex, and other AI models. With transparent pricing and flexible pay-as-you-go billing, it offers a simple and cost-effective way to access AI models. CC Switch users can enjoy an exclusive 10% discount: register via <a href="https://nekocode.ai?aff=CCSWITCH">this link</a> and enter promo code <code>cc-switch</code> during recharge to receive 10% off your top-up!</td>
@@ -184,19 +184,24 @@ TeamoRouter also offers enterprise features including centralized billing, team
<td>Thanks to the open-source AI infrastructure project <a href="https://www.newapi.ai/">new-api</a> for its strong support of this project! new-api is an open-source AI infrastructure project from QuantumNous and one of the leading unified LLM access-and-distribution projects by activity and adoption, focused on helping developers, teams, and enterprises build manageable, scalable AI service platforms at lower cost. As a fellow project rooted in the open-source ecosystem, new-api hopes to sponsor and support the continued growth of more outstanding open-source projects. 🌟 Star new-api to show your support: <a href="https://github.com/QuantumNous/new-api">https://github.com/QuantumNous/new-api</a>. Website: <a href="https://www.newapi.ai/">https://www.newapi.ai/</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://subrouter.ai/register?aff=l3ri"><img src="assets/partners/logos/subrouter-banner.png" alt="SubRouter" width="150"></a></td>
<td>Thanks to SubRouter for sponsoring this project! SubRouter is a marketplace and smart routing platform for AI service operators. Merchants can launch operating sites, publish packages, manage users, models, and pricing, while users discover services and access reliable AI models through one unified API. Register via <a href="https://subrouter.ai/register?aff=l3ri">this link</a>!</td>
</tr>
</table>
</details>
## Why CC Switch?
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
**CC Switch** gives you a single desktop app to manage all supported AI tools. Instead of editing config files by hand, you get a visual interface to import providers with one click, switch between them instantly, with 50+ built-in provider presets, unified MCP and Skills management, and system tray quick switching — all backed by a reliable SQLite database with atomic writes that protect your configs from corruption.
- **One App, Seven Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes from a single interface
- **One App, Eight Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes from a single interface
- **No More Manual Editing** — 50+ provider presets including AWS Bedrock, NVIDIA NIM, and community relays; just pick and switch
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync
- **System Tray Quick Switch** — Switch providers instantly from the tray menu, no need to open the full app
- **Cloud Sync** — Sync provider data across devices via Dropbox, OneDrive, iCloud, or WebDAV servers
- **Cross-Platform** — Native desktop app for Windows, macOS, and Linux, built with Tauri 2
@@ -214,18 +219,18 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
### Provider Management
- **7 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; copy your key and import with one click
- **8 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes; copy your key and import with one click
- **Universal providers** — One config syncs to Claude Code, Codex, and Gemini CLI
- One-click switching, system tray quick access, drag-and-drop sorting, import/export
### Proxy & Failover
- **Local proxy with hot-switching** — Format conversion, auto-failover, circuit breaker, provider health monitoring, and request rectifier
- **App-level takeover** — Independently proxy Claude, Codex, or Gemini, down to individual providers
- **App-level takeover** — Independently proxy Claude, Codex, Gemini, or Grok Build, down to individual providers
### MCP, Prompts & Skills
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync and Deep Link import
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync and Deep Link import
- **Prompts** — Markdown editor with cross-app sync (CLAUDE.md / AGENTS.md / GEMINI.md) and backfill protection
- **Skills** — One-click install from GitHub repos or ZIP files, custom repository management, with symlink and file copy support
@@ -249,7 +254,7 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
<details>
<summary><strong>Which AI tools does CC Switch support?</strong></summary>
CC Switch supports seven tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
CC Switch supports eight tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
</details>
+18 -13
View File
@@ -2,7 +2,7 @@
# CC Switch
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -69,6 +69,11 @@ Anders als typische API-Relay-Dienste bündelt TeamoRouter Hunderte offizieller
TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team-Verwaltung, BYOK, intelligentes Routing, Nutzungsanalysen, dynamische Anbieter-Optimierung und dedizierten Support. Für ein noch einfacheres Erlebnis können Sie mit Teamo Desktop Claude Code, Codex, Gemini CLI und weitere beliebte KI-Agenten per Ein-Klick-Einrichtung nutzen — ohne Verwaltung von API-Schlüsseln oder manuelle Gateway-Konfiguration. Registrieren Sie sich als neuer Nutzer über <a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">diesen Link</a> und erhalten Sie 10 % Rabatt auf Ihre erste Aufladung.</td>
</tr>
<tr>
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
<td>Danke an ZetaAPI für die Unterstützung dieses Projekts! ZetaAPI legt den Fokus auf echte Modelltreue — keine verwässerten Antworten, keine Qualitätsminderung — und Preise von nur 35 % der offiziellen Tarife. Die Plattform mischt keinen Traffic, ersetzt Modelle nicht heimlich durch minderwertige Alternativen und nutzt kein gefälschtes Modell-Routing. Sie unterstützt Claude Code, Codex, Gemini, ChatGPT und weitere gängige KI-Modelle und hilft Nutzern, die API-Kosten deutlich zu senken und gleichzeitig eine zuverlässige Modellqualität zu gewährleisten. Gleichzeitig bietet ZetaAPI eine SLA-gestützte Stabilität auf Unternehmensniveau, Standard-API-Kompatibilität, einen API-Key für mehrere Modelle, schnelle Integration und nutzungsbasierte Abrechnung — geeignet für KI-Produkte, Coding-Agents, interne Unternehmenstools, Kundenservice-Systeme, Content-Erstellung und Automatisierungs-Workflows. Falls bei einem Modell nachgewiesen wird, dass es nicht der angegebenen Qualität entspricht, sichert ZetaAPI dies mit einer 10-fachen Entschädigungsgarantie ab und bietet Nutzern ein stabileres, transparenteres und vertrauenswürdigeres Erlebnis. Registrieren Sie sich über <a href="https://zetaapi.ai/go/ccs">diesen Link</a> und verwenden Sie bei Ihrer ersten Aufladung den Promo-Code CC-SWITCH, um als CC-Switch-Nutzer einen exklusiven Rabatt von 10 % auf Ihre erste Aufladung zu erhalten!</td>
</tr>
<tr>
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
@@ -169,11 +174,6 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
<td>Danke an Fenno.ai für die Unterstützung dieses Projekts! Fenno.ai ist ein stabiler und effizienter API-Relay-Dienstleister, der sich derzeit hauptsächlich auf Codex-Relay konzentriert. Er ist mit den OpenAI- und Anthropic-Protokollen kompatibel und lässt sich flexibel mit Codex, Claude Code, OpenCode und anderen gängigen Coding-Tools nutzen. Er unterstützt zuverlässig Workloads auf Unternehmensniveau von Hunderten Milliarden Tokens pro Tag und bietet B2B-Abrechnung sowie Rechnungsstellung für Unternehmen im In- und Ausland. Fenno.ai bietet einen exklusiven Vorteil für CC-Switch-Nutzer: Abonnieren Sie über <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">diesen Link</a> den unschlagbaren ¥9,9-Coding-Plan im Wert von $150 Guthaben und erhalten Sie bis zu 20% Empfehlungsprämien — je mehr Einladungen, desto mehr Belohnung!</td>
</tr>
<tr>
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
<td>Danke an ZetaAPI für die Unterstützung dieses Projekts! ZetaAPI legt den Fokus auf echte Modelltreue — keine verwässerten Antworten, keine Qualitätsminderung — und Preise von nur 35 % der offiziellen Tarife. Die Plattform mischt keinen Traffic, ersetzt Modelle nicht heimlich durch minderwertige Alternativen und nutzt kein gefälschtes Modell-Routing. Sie unterstützt Claude Code, Codex, Gemini, ChatGPT und weitere gängige KI-Modelle und hilft Nutzern, die API-Kosten deutlich zu senken und gleichzeitig eine zuverlässige Modellqualität zu gewährleisten. Gleichzeitig bietet ZetaAPI eine SLA-gestützte Stabilität auf Unternehmensniveau, Standard-API-Kompatibilität, einen API-Key für mehrere Modelle, schnelle Integration und nutzungsbasierte Abrechnung — geeignet für KI-Produkte, Coding-Agents, interne Unternehmenstools, Kundenservice-Systeme, Content-Erstellung und Automatisierungs-Workflows. Falls bei einem Modell nachgewiesen wird, dass es nicht der angegebenen Qualität entspricht, sichert ZetaAPI dies mit einer 10-fachen Entschädigungsgarantie ab und bietet Nutzern ein stabileres, transparenteres und vertrauenswürdigeres Erlebnis. Registrieren Sie sich über <a href="https://zetaapi.ai/go/ccs">diesen Link</a> und verwenden Sie bei Ihrer ersten Aufladung den Promo-Code CC-SWITCH, um als CC-Switch-Nutzer einen exklusiven Rabatt von 10 % auf Ihre erste Aufladung zu erhalten!</td>
</tr>
<tr>
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
<td>Vielen Dank an <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> für die Unterstützung dieses Projekts! NekoCode bietet Entwicklern einen stabilen, effizienten und zuverlässigen API-Relay-Dienst für Claude, Codex und weitere KI-Modelle. Mit transparenter Preisgestaltung und flexibler nutzungsbasierter Abrechnung bietet es einen einfachen und kostengünstigen Zugang zu KI-Modellen. CC-Switch-Nutzer erhalten einen exklusiven Rabatt von 10 %: Registrieren Sie sich über <a href="https://nekocode.ai?aff=CCSWITCH">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode <code>cc-switch</code> ein, um 10 % Rabatt auf Ihre Aufladung zu erhalten!</td>
@@ -184,19 +184,24 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
<td>Vielen Dank an das Open-Source-KI-Infrastrukturprojekt <a href="https://www.newapi.ai/">new-api</a> für die tatkräftige Unterstützung dieses Projekts! new-api ist ein Open-Source-KI-Infrastrukturprojekt von QuantumNous und eines der nach Aktivität und Verbreitung führenden Projekte für den einheitlichen Zugang zu und die Verteilung von LLMs, das sich darauf konzentriert, Entwicklern, Teams und Unternehmen beim Aufbau verwaltbarer und skalierbarer KI-Serviceplattformen zu geringeren Kosten zu helfen. Als ein ebenfalls im Open-Source-Ökosystem verwurzeltes Projekt möchte new-api durch Sponsoring die kontinuierliche Weiterentwicklung weiterer herausragender Open-Source-Projekte unterstützen. 🌟 Unterstützen Sie new-api mit einem Star: <a href="https://github.com/QuantumNous/new-api">https://github.com/QuantumNous/new-api</a>. Website: <a href="https://www.newapi.ai/">https://www.newapi.ai/</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://subrouter.ai/register?aff=l3ri"><img src="assets/partners/logos/subrouter-banner.png" alt="SubRouter" width="150"></a></td>
<td>Danke an SubRouter für die Unterstützung dieses Projekts! SubRouter ist ein Marktplatz und eine intelligente Routing-Plattform für Betreiber von KI-Diensten. Händler können eigene Betriebsseiten starten, Pakete veröffentlichen sowie Nutzer, Modelle und Preise verwalten, während Nutzer im Marktplatz Dienste entdecken und über eine einzige einheitliche API zuverlässige und effiziente Modellaufrufe nutzen. Registrieren Sie sich über <a href="https://subrouter.ai/register?aff=l3ri">diesen Link</a>!</td>
</tr>
</table>
</details>
## Warum CC Switch?
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
**CC Switch** gibt Ihnen eine einzige Desktop-App, um alle unterstützten KI-Werkzeuge zu verwalten. Statt Konfigurationsdateien von Hand zu bearbeiten, erhalten Sie eine visuelle Oberfläche, um Anbieter mit einem Klick zu importieren und sofort zwischen ihnen zu wechseln — mit 50+ integrierten Anbieter-Presets, einheitlicher MCP- und Skills-Verwaltung und schnellem Umschalten über das System-Tray. Das Ganze gestützt auf eine zuverlässige SQLite-Datenbank mit atomaren Schreibvorgängen, die Ihre Konfigurationen vor Beschädigung schützen.
- **Eine App, sieben Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
- **Eine App, acht Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
- **Kein manuelles Bearbeiten mehr** — 50+ Anbieter-Presets einschließlich AWS Bedrock, NVIDIA NIM und Community-Relays; einfach auswählen und umschalten
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung
- **Schnellumschaltung über System-Tray** — Wechseln Sie Anbieter sofort über das Tray-Menü, ohne die vollständige App öffnen zu müssen
- **Cloud-Synchronisierung** — Synchronisieren Sie Anbieterdaten geräteübergreifend über Dropbox, OneDrive, iCloud oder WebDAV-Server
- **Plattformübergreifend** — Native Desktop-App für Windows, macOS und Linux, gebaut mit Tauri 2
@@ -214,18 +219,18 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
### Anbieterverwaltung
- **7 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
- **8 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
- **Universelle Anbieter** — Eine Konfiguration synchronisiert sich mit Claude Code, Codex und Gemini CLI
- Umschaltung mit einem Klick, Schnellzugriff über System-Tray, Sortierung per Drag-and-drop, Import/Export
### Proxy & Failover
- **Lokaler Proxy mit Hot-Switching** — Formatkonvertierung, automatisches Failover, Circuit Breaker, Anbieter-Health-Monitoring und Request-Rectifier
- **Übernahme auf App-Ebene** — Claude, Codex oder Gemini unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
- **Übernahme auf App-Ebene** — Claude, Codex, Gemini oder Grok Build unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
### MCP, Prompts & Skills
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
- **Prompts** — Markdown-Editor mit App-übergreifender Synchronisierung (CLAUDE.md / AGENTS.md / GEMINI.md) und Backfill-Schutz
- **Skills** — Installation mit einem Klick aus GitHub-Repositorys oder ZIP-Dateien, Verwaltung eigener Repositorys, mit Unterstützung für Symlinks und Dateikopien
@@ -249,7 +254,7 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
<details>
<summary><strong>Welche KI-Werkzeuge unterstützt CC Switch?</strong></summary>
CC Switch unterstützt sieben Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
CC Switch unterstützt acht Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
</details>
+18 -13
View File
@@ -2,7 +2,7 @@
# CC Switch
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -69,6 +69,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーティング、利用状況分析、動的なプロバイダー最適化、専任サポートなどのエンタープライズ機能も提供しています。さらにシンプルな体験を求める場合は、Teamo Desktop を使うことで、Claude Code、Codex、Gemini CLI、その他の人気 AI エージェントをワンクリックで利用できます。API キー管理や手動のゲートウェイ設定は不要です。新規ユーザーとして<a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">こちらのリンク</a>から登録すると、初回チャージが 10% オフになります。</td>
</tr>
<tr>
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
<td>本プロジェクトをご支援いただいている ZetaAPI に感謝します!ZetaAPI は、モデル品質の忠実性、水増しなし、性能劣化なし、公式価格の 35% から利用できる低価格を主な特徴としています。プラットフォームはトラフィックの混在、低品質モデルへの密かな切り替え、虚偽のモデルルーティングを行わず、Claude Code、Codex、Gemini、ChatGPT などの主要 AI モデルに対応しており、モデル品質を維持しながら API 利用コストを大幅に削減できます。同時に、ZetaAPI はエンタープライズ級の SLA 安定性保証、標準 API 互換、1つの Key による複数モデル接続、迅速な導入、従量課金などの機能を提供し、AI プロダクト、コード生成、企業内ツール、カスタマーサポート、コンテンツ生成、自動化ワークフローなどの用途に適しています。万が一、モデル品質が表記内容と一致しないことが確認された場合、ZetaAPI は 10 倍補償保証を提供し、ユーザーがより安定して、透明性高く、安心して利用できる環境を実現します。<a href="https://zetaapi.ai/go/ccs">こちらのリンク</a>から登録し、初回チャージ時にプロモコード CC-SWITCH を使用すると、CC Switch ユーザー限定の初回チャージ 10% オフ特典をご利用いただけます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
@@ -169,11 +174,6 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
<td>Fenno.ai のご支援に感謝します!Fenno.ai は安定かつ高効率な API 中継サービスプロバイダーで、現在は主に Codex の中継を提供しています。OpenAI および Anthropic プロトコルに対応し、Codex・Claude Code・OpenCode などの主要なコーディングツールから柔軟に利用できます。1 日あたり数千億トークンというエンタープライズ級の呼び出し需要を安定して支え、国内外の法人による B2B 決済・請求書発行に対応しています。Fenno.ai は CC Switch 利用者限定の特典を用意しています:<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">こちらのリンク</a>から 9.9 元(150 ドル相当)のお得な Coding Plan を購入でき、友達紹介で最大 20% の特典がもらえます。紹介すればするほどお得です!</td>
</tr>
<tr>
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
<td>本プロジェクトをご支援いただいている ZetaAPI に感謝します!ZetaAPI は、モデル品質の忠実性、水増しなし、性能劣化なし、公式価格の 35% から利用できる低価格を主な特徴としています。プラットフォームはトラフィックの混在、低品質モデルへの密かな切り替え、虚偽のモデルルーティングを行わず、Claude Code、Codex、Gemini、ChatGPT などの主要 AI モデルに対応しており、モデル品質を維持しながら API 利用コストを大幅に削減できます。同時に、ZetaAPI はエンタープライズ級の SLA 安定性保証、標準 API 互換、1つの Key による複数モデル接続、迅速な導入、従量課金などの機能を提供し、AI プロダクト、コード生成、企業内ツール、カスタマーサポート、コンテンツ生成、自動化ワークフローなどの用途に適しています。万が一、モデル品質が表記内容と一致しないことが確認された場合、ZetaAPI は 10 倍補償保証を提供し、ユーザーがより安定して、透明性高く、安心して利用できる環境を実現します。<a href="https://zetaapi.ai/go/ccs">こちらのリンク</a>から登録し、初回チャージ時にプロモコード CC-SWITCH を使用すると、CC Switch ユーザー限定の初回チャージ 10% オフ特典をご利用いただけます!</td>
</tr>
<tr>
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
<td>本プロジェクトをご支援いただいている <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> に感謝します!NekoCode は、Claude や Codex などの AI モデルに対応した、安定性・効率性・信頼性に優れた API 中継サービスを提供しています。料金体系は明瞭で、柔軟な従量課金にも対応しています。CC Switch ユーザー限定の 10%オフ特典:<a href="https://nekocode.ai?aff=CCSWITCH">こちらのリンク</a> から登録し、チャージ時にクーポンコード <code>cc-switch</code> を入力すると、チャージが 10%オフになります!</td>
@@ -184,19 +184,24 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
<td>オープンソースの AI インフラプロジェクト <a href="https://www.newapi.ai/">new-api</a> による本プロジェクトへの多大なご支援に感謝します!new-api は QuantumNous(锟腾科技)が開発したオープンソースの AI インフラプロジェクトであり、活発さと利用規模の面でリードする LLM 統合アクセス・配信プロジェクトの一つで、開発者・チーム・企業がより低コストで管理・拡張可能な AI サービスプラットフォームを構築できるよう支援することに注力しています。同じくオープンソースエコシステムに根ざすプロジェクトとして、new-api はスポンサーシップを通じて、より多くの優れたオープンソースプロジェクトの継続的な発展を支援したいと考えています。🌟 new-api への Star で応援をお願いします:<a href="https://github.com/QuantumNous/new-api">https://github.com/QuantumNous/new-api</a>。公式サイト:<a href="https://www.newapi.ai/">https://www.newapi.ai/</a>。</td>
</tr>
<tr>
<td width="180"><a href="https://subrouter.ai/register?aff=l3ri"><img src="assets/partners/logos/subrouter-banner.png" alt="SubRouter" width="150"></a></td>
<td>本プロジェクトをご支援いただいている SubRouter に感謝します!SubRouter は、AI サービス事業者向けのマーケットプレイス兼スマートルーティングプラットフォームです。事業者は独立した運営サイトを立ち上げ、プランを公開し、ユーザー・モデル・価格を管理でき、ユーザーはマーケットでサービスを見つけ、統一された API を通じて安定かつ高効率なモデル呼び出しを利用できます。<a href="https://subrouter.ai/register?aff=l3ri">こちらのリンク</a>から登録してください!</td>
</tr>
</table>
</details>
## CC Switch を選ぶ理由
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
**CC Switch** は、対応する AI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。
- **1 つのアプリで 7 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
- **1 つのアプリで 8 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
- **手動編集は不要** -- AWS Bedrock、NVIDIA NIM、コミュニティリレーなど 50 以上のプロバイダプリセットを内蔵。選んで切り替えるだけ
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
- **システムトレイでクイック切り替え** -- トレイメニューから即座にプロバイダを切り替え。アプリを開く必要なし
- **クラウド同期** -- Dropbox、OneDrive、iCloud、または WebDAV サーバー経由でデバイス間のプロバイダデータを同期
- **クロスプラットフォーム** -- Tauri 2 で構築された Windows、macOS、Linux 対応のネイティブデスクトップアプリ
@@ -214,18 +219,18 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
### プロバイダ管理
- **7 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
- **8 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
- **ユニバーサルプロバイダ** -- 1 つの設定を Claude Code、Codex、Gemini CLI に同期
- ワンクリック切り替え、システムトレイクイックアクセス、ドラッグ&ドロップ並び替え、インポート/エクスポート
### プロキシ & フェイルオーバー
- **ローカルプロキシのホットスイッチ** -- フォーマット変換、自動フェイルオーバー、サーキットブレーカー、プロバイダヘルスモニタリング、リクエストレクティファイア
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini、Grok Build を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
### MCP、Prompts & Skills
- **統一 MCP パネル** -- Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
- **統一 MCP パネル** -- Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
- **Prompts** -- Markdown エディタ、クロスアプリ同期(CLAUDE.md / AGENTS.md / GEMINI.md)、バックフィル保護
- **Skills** -- GitHub リポジトリまたは ZIP ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応
@@ -249,7 +254,7 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
<details>
<summary><strong>CC Switch はどの AI ツールに対応していますか?</strong></summary>
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** の 7 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw**、**Hermes** の 8 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
</details>
+18 -13
View File
@@ -2,7 +2,7 @@
# CC Switch
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -69,6 +69,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK、智能路由、用量分析、动态提供商优化和专属支持。为了获得更简单的使用体验,Teamo Desktop 支持你一键使用 Claude Code、Codex、Gemini CLI 和其他热门 AI Agent,无需管理 API Key,也无需手动配置网关。新用户通过<a href="https://teamorouter.com/zh?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">此链接</a>注册,首次充值可享受 10% 折扣。</td>
</tr>
<tr>
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
<td>感谢 ZetaAPI 赞助本项目!ZetaAPI 主打模型不掺水、保真不降智、价格低至官方价 35 折,平台不混量、不暗中替换低质量模型、不做虚假路由,支持 Claude Code、Codex、Gemini、ChatGPT 等主流模型接入,帮助用户在保证模型质量的同时大幅降低 API 使用成本。同时,ZetaAPI 提供企业级 SLA 稳定性保障、标准接口兼容、一个 Key 接入多模型、快速集成、按量计费等能力,适用于 AI 产品、代码生成、企业内部工具、客服系统、内容生产和自动化流程等场景。若经验证发现模型质量与标称不符,ZetaAPI 承诺假一赔十,让用户用得更稳定、更透明、更放心。通过<a href="https://zetaapi.ai/go/ccs">此链接</a>注册,并在首次充值时使用优惠码 CC-SWITCH,即可享受 CC Switch 用户专属的首次充值九折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
@@ -170,11 +175,6 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
<td>感谢 Fenno.ai 赞助了本项目!Fenno.ai 是一家稳定、高效的 API 中转服务商,目前主要提供 Codex 中转服务,兼容 OpenAI 及 Anthropic 协议,可灵活接入 Codex、Claude Code、OpenCode 等主流编程工具,可稳定支撑千亿 Token/日的企业级调用需求,支持国内及海外主体公对公结算、开票。Fenno.ai 为 CC Switch 的用户提供了专属福利:通过<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">此链接</a>即可订阅 9.9 元/150 刀额度的超值 Coding Plan,邀请好友最高可享 20% 奖励,多邀多得!</td>
</tr>
<tr>
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
<td>感谢 ZetaAPI 赞助本项目!ZetaAPI 主打模型不掺水、保真不降智、价格低至官方价 35 折,平台不混量、不暗中替换低质量模型、不做虚假路由,支持 Claude Code、Codex、Gemini、ChatGPT 等主流模型接入,帮助用户在保证模型质量的同时大幅降低 API 使用成本。同时,ZetaAPI 提供企业级 SLA 稳定性保障、标准接口兼容、一个 Key 接入多模型、快速集成、按量计费等能力,适用于 AI 产品、代码生成、企业内部工具、客服系统、内容生产和自动化流程等场景。若经验证发现模型质量与标称不符,ZetaAPI 承诺假一赔十,让用户用得更稳定、更透明、更放心。通过<a href="https://zetaapi.ai/go/ccs">此链接</a>注册,并在首次充值时使用优惠码 CC-SWITCH,即可享受 CC Switch 用户专属的首次充值九折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
<td>感谢 <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> 赞助本项目!NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明,接入便捷,支持灵活的按量计费。CC Switch 用户专享 9 折福利:通过 <a href="https://nekocode.ai?aff=CCSWITCH">此链接</a> 注册,并在充值时输入优惠码 <code>cc-switch</code>,即可享受充值 9 折优惠!</td>
@@ -185,19 +185,24 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
<td>感谢开源 AI 基础设施项目 <a href="https://www.newapi.ai/">new-api</a> 对本项目的鼎力支持!new-api 是由 QuantumNous(锟腾科技)推出的开源 AI 基础设施项目,也是活跃度与使用规模领先的大模型统一接入与分发项目之一,专注于帮助开发者、团队和企业以更低成本构建可管理、可扩展的 AI 服务平台。作为同样扎根开源生态的项目,new-api 希望通过赞助支持更多优秀开源项目持续发展。🌟 欢迎 Star 支持 new-api<a href="https://github.com/QuantumNous/new-api">https://github.com/QuantumNous/new-api</a>,官网:<a href="https://www.newapi.ai/">https://www.newapi.ai/</a>。</td>
</tr>
<tr>
<td width="180"><a href="https://subrouter.ai/register?aff=l3ri"><img src="assets/partners/logos/subrouter-banner.png" alt="SubRouter" width="150"></a></td>
<td>感谢 SubRouter 赞助本项目!SubRouter 是面向 AI 服务经营者的公开市场与智能路由平台。商家可快速开通独立经营站,发布套餐、管理用户与模型价格;用户可在市场发现服务,并通过统一 API 获得稳定高效的模型调用。通过<a href="https://subrouter.ai/register?aff=l3ri">此链接</a>注册!</td>
</tr>
</table>
</details>
## 为什么选择 CC Switch
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
**CC Switch** 为你提供一个桌面应用来管理所有支持的 AI 工具。无需手动编辑配置文件,你将获得一个可视化界面,一键将供应商导入应用,一键在不同的供应商之间进行切换,内置 50+ 供应商预设、统一的 MCP, SKILLS 管理以及系统托盘即时切换功能——所有操作都基于可靠的 SQLite 数据库和原子写入机制,保护你的配置不被损坏。
- **一个应用,个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes
- **一个应用,个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes
- **告别手动编辑** — 50+ 供应商预设,包括 AWS Bedrock、NVIDIA NIM 和社区中转服务;一键即可切换
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
- **系统托盘快速切换** — 从托盘菜单即时切换供应商,无需打开完整应用
- **云同步** — 通过 Dropbox、OneDrive、iCloud 或 WebDAV 服务器在不同设备之间同步供应商数据
- **跨平台** — 基于 Tauri 2 构建的原生桌面应用,支持 Windows、macOS 和 Linux
@@ -215,18 +220,18 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
### 供应商管理
- **7 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
- **8 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
- **通用供应商** — 一份配置同步到 Claude Code、Codex 和 Gemini CLI
- 一键切换、系统托盘快速访问、拖拽排序、导入导出
### 代理与故障转移
- **本地代理热切换** — 格式转换、自动故障转移、熔断器、供应商健康监控和整流器
- **应用级代理接管** — 独立为 Claude、CodexGemini 配置代理,具体到单个供应商
- **应用级代理接管** — 独立为 Claude、CodexGemini 或 Grok Build 配置代理,具体到单个供应商
### MCP、Prompts 与 Skills
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
- **Prompts** — Markdown 编辑器,跨应用同步(CLAUDE.md / AGENTS.md / GEMINI.md),回填保护
- **Skills** — 从 GitHub 仓库或 ZIP 文件一键安装,自定义仓库管理,支持软连接和文件复制
@@ -250,7 +255,7 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
<details>
<summary><strong>CC Switch 支持哪些 AI 工具?</strong></summary>
CC Switch 支持个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
CC Switch 支持个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
</details>
Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

@@ -0,0 +1,129 @@
# Using Claude in Codex: CC Switch Local Routing Guide
> Applies to CC Switch 3.17.0 and later (the Anthropic Messages upstream was introduced in 3.17.0). This guide is based on the repository documentation and code, and uses a Claude-family relay gateway as the example. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key.
## Why local routing is needed
The newer Codex CLI targets the OpenAI Responses API, while the various Claude-family relay gateways and internal enterprise gateways expose the Anthropic Messages protocol — that is, `/v1/messages`. These two protocols use completely different request bodies, streaming events, and response structures, so putting such a gateway's endpoint directly into Codex configuration can only result in a request to `/responses` coming back 404.
This feature targets the scenario where all you have is a `/v1/messages` endpoint: you have a key for some Claude-family relay gateway and want to run Claude-family models with Codex's interaction style; or your company has banned the Claude Code client for compliance reasons and kept only an approved Claude-family gateway — the model itself is available, and all that's missing is a permitted client, which Codex can now fill.
CC Switch's approach is to keep Codex always talking to the local route and still sending Responses API requests; once the route detects that the active provider is Anthropic-format, it converts the request into Anthropic Messages for the upstream, then converts the response back into the Responses shape it returns to Codex.
![Needs routing marker in the Codex provider list](../images/codex-claude-routing/01-codex-providers-require-routing.png)
The chain has four main steps:
1. When Codex is taken over, the local configuration is written as `http://127.0.0.1:15721/v1`, and `wire_api = "responses"` is forcibly kept in place.
2. The provider's `anthropic` upstream format tells the route that the real upstream speaks the Anthropic Messages protocol.
3. The route rewrites `/responses` to `/v1/messages` and converts the Responses request body into an Anthropic request body.
4. After the upstream responds, the route converts the Anthropic JSON or SSE back into the Responses JSON/SSE that Codex understands — reasoning content, tool calls, and images are all within the conversion scope.
## Prerequisites
Prepare these three things first:
- CC Switch installed and able to start (3.17.0 or later).
- Codex CLI installed and run at least once, so the `~/.codex/` directory structure exists.
- An API key that can reach an Anthropic Messages protocol endpoint (`/v1/messages`) — from some Claude-family relay gateway, or an internal enterprise Claude gateway; follow the gateway's documentation for its endpoint and auth method. Note: some providers restrict their Claude API to Claude Code only, so such a key may error out when used through Codex — if you're unsure, check with your provider first.
The Codex tab currently has no built-in Anthropic preset, so the steps below use the `Custom Configuration` path — just four or five fields from start to finish.
## Step 1: Add a Codex provider
Open CC Switch, switch to the top-level `Codex` tab, click the plus button in the upper-right corner to add a provider, keep the default `Custom Configuration`, then fill in:
- **Provider Name**: anything you like, e.g. `Claude Gateway`.
- **API Key**: your gateway key. The real key is stored only in CC Switch and injected by the local route when forwarding, so it never enters Codex's live config.
- **API Request URL**: just the gateway's service root, e.g. `https://claude-gateway.example.com`. It works with or without a trailing `/v1` — the route sends requests to `/v1/messages` automatically; don't assemble `/v1/messages` yourself (if your gateway documentation gives you a complete messages URL, you can turn on the `Full URL` toggle next to it and paste it verbatim). The yellow hint below the address bar, "compatible with OpenAI Response format", is generic copy written for the direct Responses scenario; when you choose the Anthropic format, just fill it in as this guide describes.
- **Default Model**: enter a Claude model id the gateway recognizes, e.g. `claude-sonnet-5`; follow the model names in the gateway's documentation.
Then expand `Advanced Options` and change `Upstream Format` from the default `Responses (native)` to **`Anthropic Messages (routing required)`**.
![Codex provider form for a Claude gateway](../images/codex-claude-routing/02-claude-codex-provider-form.png)
After selecting Anthropic Messages, three supporting fields appear below:
![Advanced options for the Anthropic upstream](../images/codex-claude-routing/03-anthropic-advanced-options.png)
- **Auth field**: determines which header carries the API key to the upstream; only one of the two is sent — choose per your gateway's documentation.
- `ANTHROPIC_AUTH_TOKEN (Authorization)`: sends `Authorization: Bearer <key>`. This is the default, and most Claude-family relay gateways use it.
- `ANTHROPIC_API_KEY (x-api-key)`: sends `x-api-key: <key>`. Some gateways that follow Anthropic's native header convention require this. Picking the wrong one usually shows up as 401 / 403.
- **Emulate Claude Code client**: off by default. Turn it on only when the gateway or its upstream restricts usage to "Claude Code only"; when enabled, it spoofs the User-Agent, `anthropic-beta`, and `x-app` headers and injects the Claude Code identity as the first line of the system prompt. Ordinary gateways don't need it; if you're still rejected after enabling it, see "FAQ".
- **Max output tokens**: the Anthropic protocol's `max_tokens` is required, and when a Codex request carries no output ceiling the route falls back to a conservative 8192, which may truncate long answers or deep reasoning (showing up as an incomplete reply, `stop_reason=max_tokens`). If you hit truncation, raise this to the model's real ceiling here — but don't exceed it, or the upstream will 400 outright.
The `Model Mapping` in the same area is optional: add model ids like `claude-opus-4-8`, `claude-sonnet-5`, and `claude-haiku-4-5-20251001` (use the names your upstream recognizes) one per row, and CC Switch generates a model catalog so Codex's `/model` menu can list them; you can also leave it empty, in which case Codex just requests the default model.
After you save the provider, a `Needs Routing` marker appears on the card — providers like this only work while local routing is running.
## Step 2: Enable local routing and take over Codex
Go to the `Routing` page in Settings, expand `Local Routing`, and complete two toggles:
1. Turn on the `Routing Master Switch` to start the local service (the first time you enable it, an explanatory confirmation dialog appears). The default address is `127.0.0.1:15721`.
2. Turn on `Codex` under `Routing Enabled`. If you only want Codex to use routing, you can leave Claude and Gemini off.
![Enabling Codex takeover on the local routing page](../images/codex-claude-routing/04-local-route-codex-takeover.png)
After takeover, CC Switch points Codex's live config at the local route (`base_url = http://127.0.0.1:15721/v1`), with only a placeholder in `auth.json`. The real Claude key stays in the CC Switch provider config and is injected by the local route on forward, using the auth field you selected.
## Step 3: Switch providers and restart Codex
Return to the Codex provider list and click `Enable` on the Claude provider. If routing isn't running, CC Switch shows "This provider uses Anthropic Messages API format, requires the routing service to work properly. Start routing first." — just go back to Step 2 and turn it on.
After switching, restart the current Codex terminal session: `config.toml` and the model catalog are read when the Codex process starts, and a running process isn't guaranteed to hot-load them.
Inside Codex you can verify step by step:
- If you configured model mapping, use `/model` to check whether the Claude models now appear in the menu; without a mapping, Codex just uses the default model.
- Send a small question and watch the "Current Provider" on the Settings → Routing page change from "Waiting for first request..." to your Claude provider, with "Total Requests" starting to climb.
- In the usage dashboard, these requests show their model names faithfully as `claude-*`, and you can filter by provider to reconcile token usage.
## Capabilities and known limitations
- **Prompt caching is automatic**: the conversion bridge injects standard 5-minute prompt-cache markers (system prompt, tool definitions, and conversation history) per Anthropic's convention, so long conversations don't resend everything at full price each turn — no configuration needed.
- **Reasoning and tools are lossless**: extended thinking content round-trips across the bridge intact, and multi-turn tool calls, image inputs, and PDF inputs are all fully converted.
- **Supports the `[1m]` long-context marker**: when the default model or a model id in the mapping ends with `[1m]` (e.g. `claude-sonnet-5[1m]`), the route strips the marker and automatically adds the corresponding 1M-context beta header, provided the gateway supports that capability.
- **Web search is unavailable**: in Anthropic upstream mode, Codex's built-in `web_search` is deliberately disabled — the conversion layer can't translate it for the Anthropic endpoint, and disabling it avoids presenting the model with a tool that's guaranteed to fail.
- **Truncation is reported faithfully**: when the upstream stops at the output ceiling or the stream is cut off, Codex sees "incomplete" rather than a disguised success, making it easy to notice and raise the max output tokens.
## FAQ
**The upstream returns 401 or 403**
Nine times out of ten the auth field doesn't match what the gateway wants: switch between `ANTHROPIC_AUTH_TOKEN (Authorization)` and `ANTHROPIC_API_KEY (x-api-key)` per your gateway's documentation and try again (most gateways use the default Bearer). Also confirm the key itself is valid and has balance.
**Codex reports 404 or cannot find `/responses`**
Usually Codex routing takeover isn't enabled, or you manually wrote the gateway's address directly into Codex — an Anthropic-protocol upstream has no `/responses` endpoint, so that always 404s. Check whether the current provider's `base_url` in `~/.codex/config.toml` points to `http://127.0.0.1:15721/v1`.
**The upstream returns 404 (routing already enabled)**
Check the API Request URL: it should be the gateway's service root, not an address carrying another protocol's path such as `/chat/completions`. When the gateway path is unusual, use the `Full URL` toggle to paste the complete messages endpoint directly.
**Replies often get cut off mid-way**
This is the default 8192 output ceiling showing up. Raise it in `Max output tokens` under the provider form's Advanced Options (don't exceed the model's/gateway's real ceiling), save, and retry.
**`/model` doesn't show the Claude models**
Confirm you've added entries to the model mapping, then restart Codex after saving the provider — the model catalog isn't hot-loaded by a running process. When the default model isn't in the mapping, the menu won't list it, but a direct request still works.
**Web search doesn't work**
By design; see "Capabilities and known limitations". For tasks that need web search, switch back to a Responses/Chat-format provider.
**An error says usage is restricted to Claude Code**
Some providers restrict their Claude API to the Claude Code client, so it gets rejected when going through this guide's chain via Codex. Try turning on the `Emulate Claude Code client` toggle in Advanced Options; if it still errors after that, the restriction is enforced on the provider's side — check with your provider whether your key can be used outside Claude Code. Keep this toggle off for ordinary gateways.
## Compliance note
Before using this in the "company bans the client but keeps only the gateway" scenario, it's worth confirming that doing so complies with your organization's specific policy — whether what's banned is a specific client or a manner of use differs from one place to the next. When using a third-party relay gateway, read the target gateway's terms on billing, compliance, and data retention.
## References
- [CC Switch User Manual: Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
- [CC Switch User Manual: App Routing](../user-manual/en/4-proxy/4.2-routing.md)
- [CC Switch v3.17.0 Release Notes](../release-notes/v3.17.0-en.md)
- This feature comes from community contribution [#5071](https://github.com/farion1231/cc-switch/pull/5071); thanks @yeeyzy.
@@ -0,0 +1,129 @@
# Codex で Claude を使う: CC Switch ローカルルーティングガイド
> 対象バージョン: CC Switch 3.17.0 以降(「Anthropic Messages 上流」は 3.17.0 から導入)。本記事はリポジトリ内のドキュメントとコードをもとに整理し、Claude 系中継ゲートウェイを例に説明します。スクリーンショットは現在のフロントエンド UI から、実際の API Key が漏れないよう匿名化したサンプルデータで生成しています。
## ローカルルーティングが必要な理由
新しい Codex CLI は OpenAI Responses API を前提にしています。一方で各種 Claude 系中継ゲートウェイや企業内部ゲートウェイが公開しているのは Anthropic Messages プロトコル、つまり `/v1/messages` です。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造がまったく異なります。この種のゲートウェイのエンドポイントをそのまま Codex 設定に入れても、`/responses` へのリクエストが 404 になるだけです。
この機能は「手元にあるのが `/v1/messages` エンドポイントだけ」という場面のためのものです。ある Claude 系中継ゲートウェイの Key を持っていて、Codex の操作感で Claude 系モデルを使いたい。あるいは会社がコンプライアンス方針で Claude Code クライアントを禁止し、承認済みの Claude 系ゲートウェイだけを残している——モデル自体は利用できるのに、許可されたクライアントがないだけです。その空白を Codex で埋められます。
CC Switch では、Codex が常にローカルルートへ接続し、Responses API のままリクエストを送るようにします。ルートは現在のプロバイダーが Anthropic 形式だと判定すると、リクエストを Anthropic Messages に変換して上流へ送り、最後にレスポンスを Responses 形式へ戻して Codex に返します。
![Codex プロバイダー一覧の「ルーティングが必要」マーク](../images/codex-claude-routing/01-codex-providers-require-routing.png)
この経路は主に 4 つのステップに分かれます:
1. Codex を引き継ぐと、ローカル設定は `http://127.0.0.1:15721/v1` に書き換えられ、`wire_api = "responses"` が強制的に維持されます。
2. プロバイダーの上流フォーマット `anthropic` が、実際の上流は Anthropic Messages プロトコルだとルートに伝えます。
3. ルートは `/responses``/v1/messages` に書き換え、Responses のリクエストボディを Anthropic のリクエストボディへ変換します。
4. 上流から返ってきた後、ルートは Anthropic の JSON または SSE を Codex が理解できる Responses JSON/SSE へ変換して返します——推論内容、ツール呼び出し、画像もすべて変換対象です。
## 事前準備
先に次の 3 つを用意してください:
- インストール済みで起動できる CC Switch(3.17.0 以降)。
- インストール済みの Codex CLI。少なくとも 1 回は実行し、`~/.codex/` のディレクトリ構造が存在していること。
- Anthropic Messages プロトコルのエンドポイント(`/v1/messages`)へアクセスできる API Key——ある Claude 系中継ゲートウェイ、または企業内部の Claude ゲートウェイのもの。エンドポイントと認証方式はゲートウェイのドキュメントに従ってください。注意:一部のプロバイダーは Claude API を Claude Code 内でのみ利用できるよう制限しており、この種の Key を Codex で使うとエラーになることがあります。判断がつかない場合は、先にプロバイダーへ問い合わせてください。
Codex タブには現時点で Anthropic の内蔵プリセットがないため、以下では「カスタム設定」の手順で進めます。入力する項目は全体でも 4〜5 個です。
## Step 1: Codex プロバイダーを追加する
CC Switch を開き、上部の `Codex` タブへ切り替え、右上のプラスボタンからプロバイダーを追加します。デフォルトの `カスタム設定` のまま、次の項目を入力します:
- **プロバイダー名**: 任意です。たとえば `Claude Gateway`
- **API Key**: あなたのゲートウェイの Key。実際の Key は CC Switch 内にのみ保存され、ローカルルートが転送時に注入するため、Codex の live 設定には入りません。
- **API エンドポイント**: ゲートウェイのルートアドレスを入力すれば十分です。たとえば `https://claude-gateway.example.com``/v1` は付けても付けなくても正しく処理され、ルートが自動的に `/v1/messages` へリクエストを送ります。自分で `/v1/messages` を組み立てないでください(ゲートウェイのドキュメントが完全な messages URL を指定している場合は、隣の `フル URL` スイッチをオンにしてそのまま貼り付けても構いません)。アドレス欄の下に表示される「OpenAI Response 互換」という黄色のヒントは Responses 直結向けの汎用文言です。Anthropic フォーマットを選ぶ場合は本記事のとおりに入力してください。
- **デフォルトモデル**: ゲートウェイが認識する Claude モデル ID を入力します。たとえば `claude-sonnet-5`。ゲートウェイのドキュメントにあるモデル名に従ってください。
続いて `高級オプション` を展開し、`上流フォーマット` をデフォルトの `Responses(ネイティブ)` から **`Anthropic Messages(ルーティング必須)`** に変更します。
![Claude ゲートウェイの Codex プロバイダーフォーム](../images/codex-claude-routing/02-claude-codex-provider-form.png)
Anthropic Messages を選ぶと、下に 3 つの関連フィールドが追加で表示されます:
![Anthropic 上流の高級オプション](../images/codex-claude-routing/03-anthropic-advanced-options.png)
- **認証フィールド**: API Key をどのヘッダーで上流へ送るかを決めます。送信されるのはどちらか一方のみで、ゲートウェイのドキュメントに従って選びます。
- `ANTHROPIC_AUTH_TOKENAuthorization`: `Authorization: Bearer <key>` を送信します。デフォルト値で、多くの Claude 系中継ゲートウェイがこの方式を使います。
- `ANTHROPIC_API_KEYx-api-key`: `x-api-key: <key>` を送信します。Anthropic ネイティブのヘッダー規約を踏襲する一部のゲートウェイはこちらを要求します。選択を誤ると、通常 401 / 403 という形で現れます。
- **Claude Code クライアントを模倣**: デフォルトはオフです。ゲートウェイ(またはその上流)が「Claude Code からのみ利用可能」と制限している場合にのみオンにします。オンにすると User-Agent・`anthropic-beta``x-app` ヘッダーを偽装し、システムプロンプトの先頭行に Claude Code のアイデンティティを注入します。通常のゲートウェイでは不要です。オンにしても拒否される場合の対処は「よくある質問」を参照してください。
- **最大出力トークン**: Anthropic プロトコルの `max_tokens` は必須項目です。Codex のリクエストが出力上限を含まない場合、ルートは保守的に 8192 で補います。長い回答や深い思考では切り詰められることがあります(回答が不完全になる、`stop_reason=max_tokens` になる、といった形で現れます)。切り詰められたら、ここでモデルの実際の上限に合わせて引き上げてください。ただし超えないように——超えると上流が直接 400 を返します。
同じエリアの `モデルマッピング` は任意です。`claude-opus-4-8``claude-sonnet-5``claude-haiku-4-5-20251001` のようなモデル ID(上流が認識する名前に従ってください)を 1 行ずつ追加すると、CC Switch がモデルカタログを生成し、Codex の `/model` メニューに一覧表示できるようになります。入力しなくても利用でき、その場合 Codex はデフォルトモデルを直接リクエストします。
プロバイダーを保存すると、カードに `ルーティングが必要` のマークが表示されます——この種のプロバイダーは、ローカルルーティングが実行中でなければ正しく動作しません。
## Step 2: ローカルルーティングを有効にして Codex をルーティングする
設定の `ルーティング` ページに入り、`ローカルルーティング` を展開して、2 つのスイッチを設定します:
1. `ルーティング総スイッチ` をオンにしてローカルサービスを起動します(初回起動時は説明の確認ダイアログが表示されます)。デフォルトアドレスは `127.0.0.1:15721` です。
2. `ルーティング有効``Codex` をオンにします。Codex だけをルーティングしたい場合は、Claude と Gemini はオフのままで構いません。
![ローカルルーティング画面で Codex のルーティングを有効化](../images/codex-claude-routing/04-local-route-codex-takeover.png)
引き継ぎ後、CC Switch は Codex の live 設定をローカルルートへ向け(`base_url = http://127.0.0.1:15721/v1`)、`auth.json` にはプレースホルダーだけが入ります。実際の Claude Key は CC Switch のプロバイダー設定内に残り、ローカルルートが転送時に、あなたが選んだ認証フィールドに従って注入します。
## Step 3: プロバイダーを切り替えて Codex を再起動する
Codex プロバイダー一覧に戻り、Claude プロバイダーの `有効化` をクリックします。ルーティングが実行されていない場合、CC Switch は「このプロバイダーは Anthropic Messages API フォーマットを使用しており、ルーティングサービスが必要です。先にルーティングを起動してください」と表示します——Step 2 に戻ってオンにすれば解決します。
切り替え後は、現在の Codex ターミナルセッションを再起動することをおすすめします。`config.toml` とモデルカタログは Codex プロセスの起動時に読み込まれるため、実行中のプロセスがホットロードするとは限りません。
Codex に入ったら、段階的に確認できます:
- モデルマッピングを設定している場合は、`/model` で Claude モデルがメニューに表示されているか確認します。マッピングを設定していない場合、Codex はデフォルトモデルを直接使います。
- 小さな質問を 1 つ送り、設定 → ルーティングページの「現在のプロバイダー」が「最初のリクエスト待ち」からあなたの Claude プロバイダーに変わり、「総リクエスト数」が増え始めるのを確認します。
- 使用量ダッシュボードでは、これらのリクエストのモデル名が `claude-*` としてそのまま表示され、プロバイダーで絞り込んで token 使用量を照合できます。
## 機能の範囲と既知の制限
- **プロンプトキャッシュが自動で有効**: 変換ブリッジは Anthropic 標準に従って 5 分のプロンプトキャッシュマーカー(システムプロンプト、ツール定義、対話履歴)を注入します。長い対話でも毎回全額で再送されることはなく、設定は不要です。
- **推論とツールを無損失で往復**: extended thinking の内容はブリッジをまたいで往復しても保持され、複数ターンのツール呼び出し、画像、PDF 入力も完全に変換されます。
- **`[1m]` 長コンテキストマーカーに対応**: デフォルトモデルやモデルマッピングのモデル ID が `[1m]` で終わる場合(例:`claude-sonnet-5[1m]`)、ルートはマーカーを取り除き、対応する 1M コンテキストの beta ヘッダーを自動で補います。ただし、ゲートウェイがその機能に対応していることが前提です。
- **Web 検索は利用不可**: Anthropic 上流モードでは Codex の内蔵 `web_search` が意図的に無効化されます——変換層が Anthropic エンドポイントへ翻訳できないためで、必ず失敗するツールをモデルに提示しないための措置です。
- **切り詰めをそのまま報告**: 上流が出力上限で止まったりストリームが切断されたりすると、Codex は偽装された成功ではなく「未完了」を受け取ります。これにより気づきやすくなり、最大出力トークンを引き上げる判断ができます。
## よくある質問
**上流が 401 または 403 を返す**
ほとんどの場合、認証フィールドがゲートウェイの要求と一致していません。`ANTHROPIC_AUTH_TOKENAuthorization``ANTHROPIC_API_KEYx-api-key` を、ゲートウェイのドキュメントに従って切り替えて再試行してください(多くのゲートウェイはデフォルトの Bearer です)。あわせて、Key 自体が有効で残高があることも確認してください。
**Codex が 404 を返す、または `/responses` が見つからない**
多くの場合、Codex のルーティング引き継ぎが有効になっていないか、ゲートウェイのエンドポイントを手動で Codex に直接書いています——Anthropic プロトコルの上流には `/responses` エンドポイントが存在しないため、必ず 404 になります。`~/.codex/config.toml` の現在の provider の `base_url``http://127.0.0.1:15721/v1` を指しているか確認してください。
**上流が 404 を返す(ルーティングは有効)**
API エンドポイントを確認してください。ゲートウェイのルートアドレスであるべきで、`/chat/completions` のような別プロトコルのパスが付いたアドレスではいけません。ゲートウェイのパスが特殊な場合は、`フル URL` スイッチを使って完全な messages エンドポイントをそのまま貼り付けてください。
**回答が途中で切り詰められることが多い**
これはデフォルトの 8192 出力上限の現れです。プロバイダーフォームの高級オプションにある `最大出力トークン` で引き上げ(モデル / ゲートウェイの実際の上限を超えないように)、保存してから再試行してください。
**`/model` に Claude モデルが表示されない**
モデルマッピングにエントリが追加されていることを確認し、プロバイダーを保存してから Codex を再起動してください——モデルカタログは実行中のプロセスにはホットロードされません。デフォルトモデルがマッピングに含まれていない場合、メニューには表示されませんが、直接リクエストは有効です。
**Web 検索が使えない**
仕様どおりです。「機能の範囲と既知の制限」を参照してください。Web 検索が必要なタスクは、Responses / Chat フォーマットのプロバイダーへ切り替えることをおすすめします。
**Claude Code でしか使えないというエラーが出る**
一部のプロバイダーは、その Claude API を Claude Code クライアント内でのみ利用できるよう制限しており、本ガイドの経路で Codex から使うと拒否されます。高級オプションの `Claude Code クライアントを模倣` スイッチをオンにして試すことはできますが、それでもエラーになる場合、制限はプロバイダーのサーバー側にあります。その Key を Claude Code 以外で使えるかどうか、プロバイダーへ問い合わせて確認してください。通常のゲートウェイでは、このスイッチはオフのままにしてください。
## コンプライアンスに関する注意
「会社がクライアントを禁止し、ゲートウェイだけを残している」という場面で使う前に、この使い方が所属組織の具体的な方針に沿っているかを確認することをおすすめします——禁止されているのが特定のクライアントなのか、それともある種の利用方法なのかは、組織によって解釈が異なります。サードパーティ中継ゲートウェイを使う場合は、対象ゲートウェイの課金・コンプライアンス・データ保持に関する規約を必ずお読みください。
## 参考リンク
- [CC Switch ユーザーマニュアル: プロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
- [CC Switch ユーザーマニュアル: アプリケーションルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
- [CC Switch v3.17.0 リリースノート](../release-notes/v3.17.0-ja.md)
- この機能はコミュニティからの貢献 [#5071](https://github.com/farion1231/cc-switch/pull/5071) によるものです。@yeeyzy に感謝します。
@@ -0,0 +1,129 @@
# 在 Codex 中用 ClaudeCC Switch 本地路由攻略
> 适用版本:CC Switch 3.17.0 及以上(「Anthropic Messages 上游」自 3.17.0 引入)。本文根据仓库内文档与代码整理,以 Claude 系中转网关为例演示。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key。
## 为什么需要本地路由
新版 Codex CLI 面向的是 OpenAI Responses API,而各类 Claude 系中转网关、企业内部网关暴露的是 Anthropic Messages 协议,也就是 `/v1/messages`。这两种协议的请求体、流式事件和返回结构完全不同,把这类网关的接口地址直接填进 Codex 配置里,结果只能是请求 `/responses` 返回 404。
这个功能面向的就是「手里只有 `/v1/messages` 端点」的场景:你有某个 Claude 系中转网关的 Key,想用 Codex 的交互习惯跑 Claude 系列模型;或者公司出于合规策略禁用了 Claude Code 客户端、只保留了经批准的 Claude 系网关——模型本身可用,缺的只是一个被允许的客户端,现在 Codex 可以补上这个位置。
CC Switch 的做法是让 Codex 始终连本机路由,仍以 Responses API 发送请求;路由识别当前供应商是 Anthropic 格式后,把请求转换成 Anthropic Messages 发给上游,再把响应转换回 Responses 形态返回给 Codex。
![Codex 供应商列表里的需要路由标记](../images/codex-claude-routing/01-codex-providers-require-routing.png)
这条链路主要分成四步:
1. Codex 接管时,本地配置会被写成 `http://127.0.0.1:15721/v1`,并强制保持 `wire_api = "responses"`
2. 供应商的上游格式 `anthropic` 会告诉路由:真实上游说的是 Anthropic Messages 协议。
3. 路由把 `/responses` 改写到 `/v1/messages`,并把 Responses 请求体转换成 Anthropic 请求体。
4. 上游返回后,路由再把 Anthropic 的 JSON 或 SSE 转回 Codex 能理解的 Responses JSON/SSE——推理内容、工具调用、图片都在转换范围内。
## 准备工作
你需要先准备好三样东西:
- 已安装并能启动的 CC Switch3.17.0 及以上)。
- 已安装 Codex CLI,并至少运行过一次,让 `~/.codex/` 目录结构存在。
- 一个能访问 Anthropic Messages 协议端点(`/v1/messages`)的 API Key——来自某个 Claude 系中转网关,或企业内部的 Claude 网关;端点地址和认证方式以网关文档为准。注意:部分供应商会限制其 Claude API 只能在 Claude Code 中使用,这类 Key 走 Codex 可能会报错,拿不准就先咨询供应商。
Codex 页签目前没有 Anthropic 内置预设,下面走「自定义配置」路径,全程也就四五个字段。
## 第一步:添加 Codex 供应商
打开 CC Switch,切到顶部的 `Codex` 标签,点击右上角的加号添加供应商,保持默认的 `自定义配置`,然后填写:
- **供应商名称**:随意,例如 `Claude Gateway`
- **API Key**:你的网关 Key。真实 Key 只保存在 CC Switch 里,由本地路由转发时注入,不会进入 Codex 的 live 配置。
- **API 请求地址**:填网关服务根地址即可,例如 `https://claude-gateway.example.com`。带不带 `/v1` 都能被正确处理,路由会自动把请求打到 `/v1/messages`;不要自己拼 `/v1/messages`(如果网关文档给的就是完整 messages URL,打开旁边的 `完整 URL` 开关原样粘贴也可以)。地址栏下方那句「兼容 OpenAI Response 格式」的黄色提示是为 Responses 直连场景写的通用文案,选 Anthropic 格式时按本文填写即可。
- **默认模型**:填网关认识的 Claude 模型 id,例如 `claude-sonnet-5`,以网关文档给出的模型名为准。
然后展开 `高级选项`,把 `上游格式` 从默认的 `Responses(原生)` 改成 **`Anthropic Messages(需开启路由)`**。
![Claude 网关的 Codex 供应商表单](../images/codex-claude-routing/02-claude-codex-provider-form.png)
选中 Anthropic Messages 后,下方会多出三个配套字段:
![Anthropic 上游的高级选项](../images/codex-claude-routing/03-anthropic-advanced-options.png)
- **认证字段**:决定 API Key 以哪个请求头发给上游,两者只发其一,按网关文档选择。
- `ANTHROPIC_AUTH_TOKENAuthorization`:发 `Authorization: Bearer <key>`,是默认值,多数 Claude 系中转网关用这种。
- `ANTHROPIC_API_KEYx-api-key`:发 `x-api-key: <key>`,部分沿用 Anthropic 原生请求头约定的网关要求这种。选错通常表现为 401 / 403。
- **模拟 Claude Code 客户端**:默认关闭。仅当网关或其上游限制「只能通过 Claude Code 使用」时才打开,开启后会伪装 User-Agent、`anthropic-beta``x-app` 请求头,并在系统提示首行注入 Claude Code 身份。普通网关不需要开;开启后仍被拒的处理见「常见问题」。
- **最大输出 tokens**Anthropic 协议的 `max_tokens` 是必填项,而 Codex 请求未携带输出上限时,路由按保守的 8192 兜底,长回答或深度思考可能被截断(表现为回复不完整、`stop_reason=max_tokens`)。遇到截断就在这里按模型真实上限调高,但不要超过——超了上游会直接 400。
同区的 `模型映射` 是可选项:把 `claude-opus-4-8``claude-sonnet-5``claude-haiku-4-5-20251001` 这类模型 id(以你上游认识的名字为准)逐行加进去,CC Switch 会生成模型目录让 Codex 的 `/model` 菜单能列出它们;不填也能用,Codex 会直接请求默认模型。
保存供应商后,卡片上会出现 `需要路由` 标记——这类供应商必须在本地路由运行时才能正常工作。
## 第二步:开启本地路由并接管 Codex
进入设置里的 `路由` 页面,展开 `本地路由`,完成两个开关:
1. 打开 `路由总开关`,启动本地服务(首次开启会弹出一个说明确认框)。默认地址是 `127.0.0.1:15721`
2.`路由启用` 中打开 `Codex`。如果只想让 Codex 走路由,可以保持 Claude、Gemini 关闭。
![本地路由页面中启用 Codex 接管](../images/codex-claude-routing/04-local-route-codex-takeover.png)
接管后,CC Switch 会把 Codex 的 live 配置指向本机路由(`base_url = http://127.0.0.1:15721/v1`),`auth.json` 里只有占位符。真实 Claude Key 仍保存在 CC Switch 的供应商配置里,由本地路由在转发时按你选的认证字段注入。
## 第三步:切换供应商并重启 Codex
回到 Codex 供应商列表,点击 Claude 供应商的 `启用`。如果路由没有在运行,CC Switch 会提示「此供应商使用 Anthropic Messages 接口格式,需要路由服务才能正常使用,请先启动路由」——回到第二步打开即可。
切换后建议重启当前 Codex 终端会话:`config.toml` 和模型目录是 Codex 进程启动时读取的,运行中的进程不保证热加载。
进入 Codex 后可以逐级验证:
- 配置了模型映射的话,用 `/model` 查看 Claude 模型是否已出现在菜单里;没配映射时 Codex 直接用默认模型。
- 发一个小问题,观察设置 → 路由页面的「当前 Provider」从「等待首次请求」变成你的 Claude 供应商、「总请求数」开始增长。
- 用量看板里,这些请求的模型名会如实显示为 `claude-*`,可按供应商筛选核对 token 用量。
## 能力边界与已知限制
- **提示缓存自动生效**:转换桥会按 Anthropic 标准注入 5 分钟提示缓存标记(系统提示、工具定义与对话历史),长对话不会每轮全价重发,无需任何配置。
- **推理与工具无损**extended thinking 内容跨桥往返保留,多轮工具调用、图片与 PDF 输入都被完整转换。
- **支持 `[1m]` 长上下文标记**:默认模型或模型映射里的模型 id 以 `[1m]` 结尾(如 `claude-sonnet-5[1m]`)时,路由会剥掉标记并自动补发对应的 1M 上下文 beta 头,前提是网关支持该能力。
- **联网搜索不可用**Anthropic 上游模式下 Codex 的内置 `web_search` 会被主动禁用——转换层无法把它翻译给 Anthropic 端点,禁用是为了不给模型呈现一个必然失败的工具。
- **截断如实上报**:上游停在输出上限或流被掐断时,Codex 会看到「未完成」而不是被伪装的成功,方便你察觉并调高最大输出 tokens。
## 常见问题
**上游返回 401 或 403**
十有八九是认证字段与网关要求不符:在 `ANTHROPIC_AUTH_TOKENAuthorization``ANTHROPIC_API_KEYx-api-key` 之间按网关文档换一个再试(多数网关用默认的 Bearer)。另外确认 Key 本身有效、有余额。
**Codex 报 404 或找不到 `/responses`**
通常是没有开启 Codex 路由接管,或者你手动把网关的地址直接写给了 Codex——Anthropic 协议的上游没有 `/responses` 端点,这样一定 404。检查 `~/.codex/config.toml` 里当前 provider 的 `base_url` 是否指向 `http://127.0.0.1:15721/v1`
**上游返回 404(路由已开启)**
检查 API 请求地址:应该是网关的服务根地址,而不是带 `/chat/completions` 之类其它协议路径的地址。网关路径特殊时,用 `完整 URL` 开关直接粘贴完整的 messages 端点。
**回复经常中途截断**
这是默认 8192 输出上限的表现。在供应商表单高级选项的 `最大输出 tokens` 里调高(不要超过模型/网关真实上限),保存后重试。
**`/model` 看不到 Claude 模型**
确认模型映射里已添加条目,保存供应商后重启 Codex——模型目录不会被运行中的进程热加载。默认模型不在映射里时菜单不会列出它,但直接请求仍然有效。
**联网搜索用不了**
设计如此,见「能力边界」。需要联网搜索的任务建议切回 Responses/Chat 格式的供应商。
**报错提示只能在 Claude Code 中使用**
部分供应商会限制其 Claude API 只能在 Claude Code 客户端中使用,经 Codex 走本攻略的链路时会被拒绝。可以尝试打开高级选项里的 `模拟 Claude Code 客户端` 开关;若开启后仍然报错,说明限制在供应商服务端,请咨询供应商确认你的 Key 能否在 Claude Code 之外使用。普通网关请保持该开关关闭。
## 合规提示
在「公司禁客户端、只留网关」的场景下使用前,建议确认这样做符合你所在组织的具体政策——被禁的是特定客户端还是某种使用方式,各家口径不同。使用第三方中转网关时,请阅读目标网关关于计费、合规与数据留存的条款。
## 参考链接
- [CC Switch 用户手册:代理服务](../user-manual/zh/4-proxy/4.1-service.md)
- [CC Switch 用户手册:应用路由](../user-manual/zh/4-proxy/4.2-routing.md)
- [CC Switch v3.17.0 发布说明](../release-notes/v3.17.0-zh.md)
- 功能来自社区贡献 [#5071](https://github.com/farion1231/cc-switch/pull/5071),感谢 @yeeyzy
Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

+10 -8
View File
@@ -10,6 +10,7 @@
The new capabilities in this release land mainly in the project switcher at the top of the home page, the Codex provider form, and the usage dashboard. The following docs are worth reading alongside it:
- **[Using Claude in Codex (local routing guide)](../guides/codex-claude-routing-guide-en.md)**: a new step-by-step guide for this release's native Anthropic Messages upstream. It walks through setting a Codex provider's upstream format to `anthropic` to connect to any Claude-family gateway that only offers `/v1/messages`, and use Claude-family models inside Codex.
- **[Using Kimi inside Codex (local routing guide)](../guides/codex-kimi-routing-guide-en.md)**: a new step-by-step guide added in this release. Newer Codex CLI speaks the OpenAI Responses protocol, while the Kimi Open Platform and Kimi For Coding expose Chat Completions endpoints, so a direct connection usually 404s; the guide walks through using the built-in `Kimi` / `Kimi For Coding` presets together with local routing to handle the protocol conversion.
- **[Codex Official Login Preservation](../guides/codex-official-auth-preservation-guide-en.md)**: understand how CC Switch preserves your official ChatGPT login when you switch to a third-party provider. This release goes a step further — the official account itself can now route through the proxy too (see "Added" below).
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources and how the statistics are counted. This release fixes cache-write billing, fills in Codex subagent session accounting, and adds GPT-5.6 and Hunyuan Hy3 pricing.
@@ -311,11 +312,11 @@ Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and do
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------ | ----------------------------------- |
| Windows | Windows 10 and later | x64 / ARM64 |
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 / ARM64 |
| System | Minimum Version | Architecture |
| ------- | -------------------- | ----------------------------------- |
| Windows | Windows 10 and later | x64 / ARM64 |
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 / ARM64 |
### Windows
@@ -354,11 +355,12 @@ Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose
- `CC-Switch-v3.17.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
| Distribution | Recommended Format | Install Command |
| --------------------------------------- | ------------------ | --------------------------------------------------------------------- |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
</content>
</invoke>
+7 -6
View File
@@ -10,6 +10,7 @@
本リリースの新機能は、主にホーム画面上部のプロジェクト切り替え、Codex プロバイダーフォーム、使用量ダッシュボードにあります。以下のドキュメントもあわせてご覧ください:
- **[Codex で Claude を使う(ローカルルーティングガイド)](../guides/codex-claude-routing-guide-ja.md)**:本リリースの「ネイティブ Anthropic Messages 上流」に対応した新しいステップバイステップガイドです。Codex プロバイダーの上流形式を `anthropic` にして、`/v1/messages` のみを提供する Claude 系ゲートウェイへ接続し、Codex の中で Claude 系モデルを使う手順を説明します。
- **[Codex で Kimi を使う(ローカルルーティングガイド)](../guides/codex-kimi-routing-guide-ja.md)**:新しい Codex CLI は OpenAI Responses 形式を使いますが、Kimi Open Platform と Kimi For Coding は Chat Completions endpoint を提供するため、直接接続すると通常は 404 になります。このガイドでは、内蔵の `Kimi` / `Kimi For Coding` プリセットとローカルルーティングを使ったプロトコル変換を説明します。
- **[Codex の公式ログインを保持する](../guides/codex-official-auth-preservation-guide-ja.md)**:サードパーティプロバイダーへ切り替える際に、CC Switch が公式 ChatGPT ログインをどう保持するかを説明します。本リリースではさらに、公式アカウント自体をプロキシ経由でルーティングできるようになりました。
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計方法を確認できます。本リリースではキャッシュ書き込み課金を修正し、Codex サブエージェントの使用量を補完し、GPT-5.6 と Hunyuan Hy3 の価格を追加しました。
@@ -332,7 +333,7 @@ Windows ARM64 デバイスでは、ファイル名に `arm64` が含まれる対
| -------------------------------- | ------------------------------------------------------ |
| `CC-Switch-v3.17.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
| `CC-Switch-v3.17.0-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
| `CC-Switch-v3.17.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
| `CC-Switch-v3.17.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
Homebrew インストール:
@@ -353,10 +354,10 @@ Linux アセットは **x86_64** と **ARM64**`aarch64`)の両方を提供
- `CC-Switch-v3.17.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.17.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------- |
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | -------------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+1
View File
@@ -10,6 +10,7 @@
本版的新能力主要落在主页顶部的项目切换器、Codex 供应商表单与用量看板里,建议结合以下文档了解:
- **[在 Codex 中用 Claude(本地路由攻略)](../guides/codex-claude-routing-guide-zh.md)**:本版新增的分步攻略,配合「原生 Anthropic Messages 上游」功能使用。攻略讲解如何把 Codex 供应商的上游格式选为 `anthropic`,接入任何只提供 `/v1/messages` 的 Claude 系网关,在 Codex 里用上 Claude 系列模型。
- **[在 Codex 里使用 Kimi(本地路由攻略)](../guides/codex-kimi-routing-guide-zh.md)**:本版新增的分步攻略。较新的 Codex CLI 走 OpenAI Responses 协议,而 Kimi 开放平台与 Kimi For Coding 暴露的是 Chat Completions 端点,直连通常 404;攻略讲解如何用内置的 `Kimi` / `Kimi For Coding` 预设配合本地路由完成协议转换。
- **[Codex 官方登录保留](../guides/codex-official-auth-preservation-guide-zh.md)**:了解 CC Switch 如何在切换第三方供应商时保留你的官方 ChatGPT 登录。本版在此基础上更进一步——官方账号本身也可以走代理路由(见下方「新功能」)。
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版修正了缓存写入计费、补齐了 Codex 子代理会话统计,并新增 GPT-5.6 与混元 Hy3 定价。
+10
View File
@@ -799,6 +799,7 @@ dependencies = [
"serde_yaml",
"serial_test",
"sha2",
"sys-locale",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
@@ -5438,6 +5439,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
+1
View File
@@ -81,6 +81,7 @@ sha2 = "0.10"
hmac = "0.12"
json5 = "0.4"
json-five = "0.3.1"
sys-locale = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
+71 -4
View File
@@ -14,6 +14,8 @@ pub struct McpApps {
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub grokbuild: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
@@ -26,6 +28,7 @@ impl McpApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
AppType::Hermes => self.hermes,
@@ -39,6 +42,7 @@ impl McpApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::GrokBuild => self.grokbuild = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
AppType::Hermes => self.hermes = enabled,
@@ -58,6 +62,9 @@ impl McpApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.grokbuild {
apps.push(AppType::GrokBuild);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
@@ -69,7 +76,12 @@ impl McpApps {
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
!self.claude
&& !self.codex
&& !self.gemini
&& !self.grokbuild
&& !self.opencode
&& !self.hermes
}
}
@@ -83,6 +95,8 @@ pub struct SkillApps {
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub grokbuild: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
@@ -95,6 +109,7 @@ impl SkillApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
@@ -108,6 +123,7 @@ impl SkillApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::GrokBuild => self.grokbuild = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
@@ -127,6 +143,9 @@ impl SkillApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.grokbuild {
apps.push(AppType::GrokBuild);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
@@ -138,7 +157,12 @@ impl SkillApps {
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
!self.claude
&& !self.codex
&& !self.gemini
&& !self.grokbuild
&& !self.opencode
&& !self.hermes
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -271,6 +295,8 @@ pub struct McpRoot {
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub grokbuild: McpConfig,
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
@@ -292,6 +318,7 @@ impl Default for McpRoot {
claude_desktop: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
grokbuild: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
hermes: McpConfig::default(),
@@ -323,6 +350,8 @@ pub struct PromptRoot {
#[serde(default)]
pub gemini: PromptConfig,
#[serde(default)]
pub grokbuild: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
@@ -348,6 +377,7 @@ pub enum AppType {
ClaudeDesktop,
Codex,
Gemini,
GrokBuild,
OpenCode,
OpenClaw,
Hermes,
@@ -360,6 +390,7 @@ impl AppType {
AppType::ClaudeDesktop => "claude-desktop",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::GrokBuild => "grokbuild",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
AppType::Hermes => "hermes",
@@ -384,6 +415,7 @@ impl AppType {
AppType::ClaudeDesktop,
AppType::Codex,
AppType::Gemini,
AppType::GrokBuild,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
@@ -402,13 +434,14 @@ impl FromStr for AppType {
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"grokbuild" | "grok-build" | "grok_build" | "grok" => Ok(AppType::GrokBuild),
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
"hermes" => Ok(AppType::Hermes),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
)),
}
}
@@ -444,6 +477,7 @@ impl CommonConfigSnippets {
AppType::ClaudeDesktop => None,
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::GrokBuild => None,
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
AppType::Hermes => self.hermes.as_ref(),
@@ -457,6 +491,7 @@ impl CommonConfigSnippets {
AppType::ClaudeDesktop => {}
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::GrokBuild => {}
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
AppType::Hermes => self.hermes = snippet,
@@ -500,6 +535,7 @@ impl Default for MultiAppConfig {
apps.insert("claude-desktop".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("grokbuild".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
apps.insert("hermes".to_string(), ProviderManager::default());
@@ -662,6 +698,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::GrokBuild => &self.mcp.grokbuild,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
AppType::Hermes => &self.mcp.hermes,
@@ -675,6 +712,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::GrokBuild => &mut self.mcp.grokbuild,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
AppType::Hermes => &mut self.mcp.hermes,
@@ -691,6 +729,7 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::GrokBuild)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
@@ -714,6 +753,7 @@ impl MultiAppConfig {
|| !self.prompts.claude_desktop.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.grokbuild.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
|| !self.prompts.hermes.prompts.is_empty()
@@ -728,6 +768,7 @@ impl MultiAppConfig {
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::GrokBuild,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
@@ -801,6 +842,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::GrokBuild => &mut config.prompts.grokbuild.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
AppType::Hermes => &mut config.prompts.hermes.prompts,
@@ -843,6 +885,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::GrokBuild => continue,
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
@@ -1133,6 +1176,30 @@ mod tests {
);
}
#[test]
#[serial]
fn auto_imports_grokbuild_prompt_on_first_launch() {
let _home = TempHome::new();
write_prompt_file(AppType::GrokBuild, "# Grok Build Prompt\n\nTest content");
let config = MultiAppConfig::load().expect("load config");
assert_eq!(config.prompts.grokbuild.prompts.len(), 1);
let prompt = config
.prompts
.grokbuild
.prompts
.values()
.next()
.expect("grokbuild prompt exists");
assert!(prompt.enabled, "grokbuild prompt should be enabled");
assert_eq!(prompt.content, "# Grok Build Prompt\n\nTest content");
assert_eq!(
prompt.description,
Some("Automatically imported on first launch".to_string())
);
}
#[test]
#[serial]
fn auto_imports_all_three_apps_prompts() {
+484
View File
@@ -7,7 +7,9 @@ use crate::config::{
};
use crate::error::AppError;
use crate::model_capabilities::{image_input_capability_from_modalities, ImageInputCapability};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::process::Command;
use toml_edit::DocumentMut;
@@ -101,6 +103,174 @@ fn codex_native_gateway_rejects_web_search(config_text: &str) -> bool {
false
}
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
const CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME: &str = "codex_managed_oauth_live_auth.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexManagedOAuthLiveAuthMarker {
version: u32,
account_id: String,
access_token_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodexLiveFileState {
path: PathBuf,
contents: Option<Vec<u8>>,
#[cfg(unix)]
mode: Option<u32>,
}
impl CodexLiveFileState {
fn capture(path: PathBuf) -> Result<Self, AppError> {
if !path.exists() {
return Ok(Self {
path,
contents: None,
#[cfg(unix)]
mode: None,
});
}
let contents = fs::read(&path).map_err(|error| AppError::io(&path, error))?;
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
Some(
fs::metadata(&path)
.map_err(|error| AppError::io(&path, error))?
.permissions()
.mode(),
)
};
Ok(Self {
path,
contents: Some(contents),
#[cfg(unix)]
mode,
})
}
fn restore(&self) -> Result<(), AppError> {
match self.contents.as_deref() {
Some(contents) => {
atomic_write(&self.path, contents)?;
#[cfg(unix)]
if let Some(mode) = self.mode {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&self.path, fs::Permissions::from_mode(mode))
.map_err(|error| AppError::io(&self.path, error))?;
}
Ok(())
}
None => delete_file(&self.path),
}
}
}
/// Exact rollback state for a managed Codex live write. The generated catalog
/// and ownership marker are part of the same logical commit as auth/config.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CodexLiveStateSnapshot {
auth: CodexLiveFileState,
config: CodexLiveFileState,
catalog: CodexLiveFileState,
managed_marker: CodexLiveFileState,
}
impl CodexLiveStateSnapshot {
pub(crate) fn capture() -> Result<Self, AppError> {
Ok(Self {
auth: CodexLiveFileState::capture(get_codex_auth_path())?,
config: CodexLiveFileState::capture(get_codex_config_path())?,
catalog: CodexLiveFileState::capture(get_codex_model_catalog_path())?,
managed_marker: CodexLiveFileState::capture(
get_codex_managed_oauth_live_auth_marker_path(),
)?,
})
}
/// Roll back config/catalog exactly while retaining a demonstrably newer
/// ChatGPT auth generation for the same account. OAuth refresh can advance
/// auth.json after a provider transaction captures its snapshot; restoring
/// that snapshot blindly would invalidate the CLI's newly rotated token.
///
/// Cross-account writes are still rolled back exactly: an A -> B transaction
/// that fails must restore A even if B refreshed while it was briefly live.
/// The marker follows auth as one generation bundle.
pub(crate) fn restore_preserving_newer_same_account_auth(&self) -> Result<(), AppError> {
let mut failures = Vec::new();
let current_auth = match CodexLiveFileState::capture(get_codex_auth_path()) {
Ok(state) => Some(state),
Err(error) => {
// Inspection failure must not prevent config/catalog and the
// remaining rollback files from being attempted.
failures.push(format!("inspect current auth: {error}"));
None
}
};
let snapshot_generation = Self::chatgpt_auth_generation(&self.auth);
let current_generation = current_auth
.as_ref()
.and_then(Self::chatgpt_auth_generation);
let preserve_current_auth = match (snapshot_generation, current_generation) {
(Some((snapshot_account, snapshot_time)), Some((current_account, current_time)))
if snapshot_account == current_account =>
{
match (snapshot_time, current_time) {
(Some(snapshot_time), Some(current_time)) => current_time > snapshot_time,
(None, Some(_)) => true,
_ => false,
}
}
_ => false,
};
for (label, state) in [("catalog", &self.catalog), ("config", &self.config)] {
if let Err(error) = state.restore() {
failures.push(format!("{label}: {error}"));
}
}
if !preserve_current_auth {
for (label, state) in [
("auth", &self.auth),
("managed marker", &self.managed_marker),
] {
if let Err(error) = state.restore() {
failures.push(format!("{label}: {error}"));
}
}
}
if failures.is_empty() {
Ok(())
} else {
Err(AppError::Message(format!(
"恢复 Codex Live 状态失败: {}",
failures.join("; ")
)))
}
}
fn chatgpt_auth_generation(state: &CodexLiveFileState) -> Option<(String, Option<i64>)> {
let auth: Value = serde_json::from_slice(state.contents.as_deref()?).ok()?;
if auth.get("auth_mode").and_then(Value::as_str) != Some("chatgpt") {
return None;
}
let account_id = auth
.pointer("/tokens/account_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|account_id| !account_id.is_empty())?
.to_string();
let last_refresh_ms = auth
.get("last_refresh")
.and_then(Value::as_str)
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp_millis());
Some((account_id, last_refresh_ms))
}
}
/// Which Codex tool surface the generated model catalog should target.
///
@@ -168,6 +338,288 @@ pub fn get_codex_auth_path() -> PathBuf {
get_codex_config_dir().join("auth.json")
}
fn get_codex_managed_oauth_live_auth_marker_path() -> PathBuf {
crate::config::get_app_config_dir().join(CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME)
}
fn sha256_hex(text: &str) -> String {
let digest = Sha256::digest(text.as_bytes());
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
/// 从 live/备份的 Codex `auth` 中提取 `(account_id, access_token)`,用于 marker 记录/比对。
///
/// 仅接受 ChatGPT 登录形状(`auth_mode == "chatgpt"`、`OPENAI_API_KEY` 可清空)。
/// 托管账号写入的完整 bundle 会额外带 `tokens.refresh_token` 与顶层 `last_refresh`
/// 这里一并容忍,从而能对完整 bundle 记录/比对指纹(marker 靠 account_id +
/// access_token 哈希识别「我们写的那一份」,与用户原生登录区分开)。
fn extract_codex_managed_oauth_auth(auth: &Value) -> Option<(String, String)> {
let auth_obj = auth.as_object()?;
if auth_obj.keys().any(|key| {
!matches!(
key.as_str(),
"auth_mode" | "OPENAI_API_KEY" | "tokens" | "last_refresh"
)
}) {
return None;
}
if auth.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") {
return None;
}
let api_key_is_clearable = auth
.get("OPENAI_API_KEY")
.is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED"));
if !api_key_is_clearable {
return None;
}
let tokens = auth.get("tokens").and_then(|value| value.as_object())?;
if tokens.keys().any(|key| {
!matches!(
key.as_str(),
"access_token" | "account_id" | "id_token" | "refresh_token"
)
}) {
return None;
}
let account_id = tokens
.get("account_id")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|id| !id.is_empty())?;
let access_token = tokens
.get("access_token")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|token| !token.is_empty())?;
Some((account_id.to_string(), access_token.to_string()))
}
/// Build the native-shaped ChatGPT auth bundle shared by cc-switch and Codex CLI.
pub fn codex_managed_oauth_auth_value(
account_id: &str,
access_token: &str,
id_token: Option<&str>,
refresh_token: &str,
last_refresh: &str,
) -> Value {
let mut tokens = serde_json::Map::new();
if let Some(id_token) = id_token {
tokens.insert("id_token".to_string(), Value::String(id_token.to_string()));
}
tokens.insert(
"access_token".to_string(),
Value::String(access_token.to_string()),
);
tokens.insert(
"refresh_token".to_string(),
Value::String(refresh_token.to_string()),
);
tokens.insert(
"account_id".to_string(),
Value::String(account_id.to_string()),
);
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": Value::Object(tokens),
"last_refresh": last_refresh,
})
}
pub fn record_codex_managed_oauth_live_auth(auth: &Value) -> Result<(), AppError> {
let Some((account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else {
return Ok(());
};
let marker = CodexManagedOAuthLiveAuthMarker {
version: 1,
account_id,
access_token_sha256: sha256_hex(&access_token),
};
crate::config::write_json_file(&get_codex_managed_oauth_live_auth_marker_path(), &marker)
}
pub fn codex_auth_matches_recorded_managed_oauth(
auth: &Value,
account_id: &str,
) -> Result<bool, AppError> {
let account_id = account_id.trim();
if account_id.is_empty() {
return Ok(false);
}
let Some((auth_account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else {
return Ok(false);
};
if auth_account_id != account_id {
return Ok(false);
}
let marker_path = get_codex_managed_oauth_live_auth_marker_path();
let marker: CodexManagedOAuthLiveAuthMarker = match read_json_file(&marker_path) {
Ok(marker) => marker,
Err(err) => {
log::warn!(
"Failed to read Codex managed OAuth auth marker at {}: {err}",
marker_path.display()
);
return Ok(false);
}
};
Ok(marker.version == 1
&& marker.account_id == account_id
&& marker.access_token_sha256 == sha256_hex(&access_token))
}
/// 切走托管 provider 时删除其残留在 `~/.codex/auth.json` 的**由本应用写入的**托管登录。
///
/// 仅当 live auth 与最近一次记录的 markeraccount_id + access_token 指纹)匹配时才删除,
/// 从而绝不误删用户自己 `codex login` 的原生登录。
pub fn clear_codex_live_auth_for_managed_account(account_id: &str) -> Result<(), AppError> {
let auth_path = get_codex_auth_path();
if auth_path.exists() {
let auth: Value = read_json_file(&auth_path)?;
if !codex_auth_matches_recorded_managed_oauth(&auth, account_id)? {
return Ok(());
}
delete_file(&auth_path)?;
let _ = delete_file(&get_codex_managed_oauth_live_auth_marker_path());
}
Ok(())
}
/// 判断给定的 Codex `auth`(来自 live auth.json 或 Live 备份)是否是「属于
/// `account_id` 的 ChatGPT 托管登录」。
///
/// 托管账号写入的是**完整可刷新 bundle**,与原生浏览器登录形状一致(都含
/// refresh_token),且 Codex CLI 会轮换 token 使旧的 access_token 指纹失效,因此
/// 无法再凭形状/哈希区分。这里采用**基于内容的 account_id 判定**:只要是 chatgpt
/// 模式、且 `tokens.account_id` 命中托管账号,即视为该账号的登录。对同一账号的原生
/// 登录会被同等处理(同账号,无损)。
///
/// 用于 Live 备份剥离:避免把托管账号的可刷新 token 持久化进备份配置。
pub fn codex_live_auth_is_managed_chatgpt_login(auth: &Value, account_id: &str) -> bool {
let account_id = account_id.trim();
if account_id.is_empty() {
return false;
}
let Some(obj) = auth.as_object() else {
return false;
};
if obj.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") {
return false;
}
let api_key_clearable = obj
.get("OPENAI_API_KEY")
.is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED"));
if !api_key_clearable {
return false;
}
obj.get("tokens")
.and_then(|tokens| tokens.as_object())
.and_then(|tokens| tokens.get("account_id"))
.and_then(|value| value.as_str())
.map(str::trim)
== Some(account_id)
}
/// 读回 Codex CLI 当前 `~/.codex/auth.json` 中属于 `account_id` 的 refresh_token /
/// id_token(仅当磁盘上的登录账号与之一致时)。
///
/// 用于切换回托管 provider 前,采纳 CLI 自行刷新时轮换出的最新 refresh_token,避免
/// 用陈腐 token 覆盖 CLI 的有效登录(“裸跑 codex” 反复切换场景)。
pub fn read_codex_live_auth_refresh_for_account(
account_id: &str,
) -> Option<(String, Option<String>, Option<i64>)> {
let account_id = account_id.trim();
if account_id.is_empty() {
return None;
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return None;
}
let auth: Value = read_json_file(&auth_path).ok()?;
// 仅在磁盘上确是「该 account_id 的 ChatGPT 登录」时才采纳其 refresh_token
// 避免从非 chatgpt/异常 auth 里误取 token。
if !codex_live_auth_is_managed_chatgpt_login(&auth, account_id) {
return None;
}
let tokens = auth.get("tokens")?.as_object()?;
let refresh_token = tokens.get("refresh_token")?.as_str()?.trim().to_string();
if refresh_token.is_empty() {
return None;
}
let id_token = tokens
.get("id_token")
.and_then(|value| value.as_str())
.map(|token| token.to_string());
let last_refresh_ms = auth
.get("last_refresh")
.and_then(Value::as_str)
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp_millis());
Some((refresh_token, id_token, last_refresh_ms))
}
/// Keep Codex CLI's live auth in the same refresh-token generation after the
/// manager refreshes a managed account.
///
/// The write is compare-and-swap-like: it only proceeds while auth.json still
/// contains the exact refresh token used for the network request. If Codex CLI
/// refreshed concurrently, its newer file wins and is never overwritten.
/// Ownership is preserved separately: a file previously recorded as cc-switch
/// managed gets a new marker fingerprint, while a same-account native login is
/// updated without becoming eligible for deletion on unbind.
pub fn sync_codex_managed_oauth_live_auth_after_refresh(
account_id: &str,
expected_refresh_token: &str,
refreshed_auth: &Value,
) -> Result<bool, AppError> {
let account_id = account_id.trim();
let expected_refresh_token = expected_refresh_token.trim();
if account_id.is_empty() || expected_refresh_token.is_empty() {
return Ok(false);
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Ok(false);
}
let current_auth: Value = read_json_file(&auth_path)?;
if !codex_live_auth_is_managed_chatgpt_login(&current_auth, account_id) {
return Ok(false);
}
let current_refresh_token = current_auth
.pointer("/tokens/refresh_token")
.and_then(Value::as_str)
.map(str::trim);
if current_refresh_token != Some(expected_refresh_token) {
return Ok(false);
}
let marker_path = get_codex_managed_oauth_live_auth_marker_path();
let was_recorded_managed = marker_path.exists()
&& codex_auth_matches_recorded_managed_oauth(&current_auth, account_id)?;
write_json_file(&auth_path, refreshed_auth)?;
if was_recorded_managed {
record_codex_managed_oauth_live_auth(refreshed_auth)?;
}
Ok(true)
}
/// 获取 Codex config.toml 路径
pub fn get_codex_config_path() -> PathBuf {
get_codex_config_dir().join("config.toml")
@@ -2221,6 +2673,38 @@ base_url = "https://single.example.com/v1"
);
}
#[test]
fn managed_chatgpt_login_matched_by_account_id_including_full_refresh_bundle() {
// ① 之后托管写入的是含 refresh_token 的完整 bundle;备份剥离必须凭 account_id
// 认出它,避免把可刷新 token 持久化进 Live 备份。
let full_bundle = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "id",
"access_token": "access",
"refresh_token": "refresh-secret",
"account_id": "acct-managed"
},
"last_refresh": "2026-01-02T03:04:05.000000000Z"
});
assert!(
codex_live_auth_is_managed_chatgpt_login(&full_bundle, "acct-managed"),
"a full refreshable bundle for the managed account must be recognized"
);
assert!(
!codex_live_auth_is_managed_chatgpt_login(&full_bundle, "acct-other"),
"a login for a different account must not match"
);
// 非 chatgpt 模式(API key)不应命中。
let api_key_auth = json!({ "OPENAI_API_KEY": "sk-live" });
assert!(!codex_live_auth_is_managed_chatgpt_login(
&api_key_auth,
"acct-managed"
));
}
#[test]
fn prepare_provider_live_config_uses_top_level_token_for_reserved_provider() {
let input = r#"model_provider = "openai"
+2 -1
View File
@@ -2137,7 +2137,8 @@ base_url = "https://proxy.example/v1"
let env_sqlite_home = dir.path().join("env-sqlite-home");
let config_sqlite_home = dir.path().join("config-sqlite-home");
let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &env_sqlite_home);
let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display());
// TOML 字面量字符串(单引号)Windows 路径含反斜杠,basic string 会解析失败。
let config_text = format!("sqlite_home = '{}'\n", config_sqlite_home.display());
let paths = codex_state_db_paths(&codex_dir, &config_text);
+3 -1
View File
@@ -83,7 +83,9 @@ mod tests {
fn includes_config_sqlite_home() {
let temp = tempdir().expect("tempdir");
let sqlite_home = temp.path().join("sqlite-home");
let config_text = format!("sqlite_home = \"{}\"\n", sqlite_home.display());
// 用 TOML 字面量字符串(单引号)承载路径:Windows 路径含反斜杠,basic string(双引号)
// 会把 `\U`/`\s` 等当作非法转义导致解析失败。
let config_text = format!("sqlite_home = '{}'\n", sqlite_home.display());
let paths = codex_state_db_paths(temp.path(), &config_text);
+10 -7
View File
@@ -19,6 +19,8 @@ pub struct ManagedAuthAccount {
pub authenticated_at: i64,
pub is_default: bool,
pub github_domain: String,
/// 托管账号是否需要重新登录以补全缺失的凭据(Codex 旧账号缺少 id_token
pub reauth_required: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -55,6 +57,7 @@ fn map_account(
) -> ManagedAuthAccount {
ManagedAuthAccount {
is_default: default_account_id == Some(account.id.as_str()),
reauth_required: account.reauth_required,
id: account.id,
provider: provider.to_string(),
login: account.login,
@@ -96,7 +99,7 @@ pub async fn auth_start_login(
Ok(map_device_code_response(auth_provider, response))
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let response = auth_manager
.start_device_flow()
.await
@@ -134,7 +137,7 @@ pub async fn auth_poll_for_account(
}
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
@@ -169,7 +172,7 @@ pub async fn auth_list_accounts(
.collect())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
@@ -209,7 +212,7 @@ pub async fn auth_get_status(
})
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
@@ -247,7 +250,7 @@ pub async fn auth_remove_account(
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager
.remove_account(&account_id)
.await
@@ -274,7 +277,7 @@ pub async fn auth_set_default_account(
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager
.set_default_account(&account_id)
.await
@@ -297,7 +300,7 @@ pub async fn auth_logout(
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
_ => unreachable!(),
+7 -4
View File
@@ -10,10 +10,13 @@ use crate::services::model_fetch::FetchedModel;
use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota};
use std::sync::Arc;
use tauri::State;
use tokio::sync::RwLock;
/// Codex OAuth 认证状态
pub struct CodexOAuthState(pub Arc<RwLock<CodexOAuthManager>>);
///
/// `CodexOAuthManager` 内部已使用细粒度锁且所有方法均为 `&self`,因此这里
/// 直接持有 `Arc`,不再包一层 `RwLock`——避免任一命令持有粗粒度锁跨网络刷新
/// 时阻塞其他命令(切换 / 认证中心操作 / token 读取)。
pub struct CodexOAuthState(pub Arc<CodexOAuthManager>);
/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度
///
@@ -26,7 +29,7 @@ pub async fn get_codex_oauth_quota(
account_id: Option<String>,
state: State<'_, CodexOAuthState>,
) -> Result<SubscriptionQuota, String> {
let manager = state.0.read().await;
let manager = &state.0;
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
let resolved = match account_id {
@@ -69,7 +72,7 @@ pub async fn get_codex_oauth_models(
account_id: Option<String>,
state: State<'_, CodexOAuthState>,
) -> Result<Vec<FetchedModel>, String> {
let manager = state.0.read().await;
let manager = &state.0;
let resolved = match account_id
.as_deref()
.map(str::trim)
+11
View File
@@ -99,6 +99,15 @@ pub async fn get_config_status(
Ok(ConfigStatus { exists, path })
}
AppType::GrokBuild => {
let config_path = crate::grok_config::get_grok_config_path();
let exists = config_path.exists();
let path = crate::grok_config::get_grok_config_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
AppType::OpenCode => {
let config_path = crate::opencode_config::get_opencode_config_path();
let exists = config_path.exists();
@@ -143,6 +152,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
@@ -160,6 +170,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
+107 -22
View File
@@ -111,8 +111,8 @@ pub struct ToolVersion {
wsl_distro: Option<String>,
}
const VALID_TOOLS: [&str; 6] = [
"claude", "codex", "gemini", "opencode", "openclaw", "hermes",
const VALID_TOOLS: [&str; 7] = [
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
];
#[derive(Debug, Clone, serde::Deserialize)]
@@ -424,6 +424,7 @@ fn tool_display_name(tool: &str) -> &'static str {
"claude" => "Claude Code",
"codex" => "Codex",
"gemini" => "Gemini CLI",
"grok" => "Grok Build",
"opencode" => "OpenCode",
"openclaw" => "OpenClaw",
"hermes" => "Hermes",
@@ -492,6 +493,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
"claude" => Some("npm i -g @anthropic-ai/claude-code@latest"),
"codex" => Some("npm i -g @openai/codex@latest"),
"gemini" => Some("npm i -g @google/gemini-cli@latest"),
"grok" => Some("npm i -g @xai-official/grok@latest"),
"opencode" => Some("npm i -g opencode-ai@latest"),
"openclaw" => Some("npm i -g openclaw@latest"),
_ => None,
@@ -638,7 +640,7 @@ fn build_tool_action_line(
// (npm/pnpm)或 .exe(volta),静态命令头部是 `npm`(也是 .cmd)、`py` 等——
// 全部加 `call ` 前缀,风格统一且语义正确。含空格的头部已被 `win_quote_path_for_batch`
// 加上双引号,call 对带引号的路径解析正常。
return Ok(format!("call {command}"));
Ok(format!("call {command}"))
}
#[cfg(not(target_os = "windows"))]
@@ -762,6 +764,7 @@ async fn get_single_tool_version_impl(
}
"codex" => fetch_npm_latest_for_tool(&client, "@openai/codex", tool, local).await,
"gemini" => fetch_npm_latest_for_tool(&client, "@google/gemini-cli", tool, local).await,
"grok" => fetch_npm_latest_for_tool(&client, "@xai-official/grok", tool, local).await,
"opencode" => {
if let Some(version) =
fetch_npm_latest_for_tool(&client, "opencode-ai", tool, local).await
@@ -1069,6 +1072,8 @@ fn default_flag_for_shell(shell: &str) -> &'static str {
}
}
// 以下 shell 解析辅助函数仅被 macOS/Linux 的终端启动逻辑使用;Windows 非 test 编译下为死代码。
#[cfg_attr(windows, allow(dead_code))]
fn fallback_user_shell() -> &'static str {
if cfg!(target_os = "macos") {
"/bin/zsh"
@@ -1077,6 +1082,7 @@ fn fallback_user_shell() -> &'static str {
}
}
#[cfg_attr(windows, allow(dead_code))]
fn valid_user_shell_path(shell: &str) -> bool {
if shell.is_empty()
|| !shell.starts_with('/')
@@ -1100,11 +1106,13 @@ fn is_executable_file(path: &std::path::Path) -> bool {
}
#[cfg(not(unix))]
#[cfg_attr(windows, allow(dead_code))]
fn is_executable_file(path: &std::path::Path) -> bool {
path.is_file()
}
/// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。
#[cfg_attr(windows, allow(dead_code))]
fn get_user_shell() -> String {
std::env::var("SHELL")
.ok()
@@ -1113,6 +1121,7 @@ fn get_user_shell() -> String {
}
/// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。
#[cfg_attr(windows, allow(dead_code))]
fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
let quoted_shell = shell_single_quote(shell);
@@ -1132,6 +1141,7 @@ fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
}
/// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。
#[cfg_attr(windows, allow(dead_code))]
fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String {
let claude_command = format!("claude --settings {}", shell_single_quote(config_path));
let command = cwd
@@ -1152,6 +1162,7 @@ fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path
)
}
#[cfg_attr(windows, allow(dead_code))]
fn provider_command_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
@@ -1160,6 +1171,7 @@ fn provider_command_flag_for_shell(shell: &str) -> &'static str {
}
}
#[cfg_attr(windows, allow(dead_code))]
fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String {
if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") {
return String::new();
@@ -1936,6 +1948,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
"claude" => Some("@anthropic-ai/claude-code"),
"codex" => Some("@openai/codex"),
"gemini" => Some("@google/gemini-cli"),
"grok" => Some("@xai-official/grok"),
"opencode" => Some("opencode-ai"),
"openclaw" => Some("openclaw"),
_ => None,
@@ -2248,7 +2261,8 @@ fn package_manager_anchored_command_from_paths(
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI / Grok Build) → 锚定到
/// "那处 bin 目录的 npm"。
#[cfg(not(target_os = "windows"))]
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
let real_lower = real_target.to_ascii_lowercase();
@@ -2544,6 +2558,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
"grok" => crate::settings::get_grok_override_dir(),
"opencode" => crate::settings::get_opencode_override_dir(),
"openclaw" => crate::settings::get_openclaw_override_dir(),
"hermes" => crate::settings::get_hermes_override_dir(),
@@ -2731,7 +2746,7 @@ fn launch_terminal_with_env(
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(());
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
@@ -3252,6 +3267,7 @@ del \"%~f0\" >nul 2>&1
result
}
#[cfg_attr(windows, allow(dead_code))]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
@@ -3650,6 +3666,35 @@ mod tests {
assert_eq!(extract_version("no version here"), "no version here");
}
#[test]
fn grok_lifecycle_metadata_is_consistent() {
let requested = vec!["unsupported".to_string(), "grok".to_string()];
assert_eq!(normalize_requested_tools(&requested), vec!["grok"]);
assert_eq!(tool_display_name("grok"), "Grok Build");
assert_eq!(npm_package_for("grok"), Some("@xai-official/grok"));
assert_eq!(
npm_install_command_for("grok"),
Some("npm i -g @xai-official/grok@latest")
);
assert_eq!(official_update_args("grok"), None);
for shell in [
LifecycleCommandShell::Posix,
LifecycleCommandShell::WindowsBatch,
] {
assert_eq!(
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Install, shell,)
.as_deref(),
Some("npm i -g @xai-official/grok@latest")
);
assert_eq!(
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Update, shell,)
.as_deref(),
Some("npm i -g @xai-official/grok@latest")
);
}
}
#[test]
fn test_compare_semver() {
use std::cmp::Ordering;
@@ -3838,11 +3883,8 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("Volta", "codex.cmd", &["volta.exe"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let volta_full = format!("{}\\volta.exe", sub.to_string_lossy());
let expected = format!(
"{} update || call {} install @openai/codex",
expect_quoted_path(&bin_path),
expect_quoted_path(&volta_full)
);
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!("{} install @openai/codex", expect_quoted_path(&volta_full));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
@@ -3854,9 +3896,9 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("pnpm", "codex.cmd", &["pnpm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy());
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!(
"{} update || call {} add -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} add -g @openai/codex@latest",
expect_quoted_path(&pnpm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -3888,9 +3930,21 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "codex.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!(
"{} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn grok_windows_anchors_to_sibling_npm() {
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "grok.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
let expected = format!(
"{} i -g @xai-official/grok@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -3898,10 +3952,11 @@ mod tests {
#[test]
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
// sibling npm.cmd 不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
// 只是没有包管理器 fallback。
let (_dir, _sub, bin_path) = setup_sibling("", "codex.cmd", &[]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
// sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
// 只是没有包管理器 fallback。用 claude —— codex 自 5092fe51 起一律走 npm 锚定,
// 已不再有官方 self-update 分支,故改用仍在 prefers_official_update 的 claude 覆盖此路径。
let (_dir, _sub, bin_path) = setup_sibling("", "claude.cmd", &[]);
let cmd = anchored_command_from_paths("claude", &bin_path, &bin_path);
let expected = format!("{} update", expect_quoted_path(&bin_path));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
@@ -3977,9 +4032,9 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("Program Files", "codex.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 走 npm 锚定(5092fe51),含空格的 npm 全路径仍必须被双引号包裹。
let expected = format!(
"{} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -4001,9 +4056,10 @@ mod tests {
// 含空格的环境**(否则 sub 本身含空格 + 子目录 `path%foo%` 触发 4 倍 `%` 转义
// 会让 expected 漏引号、假失败)。
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 走 npm 锚定(5092fe51)batch 行 = `call <npm 全路径> i -g ...`
// 含字面 `%` 的 npm 路径仍须 4 倍转义。
let expected = format!(
"call {} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"call {} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(batch_line, expected);
@@ -4214,6 +4270,9 @@ mod tests {
let codex =
wsl_tool_action_shell_command("codex", ToolLifecycleAction::Install).unwrap();
assert_eq!(codex, "npm i -g @openai/codex@latest");
let grok = wsl_tool_action_shell_command("grok", ToolLifecycleAction::Install).unwrap();
assert_eq!(grok, "npm i -g @xai-official/grok@latest");
}
#[test]
@@ -4374,6 +4433,21 @@ mod tests {
);
}
#[test]
fn grok_nvm_anchors_to_npm_without_cli_update() {
let cmd = anchored_command_from_paths(
"grok",
"/Users/me/.nvm/versions/node/v22.14.0/bin/grok",
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@xai-official/grok/bin/grok",
);
assert_eq!(
cmd.as_deref(),
Some(
"/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @xai-official/grok@latest"
)
);
}
#[test]
fn codex_nvm_anchors_to_that_npm() {
// Codex 不走 self-update`codex update` 在 npm 安装上只是裸 `npm install -g`
@@ -4824,6 +4898,12 @@ mod tests {
assert_eq!(cmd, "npm i -g @google/gemini-cli@latest");
}
#[test]
fn grok_install_keeps_static_npm() {
let cmd = install_command_for("grok");
assert_eq!(cmd, "npm i -g @xai-official/grok@latest");
}
#[test]
fn openclaw_install_keeps_static_npm() {
let cmd = install_command_for("openclaw");
@@ -4846,6 +4926,11 @@ mod tests {
"npm i -g @google/gemini-cli@latest"
);
assert!(!static_fallback_command("gemini").contains("gemini update"));
assert_eq!(
static_fallback_command("grok"),
"npm i -g @xai-official/grok@latest"
);
assert!(!static_fallback_command("grok").contains("grok update"));
assert_eq!(
static_fallback_command("opencode"),
"opencode upgrade || npm i -g opencode-ai@latest"
+1
View File
@@ -23,6 +23,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
if takeover.claude
|| takeover.codex
|| takeover.gemini
|| takeover.grokbuild
|| takeover.opencode
|| takeover.openclaw
{
+1 -1
View File
@@ -247,7 +247,7 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
"Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。"
)
})?;
return Ok(true);
Ok(true)
}
#[cfg(not(target_os = "windows"))]
+8 -5
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -30,8 +30,9 @@ impl Database {
let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
let enabled_hermes: bool = row.get(11)?;
let enabled_grokbuild: bool = row.get(10)?;
let enabled_opencode: bool = row.get(11)?;
let enabled_hermes: bool = row.get(12)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -46,6 +47,7 @@ impl Database {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
grokbuild: enabled_grokbuild,
opencode: enabled_opencode,
hermes: enabled_hermes,
},
@@ -72,8 +74,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
server.id,
server.name,
@@ -88,6 +90,7 @@ impl Database {
server.apps.claude,
server.apps.codex,
server.apps.gemini,
server.apps.grokbuild,
server.apps.opencode,
server.apps.hermes,
],
+13
View File
@@ -328,6 +328,7 @@ impl Database {
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
"grokbuild" => (3, 60, 120, 4, 2, 60, 0.6, 10),
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
};
@@ -398,6 +399,18 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// grokbuild: Responses protocol, same timeout defaults as Codex.
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
) VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
+37 -36
View File
@@ -22,8 +22,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -43,12 +43,13 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -67,8 +68,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -87,12 +88,13 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
});
@@ -109,9 +111,9 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
skill.id,
skill.name,
@@ -124,6 +126,7 @@ impl Database {
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.apps.grokbuild,
skill.apps.opencode,
skill.apps.hermes,
skill.installed_at,
@@ -157,8 +160,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
@@ -232,32 +235,30 @@ impl Database {
Ok(())
}
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库
/// 初始化默认的 Skill 仓库(启动时调用,每个数据库仅执行一次
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
// 获取已有仓库列表
let existing = self.get_skill_repos()?;
let existing_keys: std::collections::HashSet<(String, String)> = existing
.iter()
.map(|r| (r.owner.clone(), r.name.clone()))
.collect();
const INITIALIZED_KEY: &str = "default_skill_repos_initialized";
if self.get_bool_flag(INITIALIZED_KEY)? {
return Ok(0);
}
// 兼容升级前已经存在的用户选择,并记录初始化状态,避免以后删空后恢复默认值。
if !self.get_skill_repos()?.is_empty() {
self.set_setting(INITIALIZED_KEY, "true")?;
return Ok(0);
}
// 获取默认仓库列表
let default_store = crate::services::skill::SkillStore::default();
let mut count = 0;
// 仅插入缺失的默认仓库
for repo in &default_store.repos {
let key = (repo.owner.clone(), repo.name.clone());
if !existing_keys.contains(&key) {
self.save_skill_repo(repo)?;
count += 1;
log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name);
}
self.save_skill_repo(repo)?;
count += 1;
log::info!("初始化默认 Skill 仓库: {}/{}", repo.owner, repo.name);
}
if count > 0 {
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
}
self.set_setting(INITIALIZED_KEY, "true")?;
Ok(count)
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 13;
pub(crate) const SCHEMA_VERSION: i32 = 15;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+238 -4
View File
@@ -65,7 +65,8 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
)",
[],
@@ -93,6 +94,7 @@ impl Database {
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
@@ -122,7 +124,7 @@ impl Database {
// 8. Proxy Config 表(三行结构,app_type 主键)
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
@@ -169,6 +171,15 @@ impl Database {
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
// 9. Provider Health 表
@@ -485,6 +496,16 @@ impl Database {
Self::migrate_v12_to_v13(conn)?;
Self::set_user_version(conn, 13)?;
}
13 => {
log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)");
Self::migrate_v13_to_v14(conn)?;
Self::set_user_version(conn, 14)?;
}
14 => {
log::info!("迁移数据库从 v14 到 v15Skills/MCP 添加 Grok Build 支持)");
Self::migrate_v14_to_v15(conn)?;
Self::set_user_version(conn, 15)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -798,12 +819,25 @@ impl Database {
old_cb.3,
old_cb.4,
),
(
"grokbuild",
false,
false,
3,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
];
// 创建新表
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
conn.execute("CREATE TABLE proxy_config_new (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
@@ -814,6 +848,7 @@ impl Database {
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
live_takeover_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -1354,6 +1389,127 @@ impl Database {
Ok(())
}
/// v13 -> v14: allow Grok Build to own an independent proxy configuration row.
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
if !Self::table_exists(conn, "proxy_config")? {
return Ok(());
}
conn.execute("DROP TABLE IF EXISTS proxy_config_v14", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE TABLE proxy_config_v14 (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721,
enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0,
auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4,
circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60,
circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
live_takeover_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let copied_columns = [
("app_type", "'claude'"),
("proxy_enabled", "0"),
("listen_address", "'127.0.0.1'"),
("listen_port", "15721"),
("enable_logging", "1"),
("enabled", "0"),
("auto_failover_enabled", "0"),
("max_retries", "3"),
("streaming_first_byte_timeout", "60"),
("streaming_idle_timeout", "120"),
("non_streaming_timeout", "600"),
("circuit_failure_threshold", "4"),
("circuit_success_threshold", "2"),
("circuit_timeout_seconds", "60"),
("circuit_error_rate_threshold", "0.6"),
("circuit_min_requests", "10"),
("default_cost_multiplier", "'1'"),
("pricing_model_source", "'response'"),
("live_takeover_active", "0"),
("created_at", "datetime('now')"),
("updated_at", "datetime('now')"),
]
.into_iter()
.map(|(column, fallback)| {
Self::has_column(conn, "proxy_config", column).map(|exists| {
if exists {
format!("\"{column}\"")
} else {
fallback.into()
}
})
})
.collect::<Result<Vec<_>, AppError>>()?
.join(", ");
let copy_sql = format!(
"INSERT INTO proxy_config_v14 (
app_type, proxy_enabled, listen_address, listen_port, enable_logging,
enabled, auto_failover_enabled, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests,
default_cost_multiplier, pricing_model_source, live_takeover_active,
created_at, updated_at
)
SELECT {copied_columns} FROM proxy_config"
);
conn.execute(&copy_sql, [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("DROP TABLE proxy_config", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// v14 -> v15: persist Grok Build enablement for unified Skills and MCP.
fn migrate_v14_to_v15(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "mcp_servers")? {
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_grokbuild",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(
conn,
"skills",
"enabled_grokbuild",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -2766,7 +2922,7 @@ mod tests {
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, 13);
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"proxy_request_logs",
@@ -2787,4 +2943,82 @@ mod tests {
Ok(())
}
#[test]
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
Database::create_tables_on_conn(&conn)?;
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
conn.execute(
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
[],
)?;
Database::set_user_version(&conn, 13)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
let grok_rows: i64 = conn.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'",
[],
|row| row.get(0),
)?;
assert_eq!(grok_rows, 1);
let codex_values: (i64, i64) = conn.query_row(
"SELECT enabled, max_retries FROM proxy_config WHERE app_type = 'codex'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(codex_values, (1, 9));
Ok(())
}
#[test]
fn migrate_v14_to_v15_adds_grokbuild_skill_and_mcp_flags() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE skills (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);",
)?;
conn.execute(
"INSERT INTO mcp_servers (id, enabled_codex) VALUES ('mcp-1', 1)",
[],
)?;
conn.execute(
"INSERT INTO skills (id, enabled_codex) VALUES ('skill-1', 1)",
[],
)?;
Database::set_user_version(&conn, 14)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"mcp_servers",
"enabled_grokbuild"
)?);
assert!(Database::has_column(&conn, "skills", "enabled_grokbuild")?);
let mcp_values: (i64, i64) = conn.query_row(
"SELECT enabled_codex, enabled_grokbuild FROM mcp_servers WHERE id = 'mcp-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
let skill_values: (i64, i64) = conn.query_row(
"SELECT enabled_codex, enabled_grokbuild FROM skills WHERE id = 'skill-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(mcp_values, (1, 0));
assert_eq!(skill_values, (1, 0));
Ok(())
}
}
+34 -2
View File
@@ -151,6 +151,38 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
}
#[test]
fn deleted_default_skill_repo_is_not_restored() {
let db = Database::memory().expect("create memory db");
assert_eq!(db.init_default_skill_repos().expect("initialize repos"), 4);
for repo in db.get_skill_repos().expect("get initialized repos") {
db.delete_skill_repo(&repo.owner, &repo.name)
.expect("delete repo");
}
assert!(db.get_skill_repos().expect("get deleted repos").is_empty());
assert_eq!(
db.init_default_skill_repos().expect("reinitialize repos"),
0
);
assert!(db.get_skill_repos().expect("get repos").is_empty());
}
#[test]
fn existing_skill_repo_selection_is_not_supplemented() {
let db = Database::memory().expect("create memory db");
let default_store = crate::services::skill::SkillStore::default();
db.save_skill_repo(&default_store.repos[0])
.expect("save existing repo");
assert_eq!(db.init_default_skill_repos().expect("initialize repos"), 0);
assert_eq!(db.get_skill_repos().expect("get repos").len(), 1);
assert!(db
.get_bool_flag("default_skill_repos_initialized")
.expect("get initialized flag"));
}
#[test]
fn schema_migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
@@ -517,7 +549,7 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count rows");
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
assert_eq!(count, 4, "per-app proxy_config should have 4 rows");
// 新结构下应能按 app_type 查询
let _: i32 = conn
@@ -657,7 +689,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
let proxy_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count proxy_config rows");
assert_eq!(proxy_rows, 3);
assert_eq!(proxy_rows, 4);
// model_pricing 应具备默认数据(迁移时会 seed)
let pricing_rows: i64 = conn
+40 -11
View File
@@ -101,17 +101,7 @@ pub fn import_mcp_from_deeplink(
// Server exists - merge apps only, keep other fields unchanged
log::info!("MCP server '{id}' already exists, merging apps only");
let mut merged_apps = existing.apps.clone();
// Merge new apps into existing apps
if target_apps.claude {
merged_apps.claude = true;
}
if target_apps.codex {
merged_apps.codex = true;
}
if target_apps.gemini {
merged_apps.gemini = true;
}
let merged_apps = merge_mcp_apps(&existing.apps, &target_apps);
McpServer {
id: existing.id.clone(),
@@ -166,6 +156,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
};
@@ -175,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"grokbuild" | "grok" => apps.grokbuild = true,
"opencode" => apps.opencode = true,
"openclaw" => {
// OpenClaw doesn't support MCP, ignore silently
@@ -197,3 +189,40 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
Ok(apps)
}
fn merge_mcp_apps(existing: &McpApps, target: &McpApps) -> McpApps {
let mut merged = existing.clone();
for app in target.enabled_apps() {
merged.set_enabled_for(&app, true);
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enabled_apps_merge_covers_every_supported_mcp_client() {
let existing = McpApps {
claude: true,
..McpApps::default()
};
let target = McpApps {
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
hermes: true,
..McpApps::default()
};
let merged = merge_mcp_apps(&existing, &target);
assert!(merged.claude);
assert!(merged.codex);
assert!(merged.gemini);
assert!(merged.grokbuild);
assert!(merged.opencode);
assert!(merged.hermes);
}
}
+13 -6
View File
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -262,10 +262,17 @@ fn parse_mcp_deeplink(
let trimmed = app.trim();
if !matches!(
trimmed,
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude"
| "codex"
| "gemini"
| "grokbuild"
| "grok"
| "opencode"
| "openclaw"
| "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
)));
}
}
+82
View File
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::GrokBuild => build_grokbuild_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
@@ -444,6 +445,36 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!({ "env": env })
}
fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let model = request
.model
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(crate::grok_config::DEFAULT_MODEL)
.trim();
let name = request
.name
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("custom")
.trim();
let endpoint = get_primary_endpoint(request).trim().to_string();
let api_key = request.api_key.as_deref().unwrap_or("").trim();
let model_value = toml_edit::Value::from(model).to_string();
let name_value = toml_edit::Value::from(name).to_string();
let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string();
let api_key_value = toml_edit::Value::from(api_key).to_string();
json!({
"config": format!(
"[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n",
crate::grok_config::DEFAULT_API_BACKEND,
crate::grok_config::DEFAULT_CONTEXT_WINDOW,
)
})
}
/// Build OpenCode settings configuration
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
@@ -600,6 +631,7 @@ pub fn parse_and_merge_config(
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
"grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" | "hermes" => {
merge_additive_config(&mut merged, &config_value)?;
@@ -774,6 +806,56 @@ fn merge_gemini_config(
Ok(())
}
fn merge_grokbuild_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
config_toml.to_string()
} else {
let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| {
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
})?;
toml::to_string(&toml_value).map_err(|error| {
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
})?
};
let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| {
AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string())
})?;
if request
.api_key
.as_ref()
.is_none_or(|value| value.is_empty())
{
request.api_key = model.api_key.or_else(|| {
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
});
}
if request
.endpoint
.as_ref()
.is_none_or(|value| value.is_empty())
{
request.endpoint = Some(model.base_url);
}
if request.model.is_none() {
request.model = Some(model.model);
}
if request
.homepage
.as_ref()
.is_none_or(|value| value.is_empty())
{
if let Some(endpoint) = request.endpoint.as_deref() {
request.homepage = infer_homepage_from_endpoint(endpoint);
}
}
Ok(())
}
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
///
/// These apps use JSON config directly, so we only extract common fields
+95
View File
@@ -87,6 +87,39 @@ fn test_parse_deeplink_with_notes() {
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn test_parse_grokbuild_provider() {
use super::provider::build_provider_from_request;
let url = "ccswitch://v1/import?resource=provider&app=grokbuild&name=Grok%20Relay&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=secret&model=grok-4.5";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.app.as_deref(), Some("grokbuild"));
assert_eq!(request.name.as_deref(), Some("Grok Relay"));
assert_eq!(
request.endpoint.as_deref(),
Some("https://api.example.com/v1")
);
assert_eq!(request.api_key.as_deref(), Some("secret"));
assert_eq!(request.model.as_deref(), Some("grok-4.5"));
let provider = build_provider_from_request(&AppType::GrokBuild, &request).unwrap();
let config = provider.settings_config["config"].as_str().unwrap();
let document = config.parse::<toml::Value>().unwrap();
let model = &document["model"]["grok-4.5"];
assert_eq!(document["models"]["default"].as_str(), Some("grok-4.5"));
assert_eq!(
model["base_url"].as_str(),
Some("https://api.example.com/v1")
);
assert_eq!(model["name"].as_str(), Some("Grok Relay"));
assert_eq!(model["api_key"].as_str(), Some("secret"));
assert_eq!(model["api_backend"].as_str(), Some("responses"));
assert_eq!(model["context_window"].as_integer(), Some(500_000));
}
#[test]
fn test_parse_invalid_scheme() {
let url = "https://v1/import?resource=provider&app=claude&name=Test";
@@ -500,6 +533,38 @@ experimental_bearer_token = "sk-rightcode"
assert_eq!(merged.model, Some("gpt-5-codex".to_string()));
}
#[test]
fn test_parse_and_merge_config_grokbuild() {
let config_toml = r#"[models]
default = "grok-profile"
[model."grok-profile"]
model = "grok-upstream"
base_url = "https://grok.example/v1"
name = "Grok Relay"
api_key = "sk-grok"
api_backend = "responses"
context_window = 500000
"#;
let config_json = serde_json::json!({ "config": config_toml }).to_string();
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("grokbuild".to_string()),
name: Some("Grok Relay".to_string()),
config: Some(BASE64_STANDARD.encode(config_json.as_bytes())),
config_format: Some("json".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request).expect("merge Grok Build config");
assert_eq!(merged.api_key.as_deref(), Some("sk-grok"));
assert_eq!(merged.endpoint.as_deref(), Some("https://grok.example/v1"));
assert_eq!(merged.model.as_deref(), Some("grok-upstream"));
assert_eq!(merged.homepage.as_deref(), Some("https://grok.example"));
}
#[test]
fn test_parse_and_merge_config_url_override() {
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
@@ -709,6 +774,11 @@ fn test_parse_mcp_apps() {
assert!(!apps.codex);
assert!(apps.gemini);
let apps = parse_mcp_apps("grokbuild,opencode,hermes").unwrap();
assert!(apps.grokbuild);
assert!(apps.opencode);
assert!(apps.hermes);
let err = parse_mcp_apps("invalid").unwrap_err();
assert!(err.to_string().contains("Invalid app"));
}
@@ -731,6 +801,18 @@ fn test_parse_prompt_deeplink() {
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_grokbuild_prompt_deeplink() {
let content_b64 = BASE64_STANDARD.encode("Grok instructions");
let url = format!(
"ccswitch://v1/import?resource=prompt&app=grokbuild&name=test&content={content_b64}"
);
let request = parse_deeplink_url(&url).expect("parse Grok Build prompt deeplink");
assert_eq!(request.app.as_deref(), Some("grokbuild"));
}
#[test]
fn test_parse_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
@@ -747,6 +829,19 @@ fn test_parse_mcp_deeplink() {
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_grokbuild_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
let config_b64 = BASE64_STANDARD.encode(config);
let url = format!(
"ccswitch://v1/import?resource=mcp&apps=grokbuild&config={config_b64}&enabled=true"
);
let request = parse_deeplink_url(&url).expect("parse Grok Build MCP deeplink");
assert_eq!(request.apps.as_deref(), Some("grokbuild"));
}
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
+516
View File
@@ -0,0 +1,516 @@
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use crate::config::{get_home_dir, write_text_file};
use crate::error::AppError;
use crate::provider::Provider;
pub const DEFAULT_MODEL: &str = "grok-4.5";
pub const DEFAULT_API_BACKEND: &str = "responses";
pub const DEFAULT_CONTEXT_WINDOW: i64 = 500_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrokModelConfig {
pub profile: String,
pub model: String,
pub base_url: String,
pub name: String,
pub api_key: Option<String>,
pub env_key: Option<String>,
pub api_backend: String,
pub context_window: i64,
}
/// Grok Build configuration directory (`~/.grok`).
pub fn get_grok_config_dir() -> PathBuf {
crate::settings::get_grok_override_dir().unwrap_or_else(|| get_home_dir().join(".grok"))
}
/// Grok Build live configuration path (`~/.grok/config.toml`).
pub fn get_grok_config_path() -> PathBuf {
get_grok_config_dir().join("config.toml")
}
fn required_non_empty_string<'a>(
table: &'a toml::value::Table,
key: &str,
) -> Result<&'a str, AppError> {
table
.get(key)
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.field.missing",
format!("Grok Build 配置缺少有效的 {key} 字段"),
format!("Grok Build configuration is missing a valid {key} field"),
)
})
}
fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option<String> {
table
.get(key)
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
/// Validate the provider-owned Grok Build TOML document.
pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> {
let document = config_toml.parse::<toml::Value>().map_err(|error| {
AppError::localized(
"provider.grokbuild.config.invalid_toml",
format!("Grok Build config.toml 格式错误: {error}"),
format!("Invalid Grok Build config.toml: {error}"),
)
})?;
let root = document.as_table().ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.not_table",
"Grok Build 配置必须是 TOML 表结构",
"Grok Build configuration must be a TOML table",
)
})?;
let models = root
.get("models")
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.models.missing",
"Grok Build 配置缺少 [models]",
"Grok Build configuration is missing [models]",
)
})?;
let default_model = required_non_empty_string(models, "default")?;
let model_entries = root
.get("model")
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.model.missing",
"Grok Build 配置缺少 [model.<name>]",
"Grok Build configuration is missing [model.<name>]",
)
})?;
let selected_model = model_entries
.get(default_model)
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
)
})?;
required_non_empty_string(selected_model, "model")?;
required_non_empty_string(selected_model, "base_url")?;
required_non_empty_string(selected_model, "name")?;
if optional_non_empty_string(selected_model, "api_key").is_none()
&& optional_non_empty_string(selected_model, "env_key").is_none()
{
return Err(AppError::localized(
"provider.grokbuild.credentials.missing",
"Grok Build 配置缺少有效的 api_key 或 env_key 字段",
"Grok Build configuration is missing a valid api_key or env_key field",
));
}
required_non_empty_string(selected_model, "api_backend")?;
selected_model
.get("context_window")
.and_then(toml::Value::as_integer)
.filter(|value| *value > 0)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.context_window.invalid",
"Grok Build context_window 必须是正整数",
"Grok Build context_window must be a positive integer",
)
})?;
Ok(())
}
pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
let document = config_toml.parse::<toml::Value>().ok()?;
let root = document.as_table()?;
let default_model = root
.get("models")?
.as_table()?
.get("default")?
.as_str()?
.trim();
let selected_model = root
.get("model")?
.as_table()?
.get(default_model)?
.as_table()?;
Some(GrokModelConfig {
profile: default_model.to_string(),
model: selected_model.get("model")?.as_str()?.trim().to_string(),
base_url: selected_model
.get("base_url")?
.as_str()?
.trim_end_matches('/')
.to_string(),
name: selected_model.get("name")?.as_str()?.trim().to_string(),
api_key: optional_non_empty_string(selected_model, "api_key"),
env_key: optional_non_empty_string(selected_model, "env_key"),
api_backend: selected_model
.get("api_backend")?
.as_str()?
.trim()
.to_string(),
context_window: selected_model.get("context_window")?.as_integer()?,
})
}
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
let config = extract_model_config(config_toml)?;
let api_key = config
.api_key
.or_else(|| {
config
.env_key
.as_deref()
.and_then(|key| std::env::var(key).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.or_else(|| {
std::env::var("XAI_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})?;
Some((config.base_url, api_key))
}
pub fn extract_inline_api_key(config_toml: &str) -> Option<String> {
extract_model_config(config_toml)?.api_key
}
pub fn extract_base_url(config_toml: &str) -> Option<String> {
Some(extract_model_config(config_toml)?.base_url)
}
fn update_selected_model_string(
config_toml: &str,
field: &str,
value: &str,
) -> Result<String, AppError> {
let mut document = config_toml
.parse::<toml_edit::DocumentMut>()
.map_err(|error| {
AppError::localized(
"provider.grokbuild.config.invalid_toml",
format!("Grok Build config.toml 格式错误: {error}"),
format!("Invalid Grok Build config.toml: {error}"),
)
})?;
let default_model = document
.get("models")
.and_then(|item| item.get("default"))
.and_then(toml_edit::Item::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
"Grok Build 配置缺少 models.default",
"Grok Build configuration is missing models.default",
)
})?
.to_string();
let selected_model = document
.get_mut("model")
.and_then(|item| item.get_mut(&default_model))
.and_then(toml_edit::Item::as_table_like_mut)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
)
})?;
selected_model.insert(field, toml_edit::value(value));
Ok(document.to_string())
}
pub fn apply_proxy_takeover(
config_toml: &str,
proxy_base_url: &str,
token_placeholder: &str,
) -> Result<String, AppError> {
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
update_selected_model_string(&updated, "api_key", token_placeholder)
}
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
update_selected_model_string(config_toml, "api_key", api_key)
}
pub fn has_proxy_placeholder(config_toml: &str, token_placeholder: &str) -> bool {
extract_model_config(config_toml)
.and_then(|config| config.api_key)
.is_some_and(|api_key| api_key == token_placeholder)
}
pub fn base_url_matches(config_toml: &str, predicate: impl FnOnce(&str) -> bool) -> bool {
extract_model_config(config_toml).is_some_and(|config| predicate(&config.base_url))
}
/// Remove MCP projections from a provider-owned Grok Build settings snapshot.
/// MCP servers are owned by the database and projected into live config.toml.
pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(Value::as_str)
.map(str::to_string)
else {
return Ok(());
};
if !config_text.contains("mcp") {
return Ok(());
}
let mut document = config_text
.parse::<toml_edit::DocumentMut>()
.map_err(|error| AppError::Message(format!("Invalid Grok Build config.toml: {error}")))?;
let mut changed = document.as_table_mut().remove("mcp_servers").is_some();
if let Some(mcp_table) = document
.get_mut("mcp")
.and_then(toml_edit::Item::as_table_like_mut)
{
if mcp_table.remove("servers").is_some() {
changed = true;
}
if mcp_table.is_empty() {
document.as_table_mut().remove("mcp");
}
}
if changed {
if let Some(object) = settings.as_object_mut() {
object.insert("config".to_string(), Value::String(document.to_string()));
}
}
Ok(())
}
pub fn read_grok_live_settings() -> Result<Value, AppError> {
let path = get_grok_config_path();
if !path.exists() {
return Err(AppError::localized(
"grokbuild.config.missing",
"Grok Build 配置文件不存在",
"Grok Build configuration file not found",
));
}
let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
validate_config_toml(&config)?;
Ok(json!({ "config": config }))
}
pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::localized(
"provider.grokbuild.settings.not_object",
"Grok Build 配置必须是 JSON 对象",
"Grok Build configuration must be a JSON object",
)
})?;
let config = settings
.get("config")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.missing",
"Grok Build 配置缺少 config 字段",
"Grok Build configuration is missing the config field",
)
})?;
write_grok_live_settings(&json!({ "config": config }))
}
pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
let config = settings
.get("config")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.missing",
"Grok Build 配置缺少 config 字段",
"Grok Build configuration is missing the config field",
)
})?;
validate_config_toml(config)?;
write_text_file(&get_grok_config_path(), config)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::TempDir;
fn valid_config() -> &'static str {
r#"[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "grok-4.5"
base_url = "https://example.com/v1"
name = "Example"
api_key = "secret"
api_backend = "responses"
context_window = 500000
"#
}
fn valid_env_key_config() -> &'static str {
r#"[models]
default = "grok-env"
[model."grok-env"]
model = "grok-4.5"
base_url = "https://example.com/v1"
name = "Example Env"
env_key = "GROK_TEST_API_KEY"
api_backend = "responses"
context_window = 500000
"#
}
#[test]
fn validates_expected_config_shape() {
validate_config_toml(valid_config()).expect("valid Grok Build config");
validate_config_toml(valid_env_key_config()).expect("valid env_key configuration");
}
#[test]
fn rejects_missing_selected_model_table() {
let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n")
.expect_err("missing model table should fail");
assert!(error.to_string().contains("model"));
}
#[test]
fn rejects_config_without_api_key_or_env_key() {
let config = valid_config().replace("api_key = \"secret\"\n", "");
let error = validate_config_toml(&config).expect_err("credentials should be required");
assert!(error.to_string().contains("api_key"));
assert!(error.to_string().contains("env_key"));
}
#[test]
fn extracts_selected_model_and_updates_takeover_fields() {
let selected = extract_model_config(valid_config()).expect("selected model");
assert_eq!(selected.profile, "grok-4.5");
assert_eq!(selected.model, "grok-4.5");
assert_eq!(selected.base_url, "https://example.com/v1");
let updated = apply_proxy_takeover(
valid_config(),
"http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED",
)
.expect("takeover config");
let selected = extract_model_config(&updated).expect("updated selected model");
assert_eq!(selected.base_url, "http://127.0.0.1:15721/grokbuild/v1");
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
assert!(has_proxy_placeholder(&updated, "PROXY_MANAGED"));
}
#[test]
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
let updated = apply_proxy_takeover(
valid_env_key_config(),
"http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED",
)
.expect("takeover config");
let selected = extract_model_config(&updated).expect("updated selected model");
assert_eq!(selected.profile, "grok-env");
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
}
#[test]
#[serial]
fn resolves_api_key_from_configured_environment_variable() {
let original = std::env::var_os("GROK_TEST_API_KEY");
std::env::set_var("GROK_TEST_API_KEY", "env-secret");
let credentials = extract_credentials(valid_env_key_config()).expect("credentials");
assert_eq!(credentials.0, "https://example.com/v1");
assert_eq!(credentials.1, "env-secret");
match original {
Some(value) => std::env::set_var("GROK_TEST_API_KEY", value),
None => std::env::remove_var("GROK_TEST_API_KEY"),
}
}
#[test]
fn strips_projected_mcp_servers_without_touching_model_config() {
let mut settings = json!({
"config": format!(
"{}\n[mcp_servers.echo]\ncommand = \"echo\"\n",
valid_config()
)
});
strip_grok_mcp_servers_from_settings(&mut settings).expect("strip MCP servers");
let config = settings.get("config").and_then(Value::as_str).unwrap();
assert!(!config.contains("mcp_servers"));
assert!(config.contains("model = \"grok-4.5\""));
validate_config_toml(config).expect("stripped config remains valid");
}
#[test]
#[serial]
fn writes_and_reads_live_config() {
let temp = TempDir::new().expect("temp dir");
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
let provider = Provider::with_id(
"grok".to_string(),
"Example".to_string(),
json!({ "config": valid_config() }),
None,
);
write_grok_provider_live(&provider).expect("write live config");
let path = get_grok_config_path();
assert_eq!(path, temp.path().join(".grok").join("config.toml"));
assert_eq!(
fs::read_to_string(path).expect("read config"),
valid_config()
);
assert_eq!(
read_grok_live_settings()
.expect("read live settings")
.get("config")
.and_then(Value::as_str),
Some(valid_config())
);
match original_test_home {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
+54 -18
View File
@@ -14,6 +14,7 @@ mod deeplink;
mod error;
mod gemini_config;
mod gemini_mcp;
mod grok_config;
pub mod hermes_config;
mod init_status;
mod lightweight;
@@ -47,10 +48,11 @@ pub use database::{Database, Profile};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use mcp::{
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
sync_single_server_to_codex, sync_single_server_to_gemini,
import_from_claude, import_from_codex, import_from_gemini, import_from_grokbuild,
remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini,
remove_server_from_grokbuild, sync_enabled_to_claude, sync_enabled_to_codex,
sync_enabled_to_gemini, sync_single_server_to_claude, sync_single_server_to_codex,
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
};
pub use prompt::Prompt;
pub use provider::{Provider, ProviderMeta};
@@ -783,6 +785,14 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_grokbuild(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Grok Build");
}
Ok(_) => log::debug!("○ No Grok Build MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Grok Build MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
@@ -808,6 +818,7 @@ pub fn run() {
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
crate::app_config::AppType::GrokBuild,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
crate::app_config::AppType::Hermes,
@@ -985,13 +996,11 @@ pub fn run() {
// 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代)
{
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use commands::CodexOAuthState;
use tokio::sync::RwLock;
let app_config_dir = crate::config::get_app_config_dir();
let codex_oauth_manager = CodexOAuthManager::new(app_config_dir);
app.manage(CodexOAuthState(Arc::new(RwLock::new(codex_oauth_manager))));
let codex_oauth_manager =
app.state::<AppState>().codex_oauth_manager.clone();
app.manage(CodexOAuthState(codex_oauth_manager));
log::info!("✓ CodexOAuthManager initialized");
}
@@ -1740,16 +1749,25 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
///
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
/// 则自动启动代理服务并接管对应应用的 Live 配置。
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let mut apps_to_restore = Vec::new();
for app_type in ["claude", "codex", "gemini"] {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
apps_to_restore.push(app_type);
}
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
let mut apps = Vec::new();
for app_type in PROXY_STARTUP_APP_TYPES {
if db
.get_proxy_config_for_app(app_type)
.await
.is_ok_and(|config| config.enabled)
{
apps.push(app_type);
}
}
apps
}
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await;
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
@@ -2067,7 +2085,8 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
#[cfg(test)]
mod tests {
use super::{classify_exit_request, ExitRequestAction};
use super::{classify_exit_request, enabled_proxy_apps_on_startup, ExitRequestAction};
use crate::database::Database;
#[test]
fn no_code_keeps_app_alive_in_tray() {
@@ -2093,4 +2112,21 @@ mod tests {
ExitRequestAction::CleanupAndExit
);
}
#[tokio::test]
async fn startup_restore_includes_enabled_grokbuild_route() {
let db = Database::memory().expect("initialize database");
let mut config = db
.get_proxy_config_for_app("grokbuild")
.await
.expect("read Grok Build proxy config");
config.enabled = true;
db.update_proxy_config_for_app(config)
.await
.expect("enable Grok Build proxy config");
let apps = enabled_proxy_apps_on_startup(&db).await;
assert_eq!(apps, vec!["grokbuild"]);
}
}
+1
View File
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+2 -1
View File
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -556,7 +557,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
/// 3. 其他未知字段使用通用转换器尝试转换
fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
use toml_edit::{Array, Item, Table};
let mut t = Table::new();
+1
View File
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: true,
grokbuild: false,
opencode: false,
hermes: false,
},
+221
View File
@@ -0,0 +1,221 @@
//! Grok Build MCP synchronization and import.
//!
//! Grok Build uses the same top-level `[mcp_servers]` TOML layout as Codex,
//! stored alongside its model configuration in `~/.grok/config.toml`.
use serde_json::{json, Value};
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
use crate::error::AppError;
use super::codex::json_server_to_toml_table;
use super::validation::validate_server_spec;
fn should_sync_grokbuild_mcp() -> bool {
crate::grok_config::get_grok_config_dir().exists()
}
fn read_config_text() -> Result<String, AppError> {
let path = crate::grok_config::get_grok_config_path();
if !path.exists() {
return Ok(String::new());
}
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
}
fn json_server_to_grokbuild_toml_table(server_spec: &Value) -> Result<toml_edit::Table, AppError> {
let mut table = json_server_to_toml_table(server_spec)?;
// Grok infers transport from `command` or `url` and uses `headers`, while
// Codex writes an explicit `type` plus `http_headers`.
table.remove("type");
if let Some(headers) = table.remove("http_headers") {
table.insert("headers", headers);
}
Ok(table)
}
fn toml_server_to_json(entry: &toml::value::Table) -> Value {
fn convert(value: &toml::Value) -> Option<Value> {
match value {
toml::Value::String(value) => Some(json!(value)),
toml::Value::Integer(value) => Some(json!(value)),
toml::Value::Float(value) => Some(json!(value)),
toml::Value::Boolean(value) => Some(json!(value)),
toml::Value::Datetime(value) => Some(json!(value.to_string())),
toml::Value::Array(values) => Some(Value::Array(
values.iter().filter_map(convert).collect::<Vec<_>>(),
)),
toml::Value::Table(values) => Some(Value::Object(
values
.iter()
.filter_map(|(key, value)| convert(value).map(|value| (key.clone(), value)))
.collect(),
)),
}
}
let mut spec = serde_json::Map::new();
for (key, value) in entry {
let output_key = if key == "http_headers" {
"headers"
} else {
key
};
if let Some(value) = convert(value) {
spec.insert(output_key.to_string(), value);
}
}
let default_type = if spec.contains_key("url") {
"http"
} else {
"stdio"
};
spec.entry("type".to_string())
.or_insert_with(|| json!(default_type));
Value::Object(spec)
}
pub fn import_from_grokbuild(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let text = read_config_text()?;
if text.trim().is_empty() {
return Ok(0);
}
let root: toml::Table = toml::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.grok/config.toml 失败: {e}")))?;
let Some(entries) = root.get("mcp_servers").and_then(toml::Value::as_table) else {
return Ok(0);
};
let servers = config
.mcp
.servers
.get_or_insert_with(std::collections::HashMap::new);
let mut changed = 0;
for (id, entry) in entries {
let Some(entry) = entry.as_table() else {
continue;
};
let spec = toml_server_to_json(entry);
if let Err(error) = validate_server_spec(&spec) {
log::warn!("跳过无效 Grok Build MCP 项 '{id}': {error}");
continue;
}
if let Some(existing) = servers.get_mut(id) {
if !existing.apps.grokbuild {
existing.apps.grokbuild = true;
changed += 1;
}
} else {
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec,
apps: McpApps {
grokbuild: true,
..Default::default()
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
}
}
Ok(changed)
}
pub fn sync_single_server_to_grokbuild(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_grokbuild_mcp() {
return Ok(());
}
use toml_edit::Item;
let path = crate::grok_config::get_grok_config_path();
let text = read_config_text()?;
let mut doc = if text.trim().is_empty() {
toml_edit::DocumentMut::new()
} else {
text.parse::<toml_edit::DocumentMut>().map_err(|e| {
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
})?
};
if !doc.contains_key("mcp_servers") {
doc["mcp_servers"] = toml_edit::table();
}
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
crate::config::write_text_file(&path, &doc.to_string())
}
pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
if !should_sync_grokbuild_mcp() {
return Ok(());
}
let path = crate::grok_config::get_grok_config_path();
if !path.exists() {
return Ok(());
}
let text = read_config_text()?;
let mut doc = match text.parse::<toml_edit::DocumentMut>() {
Ok(doc) => doc,
Err(error) => {
log::warn!("解析 Grok Build config.toml 失败: {error},跳过删除操作");
return Ok(());
}
};
if let Some(servers) = doc
.get_mut("mcp_servers")
.and_then(toml_edit::Item::as_table_mut)
{
servers.remove(id);
}
crate::config::write_text_file(&path, &doc.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_http_headers_to_unified_headers() {
let entry: toml::value::Table = toml::from_str(
r#"type = "http"
url = "https://example.com/mcp"
http_headers = { Authorization = "Bearer token" }
"#,
)
.expect("parse table");
let spec = toml_server_to_json(&entry);
assert_eq!(spec["type"], "http");
assert_eq!(spec["headers"]["Authorization"], "Bearer token");
}
#[test]
fn writes_grokbuild_remote_server_without_codex_only_fields() {
let table = json_server_to_grokbuild_toml_table(&json!({
"type": "http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer token" }
}))
.expect("convert server");
assert!(!table.contains_key("type"));
assert!(!table.contains_key("http_headers"));
assert_eq!(
table
.get("headers")
.and_then(toml_edit::Item::as_table)
.and_then(|headers| headers.get("Authorization"))
.and_then(toml_edit::Item::as_str),
Some("Bearer token")
);
}
}
+1
View File
@@ -315,6 +315,7 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: true,
},
+4
View File
@@ -14,6 +14,7 @@
mod claude;
mod codex;
mod gemini;
mod grokbuild;
mod hermes;
mod opencode;
mod validation;
@@ -30,6 +31,9 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
pub use grokbuild::{
import_from_grokbuild, remove_server_from_grokbuild, sync_single_server_to_grokbuild,
};
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
+1
View File
@@ -258,6 +258,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: true,
hermes: false,
},
+5 -4
View File
@@ -12,9 +12,9 @@ use crate::opencode_config::get_opencode_dir;
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
if matches!(app, AppType::ClaudeDesktop) {
return Err(AppError::localized(
"claude_desktop.prompts_unsupported",
"Claude Desktop 暂不支持 Prompts",
"Claude Desktop does not support Prompts",
"app.prompts_unsupported",
"当前应用暂不支持 Prompts",
"This app does not support Prompts",
));
}
@@ -22,6 +22,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
@@ -32,7 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::ClaudeDesktop => unreachable!("handled above"),
};
+5
View File
@@ -164,6 +164,11 @@ impl Provider {
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
(base_url, api_key)
}
AppType::GrokBuild => settings
.get("config")
.and_then(Value::as_str)
.and_then(crate::grok_config::extract_credentials)
.unwrap_or_default(),
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
AppType::Hermes => (
str_at(settings.get("base_url")),
+11 -6
View File
@@ -23,7 +23,6 @@ use super::{
ProxyError,
};
use crate::commands::{CodexOAuthState, CopilotAuthState};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{
app_config::AppType,
@@ -1142,9 +1141,9 @@ impl RequestForwarder {
// Codex upstream conversion mode — computed early because the [1m]-suffix strip
// below must be skipped on the Anthropic path (the marker has to survive to
// catalog matching and to the transform's own strip+beta detection).
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
let codex_responses_to_chat = matches!(app_type, AppType::Codex | AppType::GrokBuild)
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex | AppType::GrokBuild)
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
let codex_official_auth_passthrough = matches!(app_type, AppType::Codex)
&& super::providers::is_codex_official_provider(provider);
@@ -1168,6 +1167,13 @@ impl RequestForwarder {
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mut mapped_body = normalize_thinking_type(mapped_body);
// Grok Build exposes a stable client-side model profile in config.toml.
// Route requests to the provider's real upstream model before applying
// the optional Responses -> Chat/Anthropic bridge.
if matches!(app_type, AppType::GrokBuild) {
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
}
if is_copilot {
mapped_body =
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
@@ -1511,7 +1517,7 @@ impl RequestForwarder {
mapped_body
};
if matches!(app_type, AppType::Codex) {
if matches!(app_type, AppType::Codex | AppType::GrokBuild) {
self.apply_media_prevention(&mut request_body, provider);
}
@@ -1613,8 +1619,7 @@ impl RequestForwarder {
if auth.strategy == AuthStrategy::CodexOAuth {
if let Some(app_handle) = &self.app_handle {
let codex_state = app_handle.state::<CodexOAuthState>();
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
codex_state.0.read().await;
let codex_auth = &codex_state.0;
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
let account_id = provider
+52 -4
View File
@@ -758,6 +758,30 @@ pub async fn handle_chat_completions(
pub async fn handle_responses(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await
}
pub async fn handle_grokbuild_responses(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_for_app(
state,
request,
AppType::GrokBuild,
"Grok Build",
"grokbuild",
)
.await
}
async fn handle_responses_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
@@ -774,7 +798,7 @@ pub async fn handle_responses(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = endpoint_with_query(&uri, "/responses");
let is_stream = body
@@ -786,7 +810,7 @@ pub async fn handle_responses(
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_with_retry(
&AppType::Codex,
&app_type,
method,
&endpoint,
body,
@@ -849,6 +873,30 @@ pub async fn handle_responses(
pub async fn handle_responses_compact(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await
}
pub async fn handle_grokbuild_responses_compact(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_compact_for_app(
state,
request,
AppType::GrokBuild,
"Grok Build",
"grokbuild",
)
.await
}
async fn handle_responses_compact_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
@@ -865,7 +913,7 @@ pub async fn handle_responses_compact(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = endpoint_with_query(&uri, "/responses/compact");
let is_stream = body
@@ -877,7 +925,7 @@ pub async fn handle_responses_compact(
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_with_retry(
&AppType::Codex,
&app_type,
method,
&endpoint,
body,
+42 -1
View File
@@ -250,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_model_from_toml)
.and_then(|config| {
crate::grok_config::extract_model_config(config)
.map(|model| model.model)
.or_else(|| extract_codex_model_from_toml(config))
})
})
}
@@ -621,6 +625,9 @@ impl CodexAdapter {
}
if let Some(config_str) = config.as_str() {
if let Some((_, key)) = crate::grok_config::extract_credentials(config_str) {
return Some(key);
}
if let Some(key) =
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
{
@@ -675,6 +682,9 @@ impl ProviderAdapter for CodexAdapter {
// 尝试解析 TOML 字符串格式
if let Some(config_str) = config.as_str() {
if let Some(url) = crate::grok_config::extract_base_url(config_str) {
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(start) = config_str.find("base_url = \"") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('"') {
@@ -796,6 +806,37 @@ mod tests {
}
}
#[test]
fn grok_build_toml_exposes_upstream_credentials_and_model() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"config": r#"
[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "upstream-grok-model"
base_url = "https://relay.example.com/v1/"
name = "Example Relay"
api_key = "grok-secret"
api_backend = "responses"
context_window = 500000
"#
}));
assert_eq!(
adapter.extract_base_url(&provider).unwrap(),
"https://relay.example.com/v1"
);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "grok-secret");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
assert_eq!(
codex_provider_upstream_model(&provider).as_deref(),
Some("upstream-grok-model")
);
}
#[test]
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
File diff suppressed because it is too large Load Diff
@@ -340,6 +340,10 @@ pub struct GitHubAccount {
/// GitHub 域名(github.com 或 GHES 域名)
#[serde(default = "default_github_domain")]
pub github_domain: String,
/// 托管账号是否需要重新登录以补全缺失的凭据。
/// Codex:缺少持久化 id_token 的旧账号为 trueCopilot 恒为 false。
#[serde(default)]
pub reauth_required: bool,
}
impl From<&GitHubAccountData> for GitHubAccount {
@@ -350,6 +354,7 @@ impl From<&GitHubAccountData> for GitHubAccount {
avatar_url: data.user.avatar_url.clone(),
authenticated_at: data.authenticated_at,
github_domain: data.github_domain.clone(),
reauth_required: false,
}
}
}
@@ -543,6 +548,7 @@ impl CopilotAuthManager {
avatar_url: user.avatar_url.clone(),
authenticated_at: now,
github_domain,
reauth_required: false,
};
{
@@ -1546,6 +1552,7 @@ mod tests {
avatar_url: Some("https://example.com/avatar.png".to_string()),
authenticated_at: 1234567890,
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
reauth_required: false,
}],
default_account_id: Some("12345".to_string()),
migration_error: None,
+4 -8
View File
@@ -192,10 +192,8 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex-like type
ProviderType::Codex
}
AppType::GrokBuild => ProviderType::Codex,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
}
}
@@ -246,10 +244,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
AppType::GrokBuild => Box::new(CodexAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
}
}
@@ -75,6 +75,7 @@ struct ChatToResponsesState {
reasoning: ReasoningItemState,
inline_think: InlineThinkState,
tools: BTreeMap<usize, ToolCallState>,
next_tool_index_to_add: usize,
output_items: Vec<(u32, Value)>,
latest_usage: Option<Value>,
finish_reason: Option<String>,
@@ -94,6 +95,7 @@ impl Default for ChatToResponsesState {
reasoning: ReasoningItemState::default(),
inline_think: InlineThinkState::default(),
tools: BTreeMap::new(),
next_tool_index_to_add: 0,
output_items: Vec::new(),
latest_usage: None,
finish_reason: None,
@@ -347,16 +349,16 @@ impl ChatToResponsesState {
.unwrap_or("")
.to_string();
let mut should_add = false;
let mut output_index = None;
let mut item_id = String::new();
let mut pending_arguments = String::new();
let current_name: String;
{
let state = self.tools.entry(chat_index).or_default();
if let Some(id) = id_delta {
state.call_id = id;
if let Some(ref id) = id_delta {
if !id.is_empty() {
state.call_id.clone_from(id);
}
}
if let Some(ref name) = name_delta {
if !name.is_empty() {
@@ -373,10 +375,7 @@ impl ChatToResponsesState {
}
}
if !state.added && !state.call_id.is_empty() && !state.name.is_empty() {
should_add = true;
pending_arguments = state.arguments.clone();
} else if state.added {
if state.added {
output_index = state.output_index;
item_id = state.item_id.clone();
}
@@ -386,26 +385,51 @@ impl ChatToResponsesState {
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&current_name);
let mut events = Vec::new();
if should_add {
if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse::function_call_arguments_delta(
output_index,
&item_id,
&args_delta,
));
}
}
events.extend(self.flush_ready_tool_calls());
events
}
fn flush_ready_tool_calls(&mut self) -> Vec<Bytes> {
// Release consecutive Chat indexes so late identity fragments cannot reorder calls.
let mut events = Vec::new();
loop {
let key = self.next_tool_index_to_add;
let Some(state) = self.tools.get(&key) else {
break;
};
if state.added || state.done {
self.next_tool_index_to_add += 1;
continue;
}
if state.call_id.is_empty() || state.name.is_empty() {
break;
}
let assigned = self.next_output_index();
let Some(state) = self.tools.get_mut(&chat_index) else {
return events;
let Some(state) = self.tools.get_mut(&key) else {
continue;
};
state.added = true;
if state.call_id.is_empty() {
state.call_id = format!("call_{chat_index}");
}
state.output_index = Some(assigned);
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
state.item_id = response_tool_call_item_id_from_chat_name(
&state.call_id,
&state.name,
&self.tool_context,
);
item_id = state.item_id.clone();
let item = response_tool_call_item_from_chat_name(
&item_id,
&state.item_id,
"in_progress",
&state.call_id,
&state.name,
@@ -416,21 +440,16 @@ impl ChatToResponsesState {
events.push(sse::output_item_added(assigned, &item));
if !pending_arguments.is_empty() && !is_custom_tool {
if !state.arguments.is_empty()
&& !self.tool_context.is_custom_tool_chat_name(&state.name)
{
events.push(sse::function_call_arguments_delta(
assigned,
&state.item_id,
&pending_arguments,
));
}
} else if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse::function_call_arguments_delta(
output_index,
&item_id,
&args_delta,
&state.arguments,
));
}
self.next_tool_index_to_add += 1;
}
events
@@ -844,6 +863,16 @@ mod tests {
String::from_utf8(bytes.concat()).unwrap()
}
fn parse_sse_events(output: &str) -> Vec<Value> {
output
.split("\n\n")
.filter_map(|block| {
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
serde_json::from_str(data).ok()
})
.collect()
}
#[tokio::test]
async fn converts_text_chat_sse_to_responses_sse() {
let output = collect(vec![
@@ -916,6 +945,114 @@ mod tests {
assert!(output.contains("\"call_id\":\"call_1\""));
}
#[tokio::test]
async fn preserves_tool_identity_across_empty_continuation_deltas() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_dashscope\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_dashscope\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_dashscope\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\\\"cmd\\\":\\\"date\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let added = events
.iter()
.filter(|event| event["type"] == "response.output_item.added")
.collect::<Vec<_>>();
let done = events
.iter()
.find(|event| event["type"] == "response.output_item.done")
.unwrap();
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
assert_eq!(added.len(), 1);
for item in [&done["item"], &completed["response"]["output"][0]] {
assert_eq!(item["type"], "function_call");
assert_eq!(item["name"], "exec_command");
assert_eq!(item["call_id"], "call_dashscope");
assert_eq!(item["arguments"], r#"{"cmd":"date"}"#);
}
assert!(!output.contains(r#""name":"""#));
assert!(!output.contains(r#""call_id":"""#));
}
#[tokio::test]
async fn preserves_parallel_tool_order_when_earlier_name_arrives_late() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_first\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"{\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_second\",\"type\":\"function\",\"function\":{\"name\":\"second_tool\",\"arguments\":\"{\\\"value\\\":2}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"first_tool\",\"arguments\":\"\\\"value\\\":1}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let added = events
.iter()
.filter(|event| event["type"] == "response.output_item.added")
.collect::<Vec<_>>();
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(added.len(), 2);
assert_eq!(added[0]["output_index"], 0);
assert_eq!(added[0]["item"]["name"], "first_tool");
assert_eq!(added[1]["output_index"], 1);
assert_eq!(added[1]["item"]["name"], "second_tool");
assert_eq!(items[0]["name"], "first_tool");
assert_eq!(items[0]["call_id"], "call_first");
assert_eq!(items[0]["arguments"], r#"{"value":1}"#);
assert_eq!(items[1]["name"], "second_tool");
assert_eq!(items[1]["call_id"], "call_second");
assert_eq!(items[1]["arguments"], r#"{"value":2}"#);
}
#[tokio::test]
async fn finalization_keeps_valid_call_after_unnamed_earlier_call() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_parallel_missing\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_missing\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel_missing\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_valid\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\\\"cmd\\\":\\\"date\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["name"], "exec_command");
assert_eq!(items[0]["call_id"], "call_valid");
assert_eq!(items[0]["arguments"], r#"{"cmd":"date"}"#);
assert!(!output.contains("call_missing"));
}
#[tokio::test]
async fn finalization_keeps_non_contiguous_tool_index() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_sparse\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":2,\"id\":\"call_sparse\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["name"], "read_file");
assert_eq!(items[0]["call_id"], "call_sparse");
assert_eq!(items[0]["arguments"], r#"{"path":"README.md"}"#);
}
#[tokio::test]
async fn restores_custom_tool_input_stream_events() {
let request = json!({
@@ -1096,6 +1096,26 @@ fn serialize_tool_definition_for_description(tool: &Value) -> String {
canonical_json_string(tool)
}
/// Normalize a function's `parameters` JSON Schema so `type` is always `"object"`.
///
/// Some Responses tools carry `parameters: null` or `parameters: {"type": null}`,
/// but OpenAI Chat Completions strictly requires `{"type": "object", "properties": {...}}`.
fn normalize_function_parameters(params: Option<&Value>) -> Value {
let mut params = match params {
Some(Value::Object(obj)) => Value::Object(obj.clone()),
_ => json!({"type": "object", "properties": {}}),
};
if let Some(obj) = params.as_object_mut() {
match obj.get("type").and_then(|v| v.as_str()) {
Some("object") => {}
_ => {
obj.insert("type".to_string(), json!("object"));
}
}
}
params
}
fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option<Value> {
if tool.get("type").and_then(|v| v.as_str()) != Some("function") {
return None;
@@ -1110,6 +1130,14 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option
.get_mut("function")
.and_then(|value| value.as_object_mut())
{
// Ensure parameters.type is "object" for strict OpenAI-compatible providers
if let Some(params) = obj.get("parameters") {
let normalized = normalize_function_parameters(Some(params));
if normalized != *params {
obj.insert("parameters".to_string(), normalized);
}
}
obj.insert("name".to_string(), json!(chat_name));
if let Some(strict) = tool.get("strict").cloned() {
obj.entry("strict".to_string()).or_insert(strict);
@@ -1121,7 +1149,7 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option
let mut function = json!({
"name": chat_name,
"description": tool.get("description").cloned().unwrap_or(Value::Null),
"parameters": tool.get("parameters").cloned().unwrap_or_else(|| json!({}))
"parameters": normalize_function_parameters(tool.get("parameters"))
});
if let Some(strict) = tool.get("strict") {
function["strict"] = strict.clone();
+10
View File
@@ -327,6 +327,12 @@ impl ProxyServer {
.route("/v1/responses", post(handlers::handle_responses))
.route("/v1/v1/responses", post(handlers::handle_responses))
.route("/codex/v1/responses", post(handlers::handle_responses))
// Grok Build uses the Responses protocol but has an independent
// provider namespace and failover queue.
.route(
"/grokbuild/v1/responses",
post(handlers::handle_grokbuild_responses),
)
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
.route(
"/responses/compact",
@@ -344,6 +350,10 @@ impl ProxyServer {
"/codex/v1/responses/compact",
post(handlers::handle_responses_compact),
)
.route(
"/grokbuild/v1/responses/compact",
post(handlers::handle_grokbuild_responses_compact),
)
// Gemini API (支持带前缀和不带前缀)
//
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
+1
View File
@@ -113,6 +113,7 @@ pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub grokbuild: bool,
pub opencode: bool,
pub openclaw: bool,
}
+20 -1
View File
@@ -59,7 +59,7 @@ impl CostCalculator {
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
Self::calculate_with_cache_semantics(
usage,
pricing,
@@ -211,6 +211,25 @@ mod tests {
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
}
#[test]
fn grokbuild_does_not_double_bill_cached_input() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 0,
cache_read_tokens: 600,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
let pricing = ModelPricing::from_strings("10", "0", "1", "0").unwrap();
let cost = CostCalculator::calculate_for_app("grokbuild", &usage, &pricing, Decimal::ONE);
assert_eq!(cost.input_cost, Decimal::from_str("0.004").unwrap());
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.0006").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.0046").unwrap());
}
#[test]
fn test_cost_multiplier() {
let usage = TokenUsage {
+41 -5
View File
@@ -71,11 +71,12 @@ impl<'a> UsageLogger<'a> {
};
let created_at = chrono::Utc::now().timestamp();
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
let input_token_semantics =
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
conn.execute(
"INSERT OR REPLACE INTO proxy_request_logs (
@@ -459,4 +460,39 @@ mod tests {
assert_eq!(error, Some("Internal Server Error".to_string()));
Ok(())
}
#[test]
fn grokbuild_logs_total_input_token_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
let logger = UsageLogger::new(&db);
let log = RequestLog {
request_id: "grok-semantics".to_string(),
provider_id: "grok-provider".to_string(),
app_type: "grokbuild".to_string(),
model: "grok-4.5".to_string(),
request_model: "grok-4.5".to_string(),
pricing_model: String::new(),
usage: TokenUsage::default(),
cost: None,
latency_ms: 1,
first_token_ms: None,
status_code: 200,
error_message: None,
session_id: None,
provider_type: Some("grokbuild".to_string()),
is_streaming: false,
cost_multiplier: "1".to_string(),
};
logger.log_request(&log)?;
let conn = crate::database::lock_conn!(db.conn);
let semantics: i64 = conn.query_row(
"SELECT input_token_semantics FROM proxy_request_logs WHERE request_id = 'grok-semantics'",
[],
|row| row.get(0),
)?;
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
Ok(())
}
}
+2
View File
@@ -88,6 +88,7 @@ impl ConfigService {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
Self::sync_current_provider_for_app(config, &AppType::GrokBuild)?;
Ok(())
}
@@ -125,6 +126,7 @@ impl ConfigService {
// Claude Desktop 3P profiles are managed by claude_desktop_config.
}
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::GrokBuild => crate::grok_config::write_grok_provider_live(&provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
+39 -1
View File
@@ -37,6 +37,9 @@ impl McpService {
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
if prev_apps.grokbuild && !server.apps.grokbuild {
Self::remove_server_from_app(state, &server.id, &AppType::GrokBuild)?;
}
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
@@ -122,6 +125,13 @@ impl McpService {
AppType::Gemini => {
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
}
AppType::GrokBuild => {
mcp::sync_single_server_to_grokbuild(
&Default::default(),
&server.id,
&server.server,
)?;
}
AppType::OpenCode => {
mcp::sync_single_server_to_opencode(
&Default::default(),
@@ -162,6 +172,7 @@ impl McpService {
}
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::GrokBuild => mcp::remove_server_from_grokbuild(id)?,
AppType::OpenCode => {
mcp::remove_server_from_opencode(id)?;
}
@@ -393,6 +404,32 @@ impl McpService {
Ok(new_count)
}
/// 从 Grok Build 的 `[mcp_servers]` 导入 MCP。
pub fn import_from_grokbuild(state: &AppState) -> Result<usize, AppError> {
let mut temp_config = crate::app_config::MultiAppConfig::default();
let count = crate::mcp::import_from_grokbuild(&mut temp_config)?;
let mut new_count = 0;
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
let to_save = if let Some(existing_server) = existing.get(&server.id) {
let mut merged = existing_server.clone();
merged.apps.grokbuild = true;
merged
} else {
new_count += 1;
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save);
}
}
}
Ok(new_count)
}
/// 从 OpenCode 导入 MCPv3.9.2+ 新增)
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
// 创建临时 MultiAppConfig 用于导入
@@ -479,10 +516,11 @@ impl McpService {
let mut total = 0;
let mut failures: Vec<String> = Vec::new();
let results: [(&str, Result<usize, AppError>); 5] = [
let results: [(&str, Result<usize, AppError>); 6] = [
("claude", Self::import_from_claude(state)),
("codex", Self::import_from_codex(state)),
("gemini", Self::import_from_gemini(state)),
("grokbuild", Self::import_from_grokbuild(state)),
("opencode", Self::import_from_opencode(state)),
("hermes", Self::import_from_hermes(state)),
];
+504 -16
View File
@@ -3,6 +3,7 @@
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{json, Value};
use toml_edit::{DocumentMut, Item, TableLike};
@@ -13,6 +14,7 @@ use crate::config::{delete_file, get_claude_settings_path, read_json_file, write
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::mcp::McpService;
use crate::store::AppState;
@@ -523,7 +525,11 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
}
_ => false,
},
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
AppType::GrokBuild
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::ClaudeDesktop => false,
}
}
@@ -593,9 +599,11 @@ pub(crate) fn remove_common_config_from_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
Ok(settings.clone())
}
AppType::GrokBuild
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::ClaudeDesktop => Ok(settings.clone()),
}
}
@@ -650,9 +658,11 @@ fn apply_common_config_to_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
Ok(settings.clone())
}
AppType::GrokBuild
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::ClaudeDesktop => Ok(settings.clone()),
}
}
@@ -687,14 +697,31 @@ pub(crate) fn build_effective_settings_with_common_config(
Ok(effective_settings)
}
pub(crate) fn write_live_with_common_config(
db: &Database,
pub(crate) fn write_live_with_common_config_for_state(
state: &AppState,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
write_live_with_common_config_for_codex_oauth_manager(
state.db.as_ref(),
app_type,
provider,
&state.codex_oauth_manager,
)
}
pub(crate) fn write_live_with_common_config_for_codex_oauth_manager(
db: &Database,
app_type: &AppType,
provider: &Provider,
codex_oauth_manager: &Arc<CodexOAuthManager>,
) -> Result<(), AppError> {
let effective_provider = build_effective_provider_for_live_with_codex_oauth_manager(
db,
app_type,
provider,
codex_oauth_manager,
)?;
if matches!(app_type, AppType::ClaudeDesktop) {
crate::claude_desktop_config::apply_provider(db, &effective_provider)?;
@@ -709,6 +736,132 @@ pub(crate) fn write_live_with_common_config(
write_live_snapshot(app_type, &effective_provider)
}
pub(crate) fn build_effective_provider_for_live_with_codex_oauth_manager(
db: &Database,
app_type: &AppType,
provider: &Provider,
codex_oauth_manager: &Arc<CodexOAuthManager>,
) -> Result<Provider, AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
apply_codex_managed_oauth_auth(app_type, &mut effective_provider, Some(codex_oauth_manager))?;
Ok(effective_provider)
}
fn apply_codex_managed_oauth_auth(
app_type: &AppType,
provider: &mut Provider,
codex_oauth_manager: Option<&Arc<CodexOAuthManager>>,
) -> Result<(), AppError> {
if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") {
return Ok(());
}
let Some(account_id) = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
else {
return Ok(());
};
let Some(manager) = codex_oauth_manager else {
return Err(AppError::Message(
"Codex OAuth 托管账号不可用,请重启应用后重试".to_string(),
));
};
let auth = get_codex_managed_oauth_live_auth_value(manager.clone(), account_id.clone())?;
let Some(settings_obj) = provider.settings_config.as_object_mut() else {
return Err(AppError::Config(
"Codex 供应商配置必须是 JSON 对象".to_string(),
));
};
settings_obj.insert("auth".to_string(), auth);
Ok(())
}
/// 构建写入托管 Codex `auth.json` 的完整可刷新 auth(含 refresh_token + last_refresh)。
///
/// 步骤:
/// 1. **读回**:若 Codex CLI 已自行刷新并轮换 refresh_token,先采纳盘上最新值,避免
/// 用陈腐 refresh_token 覆盖 CLI 的有效登录(反复切换场景)。
/// 2. 取有效 token 束(必要时刷新 access_token)。
/// 3. 按原生浏览器登录形状生成完整 auth。
///
/// 不再持有外层锁:manager 内部按账号加锁刷新,网络阻塞不会波及其他账号操作或
/// token 读取。
fn get_codex_managed_oauth_live_auth_value(
manager: Arc<CodexOAuthManager>,
account_id: String,
) -> Result<Value, AppError> {
std::thread::spawn(move || {
tauri::async_runtime::block_on(async move {
if let Some((refresh_token, id_token, last_refresh_ms)) =
crate::codex_config::read_codex_live_auth_refresh_for_account(&account_id)
{
if let Err(err) = manager
.adopt_account_refresh_token(
&account_id,
refresh_token,
id_token,
last_refresh_ms,
)
.await
{
log::warn!(
"读回 Codex CLI 轮换后的 refresh_token 失败(account={account_id}: {err}"
);
}
}
let bundle = manager
.get_valid_token_bundle_for_account(&account_id)
.await
.map_err(|err| {
format!(
"Codex OAuth 账号 {account_id} 认证失败,请重新登录 ChatGPT 账号: {err}"
)
})?;
Ok::<Value, String>(codex_managed_oauth_live_auth(
&account_id,
&bundle.access_token,
bundle.id_token.as_deref(),
&bundle.refresh_token,
&bundle.last_refresh,
))
})
})
.join()
.map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))?
.map_err(AppError::Message)
}
pub(crate) fn codex_managed_oauth_live_auth(
account_id: &str,
access_token: &str,
id_token: Option<&str>,
refresh_token: &str,
last_refresh: &str,
) -> Value {
// 与原生 Codex 浏览器登录的形状对齐:tokens 字段顺序 id_token、access_token、
// refresh_token、account_id,并带顶层 last_refresh。**必须**包含 refresh_token
// 否则 Codex CLI 在 access_token 过期后无法自刷新(“裸跑 codex” 会静默失效)。
crate::codex_config::codex_managed_oauth_auth_value(
account_id,
access_token,
id_token,
refresh_token,
last_refresh,
)
}
pub(crate) fn strip_common_config_from_live_settings(
db: &Database,
app_type: &AppType,
@@ -825,6 +978,16 @@ fn restore_live_settings_for_provider_backfill(
strip_injected_kimi_for_coding_context_defaults(&mut settings, provider);
return settings;
}
if matches!(app_type, AppType::GrokBuild) {
let mut settings = live_settings;
if let Err(err) = crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings) {
log::warn!(
"Failed to strip Grok Build mcp_servers while backfilling '{}': {err}",
provider.id
);
}
return settings;
}
if !matches!(app_type, AppType::Codex) {
return live_settings;
}
@@ -846,6 +1009,8 @@ fn restore_live_settings_for_provider_backfill(
);
}
strip_codex_managed_oauth_auth_for_backfill(provider, &mut settings);
// MCP 服务器归 DB mcp_servers 表所有,live 里的 [mcp_servers] 是同步投影;
// 回填时剥掉,否则已删除的服务器会随供应商快照复活(逐条 reconcile 清不掉孤儿)。
if let Err(err) = crate::codex_config::strip_codex_mcp_servers_from_settings(&mut settings) {
@@ -884,6 +1049,40 @@ fn restore_live_settings_for_provider_backfill(
settings
}
/// 回填(backfill)托管 Codex 官方 provider 时,剥离 live 的 `auth`。
///
/// 托管 provider 的存储配置**永远不应**持久化真实 OAuth tokentoken 由
/// `CodexOAuthManager` 按账号集中保管,provider 配置只保留绑定与占位 auth。
///
/// 因此无论 live 里当前是什么(我们写入的托管 auth、被 CLI 轮换过的 token、
/// 还是用户自己浏览器登录的原生 auth,后者含真实 access/refresh_token),
/// 都统一替换为 provider 存储的占位 auth,避免把真实凭据回填进 DB 配置。
fn strip_codex_managed_oauth_auth_for_backfill(provider: &Provider, settings: &mut Value) {
let is_managed = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.is_some();
if !is_managed {
return;
}
// live 里没有 auth 就无需处理。
if settings.get("auth").is_none() {
return;
}
let stored_auth = provider
.settings_config
.get("auth")
.filter(|auth| auth.is_object())
.cloned()
.unwrap_or_else(|| json!({}));
if let Some(obj) = settings.as_object_mut() {
obj.insert("auth".to_string(), stored_auth);
}
}
pub(crate) fn normalize_provider_common_config_for_storage(
db: &Database,
app_type: &AppType,
@@ -1032,11 +1231,22 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
config_str,
profile,
)?;
if provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.is_some()
{
crate::codex_config::record_codex_managed_oauth_live_auth(auth)?;
}
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
}
AppType::GrokBuild => {
crate::grok_config::write_grok_provider_live(provider)?;
}
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
use crate::opencode_config;
@@ -1163,7 +1373,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
continue;
}
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
if let Err(e) = write_live_with_common_config_for_state(state, app_type, provider) {
log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}",
app_type,
@@ -1193,7 +1403,7 @@ pub(crate) fn sync_current_provider_for_app_to_live(
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
write_live_with_common_config_for_state(state, app_type, provider)?;
}
}
@@ -1232,7 +1442,7 @@ fn sync_current_provider_for_app_respecting_takeover(
// that normal provider sync must not rewrite the managed live file.
if has_live_backup || live_taken_over {
if matches!(app_type, AppType::ClaudeDesktop) {
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
write_live_with_common_config_for_state(state, app_type, provider)?;
} else {
futures::executor::block_on(
state
@@ -1244,7 +1454,7 @@ fn sync_current_provider_for_app_respecting_takeover(
return Ok(());
}
write_live_with_common_config(state.db.as_ref(), app_type, provider)
write_live_with_common_config_for_state(state, app_type, provider)
}
/// Sync current provider to live configuration
@@ -1367,6 +1577,7 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
let config = read_opencode_config()?;
Ok(config)
}
AppType::GrokBuild => crate::grok_config::read_grok_live_settings(),
AppType::OpenClaw => {
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
@@ -1435,6 +1646,11 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
let settings_config = match app_type {
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
AppType::GrokBuild => {
let mut settings = crate::grok_config::read_grok_live_settings()?;
crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings)?;
settings
}
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
@@ -1925,6 +2141,7 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
use serde_json::json;
#[test]
@@ -2348,6 +2565,249 @@ base_url = "https://a.example/v1"
assert_eq!(stripped, settings);
}
#[test]
fn codex_managed_oauth_live_auth_matches_codex_cli_shape() {
assert_eq!(
codex_managed_oauth_live_auth(
"acct-managed",
"access-token",
Some("id-token"),
"refresh-token",
"2026-01-02T03:04:05.000000000Z",
),
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "id-token",
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "acct-managed"
},
"last_refresh": "2026-01-02T03:04:05.000000000Z"
}),
"managed live auth must carry refresh_token + last_refresh so the Codex CLI can self-refresh"
);
}
#[test]
fn codex_managed_oauth_live_auth_without_id_token_omits_it() {
assert_eq!(
codex_managed_oauth_live_auth(
"acct-managed",
"access-token",
None,
"refresh-token",
"2026-01-02T03:04:05.000000000Z",
),
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "acct-managed"
},
"last_refresh": "2026-01-02T03:04:05.000000000Z"
}),
"without a stored id_token the field is omitted rather than written as null"
);
}
#[test]
fn codex_official_managed_oauth_binding_replaces_auth_with_selected_account_token() {
let temp = tempfile::tempdir().expect("tempdir");
let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf()));
tauri::async_runtime::block_on(async {
manager
.add_test_account_with_access_token(
"acct-managed",
"managed-token",
Some("managed-id-token"),
)
.await
.expect("seed managed account");
});
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "stale-key"
},
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
provider.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-managed".to_string()),
}),
..Default::default()
});
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
.expect("apply managed OAuth auth");
// last_refresh 是写入时刻的时间戳(非确定),因此逐字段断言而非整体等值。
let auth = provider.settings_config.get("auth").expect("auth written");
assert_eq!(
auth.get("auth_mode").and_then(|v| v.as_str()),
Some("chatgpt")
);
assert!(auth.get("OPENAI_API_KEY").is_some_and(|v| v.is_null()));
let tokens = auth
.get("tokens")
.and_then(|v| v.as_object())
.expect("tokens object");
assert_eq!(
tokens.get("account_id").and_then(|v| v.as_str()),
Some("acct-managed")
);
assert_eq!(
tokens.get("access_token").and_then(|v| v.as_str()),
Some("managed-token")
);
assert_eq!(
tokens.get("id_token").and_then(|v| v.as_str()),
Some("managed-id-token")
);
assert_eq!(
tokens.get("refresh_token").and_then(|v| v.as_str()),
Some("test-refresh-token"),
"managed live auth must carry the account's refresh_token so the Codex CLI can self-refresh"
);
assert!(
auth.get("last_refresh")
.and_then(|v| v.as_str())
.is_some_and(|s| s.ends_with('Z')),
"managed live auth must include an RFC3339 last_refresh timestamp"
);
}
#[test]
fn codex_official_without_binding_does_not_fall_back_to_managed_default_account() {
let temp = tempfile::tempdir().expect("tempdir");
let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf()));
tauri::async_runtime::block_on(async {
manager
.add_test_account_with_access_token("acct-managed", "managed-token", None)
.await
.expect("seed managed account");
});
let original_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "native-codex-token",
"account_id": "acct-native"
}
});
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": original_auth.clone(),
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
.expect("no binding should be a no-op");
assert_eq!(
provider.settings_config.get("auth"),
Some(&original_auth),
"unbound official providers must keep native Codex auth instead of using the managed default account"
);
}
#[test]
fn backfill_never_persists_native_tokens_into_managed_provider_config() {
// 托管 provider 的存储配置以占位 auth 表示;但 live 里此刻是用户自己
// 浏览器登录的原生 auth(含真实 refresh_token)。backfill 必须把 live
// auth 换回存储占位,绝不把原生 access/refresh_token 回填进 DB 配置。
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
provider.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-managed".to_string()),
}),
..Default::default()
});
let mut live_settings = json!({
"auth": {
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "native-id",
"access_token": "native-access-secret",
"refresh_token": "native-refresh-secret",
"account_id": "acct-native"
},
"last_refresh": "2026-01-01T00:00:00Z"
},
"config": ""
});
strip_codex_managed_oauth_auth_for_backfill(&provider, &mut live_settings);
assert_eq!(
live_settings.get("auth"),
Some(&json!({})),
"managed provider backfill must reset live auth to the stored placeholder"
);
let serialized = live_settings.to_string();
assert!(
!serialized.contains("native-refresh-secret"),
"native refresh_token must not leak into a managed provider's backfilled config"
);
assert!(
!serialized.contains("native-access-secret"),
"native access_token must not leak into a managed provider's backfilled config"
);
}
#[test]
fn backfill_leaves_non_managed_provider_auth_untouched() {
// 非托管 provider 不受此剥离影响:其 auth 就该原样保留。
let mut provider = Provider::with_id(
"custom".to_string(),
"Custom".to_string(),
json!({ "auth": { "OPENAI_API_KEY": "user-key" }, "config": "" }),
None,
);
provider.category = Some("custom".to_string());
let mut live_settings = json!({
"auth": { "OPENAI_API_KEY": "user-key" },
"config": ""
});
let before = live_settings.clone();
strip_codex_managed_oauth_auth_for_backfill(&provider, &mut live_settings);
assert_eq!(
live_settings, before,
"non-managed provider auth must be left untouched by managed-oauth backfill stripping"
);
}
#[test]
fn explicit_common_config_flag_overrides_legacy_subset_detection() {
let mut provider = Provider::with_id(
@@ -2536,4 +2996,32 @@ base_url = "https://a.example/v1"
"non-MCP content must survive the strip"
);
}
#[test]
fn grok_switch_backfill_strips_synced_mcp_servers() {
let provider = Provider::with_id(
"grok".to_string(),
"Grok".to_string(),
json!({
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n"
}),
None,
);
let live_settings = json!({
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n\n[mcp_servers.echo]\ncommand = \"echo\"\n"
});
let result = restore_live_settings_for_provider_backfill(
&AppType::GrokBuild,
&provider,
live_settings,
);
let config_text = result
.get("config")
.and_then(Value::as_str)
.expect("config text");
assert!(!config_text.contains("mcp_servers"));
assert!(config_text.contains("model = \"grok-4.5\""));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+47 -24
View File
@@ -374,20 +374,15 @@ fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
/// 获取 `~/.agents/skills/` 目录(存在时返回)
fn get_agents_skills_dir() -> Option<PathBuf> {
dirs::home_dir()
.map(|h| h.join(".agents").join("skills"))
.filter(|p| p.exists())
let dir = crate::config::get_home_dir().join(".agents").join("skills");
dir.exists().then_some(dir)
}
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
let path = match dirs::home_dir() {
Some(h) => h.join(".agents").join(".skill-lock.json"),
None => {
log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");
return HashMap::new();
}
};
let path = crate::config::get_home_dir()
.join(".agents")
.join(".skill-lock.json");
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
@@ -482,12 +477,7 @@ impl SkillService {
let dir = match location {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
home.join(".agents").join("skills")
crate::config::get_home_dir().join(".agents").join("skills")
}
};
fs::create_dir_all(&dir)?;
@@ -521,6 +511,11 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::GrokBuild => {
if let Some(custom) = crate::settings::get_grok_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::OpenCode => {
if let Some(custom) = crate::settings::get_opencode_override_dir() {
return Ok(custom.join("skills"));
@@ -538,18 +533,17 @@ impl SkillService {
}
}
// 默认路径:回退到用户主目录下的标准位置
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
// 默认路径:回退到用户主目录下的标准位置
// 必须走 get_home_dir()(可被 CC_SWITCH_TEST_HOME 覆盖):Windows 上 dirs::home_dir()
// 走 Known Folder API,测试无法隔离真实用户目录。
let home = crate::config::get_home_dir();
Ok(match app {
AppType::Claude => home.join(".claude").join("skills"),
AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"),
AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::GrokBuild => home.join(".grok").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
AppType::OpenClaw => home.join(".openclaw").join("skills"),
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
@@ -1167,8 +1161,7 @@ impl SkillService {
let new_dir = match target {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
let home = dirs::home_dir().context("Cannot determine home directory")?;
home.join(".agents").join("skills")
crate::config::get_home_dir().join(".agents").join("skills")
}
};
fs::create_dir_all(&new_dir)?;
@@ -3069,6 +3062,36 @@ mod tests {
.expect("write SKILL.md");
}
#[test]
// serial:与 backup/s3_sync/deeplink 等同样读写进程级 CC_SWITCH_TEST_HOME 的测试互斥,
// EnvGuard 只负责恢复不提供互斥。
#[serial_test::serial]
fn get_app_skills_dir_honors_test_home_override() {
// 回归:曾直呼 dirs::home_dir() 绕过 CC_SWITCH_TEST_HOME——Unix 上碰巧跟 $HOME
// 一致所以测试能过,Windows 上 dirs 走 Known Folder API,测试隔离整体失效
// tests/skill_sync.rs 扫到 runner 真实用户目录)。
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
let temp = tempdir().expect("tempdir");
let _guard = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
let dir =
SkillService::get_app_skills_dir(&AppType::Claude).expect("resolve claude skills dir");
assert!(
dir.starts_with(temp.path()),
"skills dir must live under the overridden test home, got {}",
dir.display()
);
}
#[test]
fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() {
let temp = tempdir().expect("tempdir");
+11 -3
View File
@@ -16,7 +16,7 @@
/// style provider not added here) shows up loudly as a too-low cache hit
/// rate, which is easier to catch than the silent over-deduction that
/// would happen with the opposite default.
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"];
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
@@ -93,6 +93,7 @@ mod tests {
assert!(!sql.contains("."));
assert!(sql.contains("'codex'"));
assert!(sql.contains("'gemini'"));
assert!(sql.contains("'grokbuild'"));
}
#[test]
@@ -112,6 +113,13 @@ mod tests {
[],
)
.unwrap();
// Grok Build uses OpenAI Responses semantics too.
conn.execute(
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
VALUES ('grok-1', 'grokbuild', 700, 250)",
[],
)
.unwrap();
// Claude row: Anthropic semantics — input_tokens already excludes cache.
conn.execute(
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
@@ -123,8 +131,8 @@ mod tests {
let expr = fresh_input_sql("l");
let sql = format!("SELECT COALESCE(SUM({expr}), 0) FROM proxy_request_logs l");
let total: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
// Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged.
assert_eq!(total, 400 + 500 + 200);
// Codex: 400; Gemini: 500; Grok Build: 450; Claude: 200 unchanged.
assert_eq!(total, 400 + 500 + 450 + 200);
}
#[test]
+10 -2
View File
@@ -4,7 +4,7 @@ pub mod terminal;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, hermes, openclaw, opencode};
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -56,13 +56,14 @@ pub struct DeleteSessionOutcome {
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| {
let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| {
let h1 = s.spawn(codex::scan_sessions);
let h2 = s.spawn(claude::scan_sessions);
let h3 = s.spawn(opencode::scan_sessions);
let h4 = s.spawn(openclaw::scan_sessions);
let h5 = s.spawn(gemini::scan_sessions);
let h6 = s.spawn(hermes::scan_sessions);
let h7 = s.spawn(grokbuild::scan_sessions);
(
h1.join().unwrap_or_default(),
h2.join().unwrap_or_default(),
@@ -70,6 +71,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
h4.join().unwrap_or_default(),
h5.join().unwrap_or_default(),
h6.join().unwrap_or_default(),
h7.join().unwrap_or_default(),
)
});
@@ -80,6 +82,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
sessions.extend(r4);
sessions.extend(r5);
sessions.extend(r6);
sessions.extend(r7);
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
@@ -106,6 +109,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
"opencode" => opencode::load_messages(path),
"openclaw" => openclaw::load_messages(path),
"gemini" => gemini::load_messages(path),
"grokbuild" => grokbuild::load_messages(path),
"hermes" => hermes::load_messages(path),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
@@ -165,6 +169,9 @@ fn delete_session_with_roots(
openclaw::delete_session(&validated_root, &validated_source, session_id)
}
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
"grokbuild" => {
grokbuild::delete_session(&validated_root, &validated_source, session_id)
}
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
_ => Err(format!("Unsupported provider: {provider_id}")),
};
@@ -194,6 +201,7 @@ fn provider_roots(provider_id: &str) -> Result<Vec<PathBuf>, String> {
"opencode" => vec![opencode::get_opencode_data_dir()],
"openclaw" => vec![crate::openclaw_config::get_openclaw_dir().join("agents")],
"gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")],
"grokbuild" => grokbuild::session_roots(),
"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],
_ => return Err(format!("Unsupported provider: {provider_id}")),
};
@@ -0,0 +1,296 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, truncate_summary, TITLE_MAX_CHARS};
#[derive(Debug, Deserialize)]
struct GrokSessionInfo {
id: String,
#[serde(default)]
cwd: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GrokSessionSummary {
info: GrokSessionInfo,
#[serde(default)]
session_summary: Option<String>,
#[serde(default)]
generated_title: Option<String>,
#[serde(default)]
created_at: Option<Value>,
#[serde(default)]
updated_at: Option<Value>,
#[serde(default)]
last_active_at: Option<Value>,
}
pub fn session_roots() -> Vec<PathBuf> {
let config_dir = crate::grok_config::get_grok_config_dir();
vec![
config_dir.join("sessions"),
config_dir.join("archived_sessions"),
]
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let mut summaries = Vec::new();
for root in session_roots() {
collect_summary_files(&root, &mut summaries);
}
summaries
.into_iter()
.filter_map(|path| parse_summary(&path))
.collect()
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let session_dir = path
.parent()
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
let chat_path = session_dir.join("chat_history.jsonl");
let file = File::open(&chat_path)
.map_err(|e| format!("Failed to open Grok Build chat history: {e}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines().map_while(Result::ok) {
let Ok(value) = serde_json::from_str::<Value>(&line) else {
continue;
};
let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
let role = match kind {
"system" | "user" | "assistant" | "tool" => kind,
// Reasoning records can contain encrypted/internal state and are not
// conversation messages shown by Grok's own history view.
_ => continue,
};
let content = value.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
let ts = value
.get("timestamp")
.or_else(|| value.get("ts"))
.and_then(parse_timestamp_to_ms);
messages.push(SessionMessage {
role: role.to_string(),
content,
ts,
});
}
Ok(messages)
}
pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
if !path.starts_with(root) {
return Err(format!(
"Grok Build session source is outside the session root: {}",
path.display()
));
}
if path.file_name().and_then(|name| name.to_str()) != Some("summary.json") {
return Err(format!(
"Unexpected Grok Build session source: {}",
path.display()
));
}
let summary = read_summary(path)?;
if summary.info.id != session_id {
return Err(format!(
"Grok Build session ID mismatch: expected {session_id}, found {}",
summary.info.id
));
}
let session_dir = path
.parent()
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
if session_dir == root || !session_dir.starts_with(root) {
return Err(format!(
"Refusing to delete Grok Build session directory outside its root: {}",
session_dir.display()
));
}
if session_dir.file_name().and_then(|name| name.to_str()) != Some(session_id) {
return Err(format!(
"Grok Build session directory does not match session ID: {}",
session_dir.display()
));
}
std::fs::remove_dir_all(session_dir).map_err(|e| {
format!(
"Failed to delete Grok Build session directory {}: {e}",
session_dir.display()
)
})?;
Ok(true)
}
fn collect_summary_files(root: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_summary_files(&path, files);
} else if path.file_name().and_then(|name| name.to_str()) == Some("summary.json") {
files.push(path);
}
}
}
fn read_summary(path: &Path) -> Result<GrokSessionSummary, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read Grok Build session summary: {e}"))?;
serde_json::from_str(&text)
.map_err(|e| format!("Failed to parse Grok Build session summary: {e}"))
}
fn parse_summary(path: &Path) -> Option<SessionMeta> {
let summary = read_summary(path).ok()?;
let session_id = summary.info.id;
let title = summary
.generated_title
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
summary
.session_summary
.as_deref()
.filter(|value| !value.trim().is_empty())
})
.map(|value| truncate_summary(value, TITLE_MAX_CHARS));
let session_summary = summary
.session_summary
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| truncate_summary(value, 160));
let created_at = summary.created_at.as_ref().and_then(parse_timestamp_to_ms);
let last_active_at = summary
.last_active_at
.as_ref()
.or(summary.updated_at.as_ref())
.and_then(parse_timestamp_to_ms);
Some(SessionMeta {
provider_id: "grokbuild".to_string(),
session_id: session_id.clone(),
title,
summary: session_summary,
project_dir: summary.info.cwd,
created_at,
last_active_at,
source_path: Some(path.to_string_lossy().to_string()),
resume_command: Some(format!("grok --resume {session_id}")),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn scans_native_grokbuild_session_layout() {
let temp = tempdir().expect("tempdir");
let sessions_dir = temp.path().join("sessions");
let session_id = "019f6af2-18b0-7673-958e-d25be650e172";
let session_dir = sessions_dir.join("encoded-project").join(session_id);
std::fs::create_dir_all(&session_dir).expect("create session dir");
std::fs::write(
session_dir.join("summary.json"),
format!(
r#"{{"info":{{"id":"{session_id}","cwd":"C:/work"}},"session_summary":"hello grok","generated_title":"Grok session","created_at":"2026-07-16T12:00:00Z","last_active_at":"2026-07-16T12:00:01Z"}}"#
),
)
.expect("write summary");
let mut files = Vec::new();
collect_summary_files(&sessions_dir, &mut files);
let sessions = files
.iter()
.filter_map(|path| parse_summary(path))
.collect::<Vec<_>>();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].provider_id, "grokbuild");
assert_eq!(sessions[0].session_id, session_id);
assert_eq!(sessions[0].title.as_deref(), Some("Grok session"));
let expected_resume = format!("grok --resume {session_id}");
assert_eq!(
sessions[0].resume_command.as_deref(),
Some(expected_resume.as_str())
);
}
#[test]
fn loads_native_grokbuild_chat_history() {
let temp = tempdir().expect("tempdir");
let summary_path = temp.path().join("summary.json");
std::fs::write(&summary_path, "{}").expect("write summary placeholder");
std::fs::write(
temp.path().join("chat_history.jsonl"),
concat!(
"{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}\n",
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"private\"}]}\n",
"{\"type\":\"assistant\",\"content\":\"Hi there\"}\n"
),
)
.expect("write chat history");
let messages = load_messages(&summary_path).expect("load messages");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "hello");
assert_eq!(messages[1].content, "Hi there");
}
#[test]
fn delete_session_removes_only_the_matching_session_directory() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("sessions");
let session_id = "session-to-delete";
let session_dir = root.join("project").join(session_id);
let sibling_dir = root.join("project").join("session-to-keep");
std::fs::create_dir_all(&session_dir).expect("create session directory");
std::fs::create_dir_all(&sibling_dir).expect("create sibling directory");
let summary_path = session_dir.join("summary.json");
std::fs::write(
&summary_path,
format!(r#"{{"info":{{"id":"{session_id}"}}}}"#),
)
.expect("write summary");
std::fs::write(sibling_dir.join("keep.txt"), "keep").expect("write sibling file");
let deleted = delete_session(&root, &summary_path, session_id).expect("delete session");
assert!(deleted);
assert!(!session_dir.exists());
assert!(sibling_dir.exists());
}
#[test]
fn delete_session_rejects_remove_dir_all_target_outside_root() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("sessions");
let outside_dir = temp.path().join("outside").join("session-outside");
std::fs::create_dir_all(&root).expect("create root");
std::fs::create_dir_all(&outside_dir).expect("create outside directory");
let summary_path = outside_dir.join("summary.json");
std::fs::write(&summary_path, r#"{"info":{"id":"session-outside"}}"#)
.expect("write summary");
let error = delete_session(&root, &summary_path, "session-outside")
.expect_err("outside path must be rejected");
assert!(error.contains("outside the session root"));
assert!(outside_dir.exists());
}
}
@@ -1,6 +1,7 @@
pub mod claude;
pub mod codex;
pub mod gemini;
pub mod grokbuild;
pub mod hermes;
pub mod openclaw;
pub mod opencode;
+29 -1
View File
@@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
@@ -40,6 +39,8 @@ pub struct VisibleApps {
#[serde(default = "default_true")]
pub gemini: bool,
#[serde(default = "default_true")]
pub grokbuild: bool,
#[serde(default = "default_true")]
pub opencode: bool,
#[serde(default = "default_true")]
pub openclaw: bool,
@@ -54,6 +55,7 @@ impl Default for VisibleApps {
claude_desktop: true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: false, // 默认不显示,需用户手动启用
@@ -69,6 +71,7 @@ impl VisibleApps {
AppType::ClaudeDesktop => self.claude_desktop,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => self.openclaw,
AppType::Hermes => self.hermes,
@@ -412,6 +415,8 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grok_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw_config_dir: Option<String>,
@@ -431,6 +436,9 @@ pub struct AppSettings {
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
/// 当前 Grok Build 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_grokbuild: Option<String>,
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_opencode: Option<String>,
@@ -521,6 +529,7 @@ impl Default for AppSettings {
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
grok_config_dir: None,
opencode_config_dir: None,
openclaw_config_dir: None,
hermes_config_dir: None,
@@ -528,6 +537,7 @@ impl Default for AppSettings {
current_provider_claude_desktop: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_grokbuild: None,
current_provider_opencode: None,
current_provider_openclaw: None,
current_provider_hermes: None,
@@ -576,6 +586,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.grok_config_dir = self
.grok_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.opencode_config_dir = self
.opencode_config_dir
.as_ref()
@@ -660,6 +677,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()
@@ -883,6 +901,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_grok_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.grok_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_opencode_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
@@ -940,6 +966,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::GrokBuild => settings.current_provider_grokbuild.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
AppType::Hermes => settings.current_provider_hermes.clone(),
@@ -957,6 +984,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
AppType::ClaudeDesktop => settings.current_provider_claude_desktop = id_owned.clone(),
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
AppType::GrokBuild => settings.current_provider_grokbuild = id_owned.clone(),
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
AppType::Hermes => settings.current_provider_hermes = id_owned.clone(),
+9 -1
View File
@@ -1,4 +1,5 @@
use crate::database::Database;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::{ProxyService, UsageCache};
use std::sync::Arc;
@@ -7,17 +8,24 @@ pub struct AppState {
pub db: Arc<Database>,
pub proxy_service: ProxyService,
pub usage_cache: Arc<UsageCache>,
// 内部已使用细粒度锁(accounts/access_tokens/refresh_locks),所有方法均为
// `&self`,无需外层 RwLock;避免持有粗粒度锁跨网络刷新导致的连锁阻塞。
pub codex_oauth_manager: Arc<CodexOAuthManager>,
}
impl AppState {
/// 创建新的应用状态
pub fn new(db: Arc<Database>) -> Self {
let proxy_service = ProxyService::new(db.clone());
let codex_oauth_manager =
Arc::new(CodexOAuthManager::new(crate::config::get_app_config_dir()));
let proxy_service =
ProxyService::new_with_codex_oauth_manager(db.clone(), codex_oauth_manager.clone());
Self {
db,
proxy_service,
usage_cache: Arc::new(UsageCache::new()),
codex_oauth_manager,
}
}
}
+117 -3
View File
@@ -58,6 +58,41 @@ pub struct TrayTexts {
pub no_project_label: &'static str,
}
/// 将系统区域标识映射为托盘支持的语言码。
///
/// 镜像前端 `i18n/getInitialLanguage` 的判定顺序,确保首次安装
/// `settings.language` 尚未写入)时托盘语言与界面语言一致:
/// 繁中系统(zh-TW/HK/MO/Hant)→ `zh-TW`,其余 zh → `zh`
/// 日文 → `ja`,英文 → `en`,未知区域回退到 `zh`(与前端默认一致)。
fn map_locale_to_tray_language(locale: &str) -> &'static str {
let locale = locale.to_lowercase();
if locale == "zh" {
"zh"
} else if locale.starts_with("zh-tw")
|| locale.starts_with("zh-hk")
|| locale.starts_with("zh-mo")
|| locale.starts_with("zh-hant")
{
"zh-TW"
} else if locale.starts_with("zh") {
"zh"
} else if locale.starts_with("ja") {
"ja"
} else if locale.starts_with("en") {
"en"
} else {
"zh"
}
}
/// 读取系统区域并映射为托盘语言码;取不到区域时回退到 `zh`。
fn detect_system_tray_language() -> &'static str {
sys_locale::get_locale()
.as_deref()
.map(map_locale_to_tray_language)
.unwrap_or("zh")
}
impl TrayTexts {
pub fn from_language(language: &str) -> Self {
match language {
@@ -118,7 +153,7 @@ pub struct TrayAppSection {
pub const AUTO_SUFFIX: &str = "auto";
pub const TRAY_ID: &str = "cc-switch";
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
pub const TRAY_SECTIONS: [TrayAppSection; 4] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
@@ -140,6 +175,13 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
header_label: "Gemini",
log_name: "Gemini",
},
TrayAppSection {
app_type: AppType::GrokBuild,
prefix: "grokbuild_",
empty_id: "grokbuild_empty",
header_label: "Grok Build",
log_name: "Grok Build",
},
];
/// 配色阈值(与前端 `utilizationColor` 语义一致)。
@@ -597,7 +639,13 @@ pub fn create_tray_menu(
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, AppError> {
let app_settings = crate::settings::get_settings();
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
// 用户未显式设置语言(首次安装)时,按系统区域回退而非硬编码简体,
// 否则繁中系统的托盘会固定显示简体直到用户手动切换一次。
let language: &str = match app_settings.language.as_deref() {
Some(lang) => lang,
None => detect_system_tray_language(),
};
let tray_texts = TrayTexts::from_language(language);
// Get visible apps setting, default to all visible
let visible_apps = app_settings.visible_apps.unwrap_or_default();
@@ -1072,7 +1120,8 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
#[cfg(test)]
mod tests {
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
use super::{format_script_summary, format_subscription_summary, TRAY_ID, TRAY_SECTIONS};
use crate::app_config::AppType;
use crate::provider::{UsageData, UsageResult};
use crate::services::subscription::{
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
@@ -1086,6 +1135,71 @@ mod tests {
assert_ne!(TRAY_ID, "main");
}
#[test]
fn locale_maps_traditional_chinese_variants_to_zh_tw() {
use super::map_locale_to_tray_language;
for locale in [
"zh-TW",
"zh-HK",
"zh-MO",
"zh-Hant",
"zh-Hant-TW",
"zh-hant-hk",
] {
assert_eq!(
map_locale_to_tray_language(locale),
"zh-TW",
"expected {locale} -> zh-TW"
);
}
}
#[test]
fn locale_maps_simplified_chinese_variants_to_zh() {
use super::map_locale_to_tray_language;
for locale in ["zh", "zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] {
assert_eq!(
map_locale_to_tray_language(locale),
"zh",
"expected {locale} -> zh"
);
}
}
#[test]
fn locale_maps_japanese_and_english() {
use super::map_locale_to_tray_language;
assert_eq!(map_locale_to_tray_language("ja-JP"), "ja");
assert_eq!(map_locale_to_tray_language("ja"), "ja");
assert_eq!(map_locale_to_tray_language("en-US"), "en");
assert_eq!(map_locale_to_tray_language("en"), "en");
}
#[test]
fn locale_unknown_falls_back_to_zh() {
use super::map_locale_to_tray_language;
// 与前端 getInitialLanguage 的默认值保持一致。
for locale in ["de-DE", "fr", "ko-KR", ""] {
assert_eq!(
map_locale_to_tray_language(locale),
"zh",
"expected {locale} -> zh (default)"
);
}
}
#[test]
fn tray_sections_include_grokbuild_provider_switching() {
let section = TRAY_SECTIONS
.iter()
.find(|section| section.app_type == AppType::GrokBuild)
.expect("Grok Build tray section should exist");
assert_eq!(section.prefix, "grokbuild_");
assert_eq!(section.empty_id, "grokbuild_empty");
assert_eq!(section.header_label, "Grok Build");
}
fn make_quota(tool: &str, success: bool, tiers: Vec<QuotaTier>) -> SubscriptionQuota {
SubscriptionQuota {
tool: tool.to_string(),
+8
View File
@@ -6,6 +6,14 @@ use cc_switch_lib::AppType;
fn parse_known_apps_case_insensitive_and_trim() {
assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude)));
assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex)));
assert!(matches!(
AppType::from_str("grokbuild"),
Ok(AppType::GrokBuild)
));
assert!(matches!(
AppType::from_str("Grok-Build"),
Ok(AppType::GrokBuild)
));
assert!(matches!(
AppType::from_str(" ClAuDe \n"),
Ok(AppType::Claude)
+2
View File
@@ -748,6 +748,7 @@ command = "echo"
claude: false,
codex: false, // 初始未启用
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -877,6 +878,7 @@ fn import_from_claude_merges_into_config() {
claude: false, // 初始未启用
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+12
View File
@@ -227,6 +227,7 @@ command = "echo"
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -371,6 +372,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
claude: false,
codex: false, // 初始未启用
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -436,6 +438,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -481,6 +484,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -515,6 +519,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -648,6 +653,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -703,6 +709,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -758,6 +765,7 @@ fn explicit_default_claude_dir_keeps_default_split_mcp_path() {
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -814,6 +822,7 @@ fn custom_claude_dir_writes_mcp_inside_config_dir() {
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -893,6 +902,7 @@ fn custom_claude_dir_sync_does_not_copy_default_profile() {
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1032,6 +1042,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1054,6 +1065,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+76
View File
@@ -19,6 +19,81 @@ fn settings_path(home: &Path) -> PathBuf {
home.join(".cc-switch").join("settings.json")
}
fn grokbuild_config(name: &str, endpoint: &str, api_key: &str) -> String {
format!(
r#"[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "grok-4.5"
base_url = "{endpoint}"
name = "{name}"
api_key = "{api_key}"
api_backend = "responses"
context_window = 500000
"#
)
}
#[test]
fn grokbuild_import_and_switch_write_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let live_path = home.join(".grok").join("config.toml");
std::fs::create_dir_all(live_path.parent().expect("grok config dir"))
.expect("create grok config dir");
let imported_config = grokbuild_config("Imported", "https://old.example/v1", "old-key");
std::fs::write(&live_path, &imported_config).expect("seed Grok Build config");
let state = create_test_state().expect("create test state");
import_default_config_test_hook(&state, AppType::GrokBuild)
.expect("import Grok Build default provider");
let imported = state
.db
.get_provider_by_id("default", AppType::GrokBuild.as_str())
.expect("query imported provider")
.expect("imported provider exists");
assert_eq!(
imported
.settings_config
.get("config")
.and_then(|value| value.as_str()),
Some(imported_config.as_str())
);
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
state
.db
.save_provider(
AppType::GrokBuild.as_str(),
&Provider::with_id(
"relay".to_string(),
"Relay".to_string(),
json!({ "config": next_config }),
None,
),
)
.expect("save second Grok Build provider");
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
.expect("switch Grok Build provider");
assert_eq!(
std::fs::read_to_string(&live_path).expect("read switched Grok Build config"),
next_config
);
assert_eq!(
state
.db
.get_current_provider(AppType::GrokBuild.as_str())
.expect("read Grok Build current provider")
.as_deref(),
Some("relay")
);
}
#[test]
fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -300,6 +375,7 @@ command = "say"
claude: false,
codex: true, // 启用 Codex
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+5
View File
@@ -164,6 +164,7 @@ command = "say"
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -934,6 +935,7 @@ fn reapply_codex_official_live_resyncs_mcp_servers() {
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1032,6 +1034,7 @@ fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1124,6 +1127,7 @@ fn switch_codex_projects_mcp_despite_broken_claude_json() {
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1187,6 +1191,7 @@ fn sync_all_enabled_reports_broken_app_but_projects_the_rest() {
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+1
View File
@@ -31,6 +31,7 @@ pub fn reset_test_fs() {
".codex",
".cc-switch",
".gemini",
".grok",
".config",
".openclaw",
"profiles",
+8 -1
View File
@@ -127,6 +127,7 @@ const VALID_APPS: AppId[] = [
"claude-desktop",
"codex",
"gemini",
"grokbuild",
"opencode",
"openclaw",
"hermes",
@@ -194,6 +195,7 @@ function App() {
"claude-desktop": true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: true,
@@ -204,6 +206,7 @@ function App() {
if (visibleApps["claude-desktop"]) return "claude-desktop";
if (visibleApps.codex) return "codex";
if (visibleApps.gemini) return "gemini";
if (visibleApps.grokbuild) return "grokbuild";
if (visibleApps.opencode) return "opencode";
if (visibleApps.openclaw) return "openclaw";
if (visibleApps.hermes) return "hermes";
@@ -222,6 +225,7 @@ function App() {
currentView === "sessions" &&
sharedFeatureApp !== "claude" &&
sharedFeatureApp !== "codex" &&
sharedFeatureApp !== "grokbuild" &&
sharedFeatureApp !== "opencode" &&
sharedFeatureApp !== "openclaw" &&
sharedFeatureApp !== "gemini" &&
@@ -291,6 +295,7 @@ function App() {
const hasSessionSupport =
sharedFeatureApp === "claude" ||
sharedFeatureApp === "codex" ||
sharedFeatureApp === "grokbuild" ||
sharedFeatureApp === "opencode" ||
sharedFeatureApp === "openclaw" ||
sharedFeatureApp === "gemini" ||
@@ -1405,7 +1410,9 @@ function App() {
? "openclaw"
: activeApp === "hermes"
? "hermes"
: "default"
: activeApp === "grokbuild"
? "grokbuild"
: "default"
}
className="flex items-center gap-1"
initial={{ opacity: 0 }}
+3
View File
@@ -23,6 +23,7 @@ const ALL_APPS: AppId[] = [
"claude-desktop",
"codex",
"gemini",
"grokbuild",
"opencode",
"openclaw",
"hermes",
@@ -46,6 +47,7 @@ export function AppSwitcher({
"claude-desktop": "claude",
codex: "openai",
gemini: "gemini",
grokbuild: "grok",
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
@@ -55,6 +57,7 @@ export function AppSwitcher({
"claude-desktop": "Claude Desktop",
codex: "Codex",
gemini: "Gemini",
grokbuild: "Grok Build",
opencode: "OpenCode",
openclaw: "OpenClaw",
hermes: "Hermes",
+10
View File
@@ -14,6 +14,7 @@ import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
} from "@/utils/providerConfigUtils";
import { parseGrokBuildConfig } from "@/utils/grokBuildConfig";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
@@ -262,6 +263,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
apiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
} else if (appId === "grokbuild") {
const grokConfig = parseGrokBuildConfig(
(config as any).config,
provider.name,
);
return {
apiKey: grokConfig.apiKey,
baseUrl: grokConfig.baseUrl,
};
} else if (appId === "hermes") {
// Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml
return {
+23 -6
View File
@@ -28,6 +28,25 @@ interface FullScreenPanelProps {
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
const HEADER_HEIGHT = 64; // px - match App.tsx
let bodyScrollLockCount = 0;
let bodyOverflowBeforeFirstLock: string | null = null;
const lockBodyScroll = () => {
if (bodyScrollLockCount === 0) {
bodyOverflowBeforeFirstLock = document.body.style.overflow;
document.body.style.overflow = "hidden";
}
bodyScrollLockCount += 1;
};
const unlockBodyScroll = () => {
bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1);
if (bodyScrollLockCount === 0) {
document.body.style.overflow = bodyOverflowBeforeFirstLock ?? "";
bodyOverflowBeforeFirstLock = null;
}
};
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
@@ -42,12 +61,10 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
contentClassName,
}) => {
React.useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "";
};
if (!isOpen) return;
lockBodyScroll();
return unlockBodyScroll;
}, [isOpen]);
// ESC 键关闭面板
+23 -2
View File
@@ -42,7 +42,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
onClose,
existingIds = [],
defaultFormat = "json",
defaultEnabledApps = ["claude", "codex", "gemini"],
defaultEnabledApps = ["claude", "codex", "gemini", "grokbuild"],
}) => {
const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
@@ -65,17 +65,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: boolean;
codex: boolean;
gemini: boolean;
grokbuild: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
return {
...initialData.apps,
grokbuild: initialData.apps.grokbuild ?? false,
};
}
return {
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
grokbuild: defaultEnabledApps.includes("grokbuild"),
opencode: defaultEnabledApps.includes("opencode"),
openclaw: defaultEnabledApps.includes("openclaw"),
hermes: defaultEnabledApps.includes("hermes"),
@@ -566,6 +571,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-grokbuild"
checked={enabledApps.grokbuild}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, grokbuild: checked })
}
/>
<label
htmlFor="enable-grokbuild"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.grokbuild")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-opencode"
+1
View File
@@ -61,6 +61,7 @@ const UnifiedMcpPanel = React.forwardRef<
"claude-desktop": 0,
codex: 0,
gemini: 0,
grokbuild: 0,
opencode: 0,
openclaw: 0,
hermes: 0,
@@ -35,6 +35,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
grokbuild: "AGENTS.md",
opencode: "AGENTS.md",
hermes: "AGENTS.md",
};
@@ -29,6 +29,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
grokbuild: "AGENTS.md",
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
hermes: "AGENTS.md",
+38 -7
View File
@@ -12,6 +12,7 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { AuthSettingsPanel } from "@/components/providers/AuthSettingsPanel";
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
@@ -19,8 +20,10 @@ import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import { extractGrokBuildBaseUrl } from "@/utils/grokBuildConfig";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import type { ManagedAuthProvider } from "@/lib/api";
interface AddProviderDialogProps {
open: boolean;
@@ -48,6 +51,7 @@ export function AddProviderDialog({
appId !== "opencode" &&
appId !== "openclaw" &&
appId !== "hermes" &&
appId !== "grokbuild" &&
appId !== "claude-desktop";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
@@ -56,6 +60,21 @@ export function AddProviderDialog({
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [authSettingsTarget, setAuthSettingsTarget] =
useState<ManagedAuthProvider | null>(null);
const closeDialog = useCallback(() => {
setAuthSettingsTarget(null);
onOpenChange(false);
}, [onOpenChange]);
const handlePanelClose = useCallback(() => {
if (authSettingsTarget) {
setAuthSettingsTarget(null);
return;
}
closeDialog();
}, [authSettingsTarget, closeDialog]);
const handleUniversalProviderSave = useCallback(
async (provider: UniversalProvider) => {
@@ -255,6 +274,11 @@ export function AddProviderDialog({
if (env?.GOOGLE_GEMINI_BASE_URL) {
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
} else if (appId === "grokbuild") {
const config = parsedConfig.config as string | undefined;
if (config) {
addUrl(extractGrokBuildBaseUrl(config));
}
} else if (appId === "opencode") {
const options = parsedConfig.options as
| Record<string, any>
@@ -298,9 +322,9 @@ export function AddProviderDialog({
}
await onSubmit(providerData);
onOpenChange(false);
closeDialog();
},
[appId, onSubmit, onOpenChange],
[appId, onSubmit, closeDialog],
);
const footer =
@@ -311,7 +335,7 @@ export function AddProviderDialog({
</span>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={closeDialog}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
@@ -330,7 +354,7 @@ export function AddProviderDialog({
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={closeDialog}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
@@ -349,7 +373,7 @@ export function AddProviderDialog({
<FullScreenPanel
isOpen={open}
title={t("provider.addNewProvider")}
onClose={() => onOpenChange(false)}
onClose={handlePanelClose}
footer={footer}
contentClassName="pt-3"
>
@@ -372,7 +396,8 @@ export function AddProviderDialog({
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
showButtons={false}
/>
@@ -388,7 +413,8 @@ export function AddProviderDialog({
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
showButtons={false}
/>
@@ -402,6 +428,11 @@ export function AddProviderDialog({
initialPreset={selectedUniversalPreset}
/>
)}
<AuthSettingsPanel
target={authSettingsTarget}
onClose={() => setAuthSettingsTarget(null)}
/>
</FullScreenPanel>
);
}
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
import type { ManagedAuthProvider } from "@/lib/api";
interface AuthSettingsPanelProps {
target: ManagedAuthProvider | null;
onClose: () => void;
}
export function AuthSettingsPanel({ target, onClose }: AuthSettingsPanelProps) {
const { t } = useTranslation();
const isOpen = target !== null;
return (
<FullScreenPanel
isOpen={isOpen}
title={t("settings.tabAuth", { defaultValue: "认证" })}
onClose={onClose}
>
{target ? <AuthCenterPanel authScrollTarget={target} /> : null}
</FullScreenPanel>
);
}
@@ -8,7 +8,14 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { openclawApi, providersApi, vscodeApi, type AppId } from "@/lib/api";
import { AuthSettingsPanel } from "@/components/providers/AuthSettingsPanel";
import {
openclawApi,
providersApi,
vscodeApi,
type AppId,
type ManagedAuthProvider,
} from "@/lib/api";
interface EditProviderDialogProps {
open: boolean;
@@ -32,6 +39,8 @@ export function EditProviderDialog({
}: EditProviderDialogProps) {
const { t } = useTranslation();
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [authSettingsTarget, setAuthSettingsTarget] =
useState<ManagedAuthProvider | null>(null);
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是"当前生效供应商",则尝试读取实时配置替换初始值
const [liveSettings, setLiveSettings] = useState<Record<
@@ -42,6 +51,19 @@ export function EditProviderDialog({
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
const [hasLoadedLive, setHasLoadedLive] = useState(false);
const closeDialog = useCallback(() => {
setAuthSettingsTarget(null);
onOpenChange(false);
}, [onOpenChange]);
const handlePanelClose = useCallback(() => {
if (authSettingsTarget) {
setAuthSettingsTarget(null);
return;
}
closeDialog();
}, [authSettingsTarget, closeDialog]);
useEffect(() => {
let cancelled = false;
const load = async () => {
@@ -212,9 +234,9 @@ export function EditProviderDialog({
provider: updatedProvider,
originalId: provider.id,
});
onOpenChange(false);
closeDialog();
},
[appId, onSubmit, onOpenChange, provider],
[appId, onSubmit, closeDialog, provider],
);
if (!provider || !initialData) {
@@ -225,7 +247,7 @@ export function EditProviderDialog({
<FullScreenPanel
isOpen={open}
title={t("provider.editProvider")}
onClose={() => onOpenChange(false)}
onClose={handlePanelClose}
footer={
<Button
type="submit"
@@ -243,12 +265,17 @@ export function EditProviderDialog({
providerId={provider.id}
submitLabel={t("common.save")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
initialData={initialData}
showButtons={false}
isProxyTakeover={isProxyTakeover}
/>
<AuthSettingsPanel
target={authSettingsTarget}
onClose={() => setAuthSettingsTarget(null)}
/>
</FullScreenPanel>
);
}
+6 -1
View File
@@ -28,6 +28,7 @@ import {
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries";
import { resolveProviderIcon } from "@/utils/providerIcon";
interface DragHandleProps {
attributes: DraggableAttributes;
@@ -350,7 +351,11 @@ export function ProviderCard({
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
icon={resolveProviderIcon(
appId,
provider.icon,
provider.iconColor,
)}
name={provider.name}
color={provider.iconColor}
size={20}
@@ -68,6 +68,7 @@ import {
type ClaudeDesktopDefaultRoute,
} from "@/lib/api/providers";
import { resolveManagedAccountId } from "@/lib/authBinding";
import type { ManagedAuthProvider } from "@/lib/api";
export type ClaudeDesktopProviderFormValues = ProviderFormData & {
presetId?: string;
@@ -102,6 +103,7 @@ export interface ClaudeDesktopProviderFormProps {
iconColor?: string;
};
showButtons?: boolean;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
}
type RouteRow = {
@@ -254,6 +256,7 @@ export function ClaudeDesktopProviderForm({
onSubmittingChange,
initialData,
showButtons = true,
onManageAuthAccounts,
}: ClaudeDesktopProviderFormProps) {
const { t } = useTranslation();
const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct";
@@ -770,13 +773,25 @@ export function ClaudeDesktopProviderForm({
<div className="rounded-lg border border-border-default bg-muted/20 p-3">
{activeProviderType === "github_copilot" ? (
<CopilotAuthSection
mode="select"
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={setSelectedGitHubAccountId}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("github_copilot")
: undefined
}
/>
) : (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={setSelectedCodexAccountId}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
fastModeEnabled={codexFastMode}
onFastModeChange={setCodexFastMode}
/>
@@ -54,6 +54,7 @@ import type {
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import type { ManagedAuthProvider } from "@/lib/api";
import {
hasClaudeOneMMarker,
setClaudeOneMMarker,
@@ -89,6 +90,8 @@ interface ClaudeFormFieldsProps {
selectedGitHubAccountId?: string | null;
/** GitHub 账号选择回调(多账号支持) */
onGitHubAccountSelect?: (accountId: string | null) => void;
/** 打开托管账号管理入口 */
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
// Codex OAuth (ChatGPT Plus/Pro)
isCodexOauthPreset?: boolean;
@@ -168,6 +171,7 @@ export function ClaudeFormFields({
isCopilotAuthenticated,
selectedGitHubAccountId,
onGitHubAccountSelect,
onManageAuthAccounts,
isCodexOauthPreset,
isCodexOauthAuthenticated,
selectedCodexAccountId,
@@ -604,16 +608,28 @@ export function ClaudeFormFields({
{/* GitHub Copilot OAuth 认证 */}
{isCopilotPreset && (
<CopilotAuthSection
mode="select"
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={onGitHubAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("github_copilot")
: undefined
}
/>
)}
{/* Codex OAuth 认证 (ChatGPT Plus/Pro) */}
{isCodexOauthPreset && (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
fastModeEnabled={codexFastMode}
onFastModeChange={onCodexFastModeChange}
/>
@@ -26,6 +26,7 @@ import {
Trash2,
} from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { CodexOAuthSection } from "./CodexOAuthSection";
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
import {
fetchModelsForConfig,
@@ -43,12 +44,15 @@ import type {
PromptCacheRoutingMode,
ProviderCategory,
} from "@/types";
import type { ManagedAuthProvider } from "@/lib/api";
import type { AppId } from "@/lib/api";
interface EndpointCandidate {
url: string;
}
interface CodexFormFieldsProps {
appId?: AppId;
providerId?: string;
// API Key
codexApiKey: string;
@@ -58,6 +62,11 @@ interface CodexFormFieldsProps {
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
isCodexOauthPreset?: boolean;
selectedCodexAccountId?: string | null;
onCodexAccountSelect?: (accountId: string | null) => void;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
codexOauthNoneOptionLabel?: string;
// Base URL
shouldShowSpeedTest: boolean;
@@ -155,6 +164,7 @@ function catalogRowsMatchModels(
}
export function CodexFormFields({
appId = "codex",
providerId,
codexApiKey,
onApiKeyChange,
@@ -163,6 +173,11 @@ export function CodexFormFields({
websiteUrl,
isPartner,
partnerPromotionKey,
isCodexOauthPreset = false,
selectedCodexAccountId,
onCodexAccountSelect,
onManageAuthAccounts,
codexOauthNoneOptionLabel,
shouldShowSpeedTest,
codexBaseUrl,
onBaseUrlChange,
@@ -430,26 +445,43 @@ export function CodexFormFields({
return (
<>
{/* Codex OAuth 账号选择 */}
{isCodexOauthPreset && (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
noneOptionLabel={codexOauthNoneOptionLabel}
/>
)}
{/* Codex API Key 输入框 */}
<ApiKeySection
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
placeholder={{
official: t("providerForm.codexOfficialNoApiKey", {
defaultValue: "官方供应商无需 API Key",
}),
thirdParty: t("providerForm.codexApiKeyAutoFill", {
defaultValue: "输入 API Key,将自动填充到配置",
}),
}}
/>
{!isCodexOauthPreset && (
<ApiKeySection
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
placeholder={{
official: t("providerForm.codexOfficialNoApiKey", {
defaultValue: "官方供应商无需 API Key",
}),
thirdParty: t("providerForm.codexApiKeyAutoFill", {
defaultValue: "输入 API Key,将自动填充到配置",
}),
}}
/>
)}
{/* Codex Base URL 输入框 */}
{shouldShowSpeedTest && (
@@ -1007,7 +1039,7 @@ export function CodexFormFields({
{/* 端点测速弹窗 - Codex */}
{shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest
appId="codex"
appId={appId}
providerId={providerId}
value={codexBaseUrl}
onChange={onBaseUrlChange}
@@ -21,16 +21,25 @@ import {
X,
Sparkles,
User,
Settings2,
AlertTriangle,
RefreshCw,
} from "lucide-react";
import { useCodexOauth } from "./hooks/useCodexOauth";
import { copyText } from "@/lib/clipboard";
interface CodexOAuthSectionProps {
className?: string;
/** select 模式只展示账号选择和管理入口;manage 模式展示完整账号管理 */
mode?: "manage" | "select";
/** 当前选中的 ChatGPT 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
/** 打开账号管理入口 */
onManageAccounts?: () => void;
/** 空选择项文案;默认表示使用托管认证的默认账号 */
noneOptionLabel?: string;
/** 是否开启 Codex FAST mode */
fastModeEnabled?: boolean;
/** FAST mode 切换回调 */
@@ -45,8 +54,11 @@ interface CodexOAuthSectionProps {
*/
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
mode = "manage",
selectedAccountId,
onAccountSelect,
onManageAccounts,
noneOptionLabel,
fastModeEnabled = false,
onFastModeChange,
}) => {
@@ -56,6 +68,8 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
const {
accounts,
defaultAccountId,
isStatusSuccess,
isStatusError,
hasAnyAccount,
pollingState,
deviceCode,
@@ -69,6 +83,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
setDefaultAccount,
cancelAuth,
logout,
refetchStatus,
} = useCodexOauth();
const copyUserCode = async () => {
@@ -83,6 +98,25 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
onAccountSelect?.(value === "none" ? null : value);
};
React.useEffect(() => {
// Only clear a bound account when the status query has *successfully*
// loaded and the account is genuinely gone. On a failed/pending query
// `accounts` is an empty array, which must not silently unbind the
// provider's managed account (that would corrupt the saved config).
if (
mode !== "select" ||
!selectedAccountId ||
!onAccountSelect ||
!isStatusSuccess
) {
return;
}
if (!accounts.some((account) => account.id === selectedAccountId)) {
onAccountSelect(null);
}
}, [accounts, isStatusSuccess, mode, onAccountSelect, selectedAccountId]);
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
@@ -92,58 +126,184 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
}
};
// 升级前登录的旧账号没有持久化 id_token,需重新登录补全
const hasReauthAccounts = accounts.some((account) => account.reauth_required);
const selectedAccountNeedsReauth =
!!selectedAccountId &&
accounts.some(
(account) => account.id === selectedAccountId && account.reauth_required,
);
const accountSelect = isStatusSuccess &&
onAccountSelect &&
(mode === "select" || hasAnyAccount || noneOptionLabel) && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{mode === "select"
? t("codexOauth.chatgptAccount", "ChatGPT 账号")
: t("codexOauth.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"codexOauth.selectAccountPlaceholder",
"选择一个 ChatGPT 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{noneOptionLabel ??
t("codexOauth.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{account.login}</span>
{account.reauth_required && (
<span className="ml-1 inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-3 w-3" />
{t("codexOauth.reauthBadge", "需要重新登录")}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("codexOauth.authStatus", "认证状态")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
{mode === "manage" && (
<div className="flex items-center justify-between">
<Label>{t("codexOauth.authStatus", "认证状态")}</Label>
<Badge
variant={
isStatusError
? "destructive"
: hasAnyAccount
? "default"
: "secondary"
}
className={
isStatusSuccess && hasAnyAccount
? "bg-green-500 hover:bg-green-600"
: ""
}
>
{isStatusError
? t("codexOauth.statusUnavailable", "状态不可用")
: !isStatusSuccess
? t("codexOauth.statusLoading", "正在加载...")
: hasAnyAccount
? t("codexOauth.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("codexOauth.notAuthenticated", "未认证")}
</Badge>
</div>
)}
{isStatusError && (
<div
role="alert"
className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
{hasAnyAccount
? t("codexOauth.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("codexOauth.notAuthenticated", "未认证")}
</Badge>
</div>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
{t(
"codexOauth.statusLoadFailed",
"无法加载 ChatGPT 账号状态,请重试。",
)}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0"
onClick={() => void refetchStatus()}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("codexOauth.retry", "重试")}
</Button>
</div>
)}
{!isStatusSuccess && !isStatusError && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("codexOauth.statusLoading", "正在加载...")}
</div>
)}
{/* 旧账号需重新登录提示(缺少 id_token) */}
{mode === "manage" && hasReauthAccounts && (
<div className="flex items-start gap-3 rounded-lg border border-amber-300/70 bg-amber-50 p-3 text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t("codexOauth.reauthTitle", "部分账号需要重新登录")}
</p>
<p className="text-xs leading-relaxed text-amber-800/90 dark:text-amber-200/80">
{t(
"codexOauth.reauthDescription",
"为与浏览器登录行为保持一致,这些账号需要重新登录以补全所需的登录凭据(id_token)。重新登录后即可正常用于托管绑定。",
)}
</p>
</div>
</div>
)}
{/* 账号选择器 */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"codexOauth.selectAccountPlaceholder",
"选择一个 ChatGPT 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("codexOauth.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{mode === "select" && accountSelect ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">{accountSelect}</div>
{onManageAccounts && (
<Button
type="button"
variant="outline"
onClick={onManageAccounts}
className="h-9 shrink-0"
>
<Settings2 className="h-4 w-4" />
{t("codexOauth.manageAccounts", "管理账号")}
</Button>
)}
</div>
) : (
accountSelect
)}
{/* select 模式:所选账号需重新登录的内联提示 */}
{mode === "select" && selectedAccountNeedsReauth && (
<div className="flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="flex-1 leading-relaxed">
{t(
"codexOauth.reauthSelectHint",
"该账号需重新登录以启用托管绑定。",
)}
{onManageAccounts && (
<button
type="button"
onClick={onManageAccounts}
className="ml-1 font-medium underline underline-offset-2 hover:text-amber-700 dark:hover:text-amber-100"
>
{t("codexOauth.reauthNow", "立即重新登录")}
</button>
)}
</div>
</div>
)}
@@ -169,7 +329,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
{mode === "manage" && isStatusSuccess && hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.loggedInAccounts", "已登录账号")}
@@ -178,23 +338,51 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
account.reauth_required
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-950/30"
: "bg-muted/30"
}`}
>
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
<div className="flex min-w-0 items-center gap-2">
<User className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{account.login}
</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
<Badge variant="secondary" className="shrink-0 text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
<Badge variant="outline" className="shrink-0 text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
{account.reauth_required && (
<Badge
variant="outline"
className="shrink-0 gap-1 border-amber-400/70 text-xs text-amber-700 dark:border-amber-500/50 dark:text-amber-300"
>
<AlertTriangle className="h-3 w-3" />
{t("codexOauth.reauthBadge", "需要重新登录")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1">
{account.reauth_required && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 border-amber-400/70 px-2 text-xs text-amber-700 hover:bg-amber-100 dark:border-amber-500/50 dark:text-amber-300 dark:hover:bg-amber-900/40"
onClick={addAccount}
disabled={isAddingAccount}
>
<RefreshCw className="h-3.5 w-3.5" />
{t("codexOauth.reauthLogin", "重新登录")}
</Button>
)}
{defaultAccountId !== account.id && (
<Button
type="button"
@@ -211,7 +399,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
@@ -226,34 +414,40 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 未认证 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
>
<Sparkles className="mr-2 h-4 w-4" />
{t("codexOauth.loginWithChatGPT", "使用 ChatGPT 登录")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
!hasAnyAccount &&
pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
>
<Sparkles className="mr-2 h-4 w-4" />
{t("codexOauth.loginWithChatGPT", "使用 ChatGPT 登录")}
</Button>
)}
{/* 已有账号 - 添加更多按钮 */}
{hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
>
<Plus className="mr-2 h-4 w-4" />
{t("codexOauth.addAnotherAccount", "添加其他账号")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
hasAnyAccount &&
pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
>
<Plus className="mr-2 h-4 w-4" />
{t("codexOauth.addAnotherAccount", "添加其他账号")}
</Button>
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
{mode === "manage" && isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -310,7 +504,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
{mode === "manage" && pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
@@ -335,17 +529,20 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 注销所有账号 */}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("codexOauth.logoutAll", "注销所有账号")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
hasAnyAccount &&
accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("codexOauth.logoutAll", "注销所有账号")}
</Button>
)}
</div>
);
};
@@ -21,6 +21,9 @@ import {
Plus,
X,
User,
Settings2,
AlertTriangle,
RefreshCw,
} from "lucide-react";
import { useCopilotAuth } from "./hooks/useCopilotAuth";
import { copyText } from "@/lib/clipboard";
@@ -28,10 +31,14 @@ import type { GitHubAccount } from "@/lib/api";
interface CopilotAuthSectionProps {
className?: string;
/** select 模式只展示账号选择和管理入口;manage 模式展示完整账号管理 */
mode?: "manage" | "select";
/** 当前选中的 GitHub 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
/** 打开账号管理入口 */
onManageAccounts?: () => void;
}
/**
@@ -41,8 +48,10 @@ interface CopilotAuthSectionProps {
*/
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
className,
mode = "manage",
selectedAccountId,
onAccountSelect,
onManageAccounts,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
@@ -64,6 +73,8 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
accounts,
defaultAccountId,
migrationError,
isStatusSuccess,
isStatusError,
hasAnyAccount,
pollingState,
deviceCode,
@@ -77,6 +88,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
setDefaultAccount,
cancelAuth,
logout,
refetchStatus,
} = useCopilotAuth(effectiveGithubDomain);
// 复制用户码
@@ -93,6 +105,24 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
onAccountSelect?.(value === "none" ? null : value);
};
React.useEffect(() => {
// Only clear a bound account once the status query has *successfully*
// loaded and the account is genuinely gone. A failed/pending query yields
// an empty `accounts` array, which must not silently unbind the provider.
if (
mode !== "select" ||
!selectedAccountId ||
!onAccountSelect ||
!isStatusSuccess
) {
return;
}
if (!accounts.some((account) => account.id === selectedAccountId)) {
onAccountSelect(null);
}
}, [accounts, isStatusSuccess, mode, onAccountSelect, selectedAccountId]);
// 处理移除账号
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
@@ -109,60 +139,150 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
return <CopilotAccountAvatar account={account} />;
};
const accountSelect = isStatusSuccess &&
onAccountSelect &&
(mode === "select" || hasAnyAccount) && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{mode === "select"
? t("copilot.githubAccount", "GitHub 账号")
: t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
{mode === "manage" && (
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={
isStatusError
? "destructive"
: hasAnyAccount
? "default"
: "secondary"
}
className={
isStatusSuccess && hasAnyAccount
? "bg-green-500 hover:bg-green-600"
: ""
}
>
{isStatusError
? t("copilot.statusUnavailable", "状态不可用")
: !isStatusSuccess
? t("copilot.statusLoading", "正在加载...")
: hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
)}
{isStatusError && (
<div
role="alert"
className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
{hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
{t(
"copilot.statusLoadFailed",
"无法加载 GitHub Copilot 账号状态,请重试。",
)}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0"
onClick={() => void refetchStatus()}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("copilot.retry", "重试")}
</Button>
</div>
)}
{!isStatusSuccess && !isStatusError && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("copilot.statusLoading", "正在加载...")}
</div>
)}
{/* GitHub 部署类型选择 */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.deploymentType", "GitHub 部署类型")}
</Label>
<Select
value={deploymentType}
onValueChange={(v) =>
setDeploymentType(v as "github.com" | "enterprise")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="github.com">
{t("copilot.deploymentGitHubCom", "GitHub.com")}
</SelectItem>
<SelectItem value="enterprise">
{t("copilot.deploymentEnterprise", "GitHub Enterprise Server")}
</SelectItem>
</SelectContent>
</Select>
{deploymentType === "enterprise" && (
<Input
placeholder={t(
"copilot.enterpriseDomainPlaceholder",
"例如:company.ghe.com",
)}
value={enterpriseDomain}
onChange={(e) => setEnterpriseDomain(e.target.value)}
/>
)}
</div>
{mode === "manage" && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.deploymentType", "GitHub 部署类型")}
</Label>
<Select
value={deploymentType}
onValueChange={(v) =>
setDeploymentType(v as "github.com" | "enterprise")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="github.com">
{t("copilot.deploymentGitHubCom", "GitHub.com")}
</SelectItem>
<SelectItem value="enterprise">
{t("copilot.deploymentEnterprise", "GitHub Enterprise Server")}
</SelectItem>
</SelectContent>
</Select>
{deploymentType === "enterprise" && (
<Input
placeholder={t(
"copilot.enterpriseDomainPlaceholder",
"例如:company.ghe.com",
)}
value={enterpriseDomain}
onChange={(e) => setEnterpriseDomain(e.target.value)}
/>
)}
</div>
)}
{migrationError && (
{mode === "manage" && migrationError && (
<p className="text-sm text-amber-600 dark:text-amber-400">
{t("copilot.migrationFailed", {
error: migrationError,
@@ -172,44 +292,27 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 账号选择器(有账号时显示) */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{mode === "select" && accountSelect ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">{accountSelect}</div>
{onManageAccounts && (
<Button
type="button"
variant="outline"
onClick={onManageAccounts}
className="h-9 shrink-0"
>
<Settings2 className="h-4 w-4" />
{t("copilot.manageAccounts", "管理账号")}
</Button>
)}
</div>
) : (
accountSelect
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
{mode === "manage" && isStatusSuccess && hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.loggedInAccounts", "已登录账号")}
@@ -272,38 +375,46 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 未认证状态 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={deploymentType === "enterprise" && !enterpriseDomain.trim()}
>
<Github className="mr-2 h-4 w-4" />
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
!hasAnyAccount &&
pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={
deploymentType === "enterprise" && !enterpriseDomain.trim()
}
>
<Github className="mr-2 h-4 w-4" />
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
</Button>
)}
{/* 已有账号 - 添加更多账号按钮 */}
{hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={
isAddingAccount ||
(deploymentType === "enterprise" && !enterpriseDomain.trim())
}
>
<Plus className="mr-2 h-4 w-4" />
{t("copilot.addAnotherAccount", "添加其他账号")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
hasAnyAccount &&
pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={
isAddingAccount ||
(deploymentType === "enterprise" && !enterpriseDomain.trim())
}
>
<Plus className="mr-2 h-4 w-4" />
{t("copilot.addAnotherAccount", "添加其他账号")}
</Button>
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
{mode === "manage" && isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -363,7 +474,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
{mode === "manage" && pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
@@ -388,17 +499,20 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 注销所有账号按钮 */}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("copilot.logoutAll", "注销所有账号")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
hasAnyAccount &&
accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("copilot.logoutAll", "注销所有账号")}
</Button>
)}
</div>
);
};
@@ -14,6 +14,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
claude: 8,
"claude-desktop": 8,
gemini: 8,
grokbuild: 12,
opencode: 8,
openclaw: 8,
hermes: 8,
@@ -0,0 +1,576 @@
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import JsonEditor from "@/components/JsonEditor";
import { useDarkMode } from "@/hooks/useDarkMode";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import {
buildLocalProxyRequestOverrides,
formatRequestOverrideObject,
} from "@/lib/requestOverrides";
import type {
ClaudeApiKeyField,
CodexApiFormat,
CodexChatReasoning,
PromptCacheRoutingMode,
ProviderCategory,
ProviderMeta,
} from "@/types";
import type { ProviderFormProps, ProviderFormValues } from "./ProviderForm";
import { BasicFormFields } from "./BasicFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import {
codexProviderPresets,
type CodexProviderPreset,
} from "@/config/codexProviderPresets";
import {
codexApiFormatFromWireApi,
extractCodexBaseUrl,
extractCodexModelName,
extractCodexWireApi,
} from "@/utils/providerConfigUtils";
import {
buildGrokBuildConfig,
parseGrokBuildConfig,
updateGrokBuildConfig,
validateGrokBuildConfig,
} from "@/utils/grokBuildConfig";
import { resolveProviderIcon } from "@/utils/providerIcon";
type GrokBuildProviderFormProps = Omit<ProviderFormProps, "appId">;
const grokPresetEntries: Array<{
id: string;
preset: CodexProviderPreset;
}> = codexProviderPresets
.map((preset, index) => ({ id: `grokbuild-${index}`, preset }))
.filter(({ preset }) => preset.category !== "official" && !preset.isOfficial);
export const grokApiBackendFromApiFormat = (format: CodexApiFormat): string => {
if (format === "openai_chat") return "chat_completions";
if (format === "anthropic") return "messages";
return "responses";
};
export function GrokBuildProviderForm({
providerId,
submitLabel,
onSubmit,
onCancel,
onSubmittingChange,
initialData,
showButtons = true,
}: GrokBuildProviderFormProps) {
const { t } = useTranslation();
const isDarkMode = useDarkMode();
const initialConfigText =
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: undefined;
const initialConfig = useMemo(
() => parseGrokBuildConfig(initialConfigText, initialData?.name),
[initialConfigText, initialData?.name],
);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
initialData ? null : "custom",
);
const [category, setCategory] = useState<ProviderCategory | undefined>(
initialData?.category ?? "custom",
);
const [isPartner, setIsPartner] = useState(
initialData?.meta?.isPartner ?? false,
);
const [partnerPromotionKey, setPartnerPromotionKey] = useState<string>();
const [profile, setProfile] = useState(initialConfig.model);
const [upstreamModel, setUpstreamModel] = useState(
initialConfig.upstreamModel ?? initialConfig.model,
);
const [baseUrl, setBaseUrl] = useState(initialConfig.baseUrl);
const [apiKey, setApiKey] = useState(initialConfig.apiKey);
const [apiBackend, setApiBackend] = useState(initialConfig.apiBackend);
const [contextWindow, setContextWindow] = useState(
String(initialConfig.contextWindow),
);
const [rawConfig, setRawConfig] = useState(
initialConfigText ?? buildGrokBuildConfig(initialConfig),
);
const [apiFormat, setApiFormat] = useState<CodexApiFormat>(
(initialData?.meta?.apiFormat as CodexApiFormat | undefined) ??
"openai_responses",
);
const [anthropicAuthField, setAnthropicAuthField] =
useState<ClaudeApiKeyField>(
initialData?.meta?.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN",
);
const [impersonateClaudeCode, setImpersonateClaudeCode] = useState(
initialData?.meta?.impersonateClaudeCode === true,
);
const [maxOutputTokens, setMaxOutputTokens] = useState(
initialData?.meta?.maxOutputTokens
? String(initialData.meta.maxOutputTokens)
: "",
);
const [codexChatReasoning, setCodexChatReasoning] =
useState<CodexChatReasoning>(initialData?.meta?.codexChatReasoning ?? {});
const [promptCacheRouting, setPromptCacheRouting] =
useState<PromptCacheRoutingMode>(
initialData?.meta?.promptCacheRouting ?? "auto",
);
const [isFullUrl, setIsFullUrl] = useState(
initialData?.meta?.isFullUrl ?? false,
);
const [customUserAgent, setCustomUserAgent] = useState(
initialData?.meta?.customUserAgent ?? "",
);
const [headersOverride, setHeadersOverride] = useState(
formatRequestOverrideObject(
initialData?.meta?.localProxyRequestOverrides?.headers,
),
);
const [bodyOverride, setBodyOverride] = useState(
formatRequestOverrideObject(
initialData?.meta?.localProxyRequestOverrides?.body,
),
);
const [endpointAutoSelect, setEndpointAutoSelect] = useState(
initialData?.meta?.endpointAutoSelect ?? true,
);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [presetEndpoints, setPresetEndpoints] = useState<string[]>([]);
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
[],
);
const form = useForm<ProviderFormData>({
resolver: zodResolver(providerSchema),
defaultValues: {
name: initialData?.name ?? initialConfig.name,
websiteUrl: initialData?.websiteUrl ?? "",
notes: initialData?.notes ?? "",
settingsConfig: JSON.stringify({ config: rawConfig }),
icon:
resolveProviderIcon(
"grokbuild",
initialData?.icon,
initialData?.iconColor,
) ?? "",
iconColor: initialData?.iconColor ?? "",
},
mode: "onSubmit",
});
const { isSubmitting } = form.formState;
const websiteUrl = form.watch("websiteUrl") ?? "";
useEffect(() => {
onSubmittingChange?.(isSubmitting);
}, [isSubmitting, onSubmittingChange]);
const presetCategoryLabels = useMemo(
() => ({
official: t("providerForm.categoryOfficial", { defaultValue: "官方" }),
cn_official: t("providerForm.categoryCnOfficial", {
defaultValue: "国内官方",
}),
aggregator: t("providerForm.categoryAggregation", {
defaultValue: "聚合服务",
}),
third_party: t("providerForm.categoryThirdParty", {
defaultValue: "第三方",
}),
}),
[t],
);
const speedTestEndpoints = useMemo(() => {
const urls = new Set<string>();
const add = (url?: string) => {
const normalized = url?.trim().replace(/\/+$/, "");
if (normalized) urls.add(normalized);
};
add(baseUrl);
presetEndpoints.forEach(add);
draftCustomEndpoints.forEach(add);
return Array.from(urls).map((url) => ({ url }));
}, [baseUrl, draftCustomEndpoints, presetEndpoints]);
const syncStructuredConfig = (
overrides: Partial<ReturnType<typeof parseGrokBuildConfig>>,
) => {
const next = {
model: profile,
upstreamModel,
baseUrl,
name: form.getValues("name") || initialConfig.name,
apiKey,
apiBackend,
contextWindow: Number.parseInt(contextWindow, 10),
...overrides,
};
setRawConfig((current) => updateGrokBuildConfig(current, next));
};
const handlePresetChange = (presetId: string) => {
setSelectedPresetId(presetId);
if (presetId === "custom") {
setCategory("custom");
setIsPartner(false);
setPartnerPromotionKey(undefined);
setPresetEndpoints([]);
return;
}
const entry = grokPresetEntries.find(
(candidate) => candidate.id === presetId,
);
if (!entry) return;
const preset = entry.preset;
const presetName = preset.nameKey ? String(t(preset.nameKey)) : preset.name;
const presetBaseUrl = extractCodexBaseUrl(preset.config) ?? "";
const presetModel = extractCodexModelName(preset.config) ?? profile;
const presetApiFormat =
preset.apiFormat ??
codexApiFormatFromWireApi(extractCodexWireApi(preset.config)) ??
"openai_responses";
const presetApiKey =
"auth" in preset && typeof preset.auth?.OPENAI_API_KEY === "string"
? preset.auth.OPENAI_API_KEY
: "";
const presetApiBackend = grokApiBackendFromApiFormat(presetApiFormat);
form.setValue("name", presetName);
form.setValue("websiteUrl", preset.websiteUrl ?? "");
form.setValue("icon", preset.icon ?? "");
form.setValue("iconColor", preset.iconColor ?? "");
setCategory(preset.category ?? "custom");
setIsPartner(preset.isPartner ?? false);
setPartnerPromotionKey(preset.partnerPromotionKey);
setBaseUrl(presetBaseUrl);
setApiKey(presetApiKey);
setUpstreamModel(presetModel);
setApiFormat(presetApiFormat);
setApiBackend(presetApiBackend);
setPresetEndpoints(preset.endpointCandidates ?? []);
setRawConfig(
buildGrokBuildConfig({
model: profile,
upstreamModel: presetModel,
baseUrl: presetBaseUrl,
name: presetName,
apiKey: presetApiKey,
apiBackend: presetApiBackend,
contextWindow: Number.parseInt(contextWindow, 10),
}),
);
};
const handleRawConfigChange = (value: string) => {
setRawConfig(value);
if (validateGrokBuildConfig(value)) return;
const parsed = parseGrokBuildConfig(value, form.getValues("name"));
setProfile(parsed.model);
setUpstreamModel(parsed.upstreamModel ?? parsed.model);
setBaseUrl(parsed.baseUrl);
setApiKey(parsed.apiKey);
setApiBackend(parsed.apiBackend);
setContextWindow(String(parsed.contextWindow));
if (parsed.name) form.setValue("name", parsed.name);
};
const handleSubmit = async (values: ProviderFormData) => {
const name = values.name.trim();
const parsedContextWindow = Number.parseInt(contextWindow, 10);
const envKey = parseGrokBuildConfig(rawConfig).envKey?.trim();
if (
!name ||
!baseUrl.trim() ||
(!apiKey.trim() && !envKey) ||
!profile.trim()
) {
toast.error(
t("providerForm.requiredFields", {
defaultValue: "请填写供应商名称、API 地址、API Key 和模型",
}),
);
return;
}
if (!Number.isInteger(parsedContextWindow) || parsedContextWindow <= 0) {
toast.error(
t("grokBuild.contextWindowInvalid", {
defaultValue: "上下文窗口必须是正整数",
}),
);
return;
}
const finalConfig = updateGrokBuildConfig(rawConfig, {
model: profile,
upstreamModel,
baseUrl,
name,
apiKey,
apiBackend,
contextWindow: parsedContextWindow,
});
const configError = validateGrokBuildConfig(finalConfig);
if (configError) {
toast.error(
t("grokBuild.invalidToml", {
error: configError,
defaultValue: `config.toml 格式错误: ${configError}`,
}),
);
return;
}
const requestOverrides = buildLocalProxyRequestOverrides(
headersOverride,
bodyOverride,
);
if (requestOverrides.error) {
toast.error(requestOverrides.error);
return;
}
const customEndpoints = Object.fromEntries(
draftCustomEndpoints.map((url) => [
url,
{ url, addedAt: Date.now(), lastUsed: undefined },
]),
);
const parsedMaxOutputTokens = Number.parseInt(maxOutputTokens, 10);
const initialMeta = { ...(initialData?.meta ?? {}) };
delete initialMeta.custom_endpoints;
const meta: ProviderMeta = {
...initialMeta,
apiFormat,
apiKeyField: anthropicAuthField,
isFullUrl,
endpointAutoSelect,
isPartner,
partnerPromotionKey,
impersonateClaudeCode,
promptCacheRouting,
codexChatReasoning,
customUserAgent: customUserAgent.trim() || undefined,
localProxyRequestOverrides: requestOverrides.overrides,
maxOutputTokens:
Number.isInteger(parsedMaxOutputTokens) && parsedMaxOutputTokens > 0
? parsedMaxOutputTokens
: undefined,
};
if (!providerId && Object.keys(customEndpoints).length > 0) {
meta.custom_endpoints = customEndpoints;
}
const payload: ProviderFormValues = {
...values,
name,
websiteUrl: values.websiteUrl?.trim() ?? "",
notes: values.notes?.trim() ?? "",
settingsConfig: JSON.stringify({ config: finalConfig }),
presetId: selectedPresetId ?? undefined,
presetCategory: category ?? "custom",
isPartner,
meta,
};
await onSubmit(payload);
};
const rawConfigError = validateGrokBuildConfig(rawConfig);
return (
<Form {...form}>
<form
id="provider-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
{!initialData && (
<ProviderPresetSelector
selectedPresetId={selectedPresetId}
presetEntries={grokPresetEntries}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
category={category}
/>
)}
<BasicFormFields form={form} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormItem>
<FormLabel htmlFor="grokbuild-profile">
{t("grokBuild.profile", { defaultValue: "客户端模型档位" })}
</FormLabel>
<Input
id="grokbuild-profile"
value={profile}
onChange={(event) => {
const value = event.target.value;
setProfile(value);
syncStructuredConfig({ model: value });
}}
placeholder="grok-4.5"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-api-backend">
{t("grokBuild.apiBackend", { defaultValue: "API Backend" })}
</FormLabel>
<Input
id="grokbuild-api-backend"
value={apiBackend}
onChange={(event) => {
const value = event.target.value;
setApiBackend(value);
syncStructuredConfig({ apiBackend: value });
}}
placeholder="responses"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-context-window">
{t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })}
</FormLabel>
<Input
id="grokbuild-context-window"
type="number"
min={1}
step={1}
value={contextWindow}
onChange={(event) => {
const value = event.target.value;
setContextWindow(value);
syncStructuredConfig({
contextWindow: Number.parseInt(value, 10),
});
}}
/>
</FormItem>
</div>
<CodexFormFields
appId="grokbuild"
providerId={providerId}
codexApiKey={apiKey}
onApiKeyChange={(value) => {
setApiKey(value);
syncStructuredConfig({ apiKey: value });
}}
category={category}
shouldShowApiKeyLink={Boolean(websiteUrl)}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
shouldShowSpeedTest
codexBaseUrl={baseUrl}
onBaseUrlChange={(value) => {
setBaseUrl(value);
syncStructuredConfig({ baseUrl: value });
}}
isFullUrl={isFullUrl}
onFullUrlChange={setIsFullUrl}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
codexModel={upstreamModel}
onModelChange={(value) => {
setUpstreamModel(value);
syncStructuredConfig({ upstreamModel: value });
}}
apiFormat={apiFormat}
onApiFormatChange={(value) => {
const backend = grokApiBackendFromApiFormat(value);
setApiFormat(value);
setApiBackend(backend);
syncStructuredConfig({ apiBackend: backend });
}}
anthropicAuthField={anthropicAuthField}
onAnthropicAuthFieldChange={setAnthropicAuthField}
impersonateClaudeCode={impersonateClaudeCode}
onImpersonateClaudeCodeChange={setImpersonateClaudeCode}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={setMaxOutputTokens}
codexChatReasoning={codexChatReasoning}
onCodexChatReasoningChange={setCodexChatReasoning}
promptCacheRouting={promptCacheRouting}
onPromptCacheRoutingChange={setPromptCacheRouting}
speedTestEndpoints={speedTestEndpoints}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
localProxyHeadersOverride={headersOverride}
onLocalProxyHeadersOverrideChange={setHeadersOverride}
localProxyBodyOverride={bodyOverride}
onLocalProxyBodyOverrideChange={setBodyOverride}
/>
<div className="space-y-2">
<FormLabel htmlFor="grokbuild-config-toml">
{t("grokBuild.rawConfig", { defaultValue: "config.toml" })}
</FormLabel>
<JsonEditor
value={rawConfig}
onChange={handleRawConfigChange}
placeholder=""
darkMode={isDarkMode}
rows={12}
showValidation={false}
language="javascript"
/>
{rawConfigError && (
<p className="text-xs text-destructive">
{t("grokBuild.invalidToml", {
error: rawConfigError,
defaultValue: `Invalid config.toml: ${rawConfigError}`,
})}
</p>
)}
</div>
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="hidden">
<FormControl>
<Input type="hidden" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showButtons && (
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{submitLabel}
</Button>
</div>
)}
</form>
</Form>
);
}
+153 -44
View File
@@ -12,7 +12,12 @@ import {
buildLocalProxyRequestOverrides,
formatRequestOverrideObject,
} from "@/lib/requestOverrides";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import {
providersApi,
settingsApi,
type AppId,
type ManagedAuthProvider,
} from "@/lib/api";
import { useDarkMode } from "@/hooks/useDarkMode";
import type {
ProviderCategory,
@@ -77,6 +82,7 @@ import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { ClaudeDesktopProviderForm } from "./ClaudeDesktopProviderForm";
import { GrokBuildProviderForm } from "./GrokBuildProviderForm";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
@@ -133,6 +139,16 @@ type PresetEntry = {
| HermesProviderPreset;
};
function getPresetProviderType(
preset: PresetEntry["preset"] | null | undefined,
): "github_copilot" | "codex_oauth" | undefined {
if (!preset || !("providerType" in preset)) return undefined;
return preset.providerType === "github_copilot" ||
preset.providerType === "codex_oauth"
? preset.providerType
: undefined;
}
export const normalizeCodexCatalogModelsForSave = (
models: CodexCatalogModel[],
): CodexCatalogModel[] => {
@@ -224,6 +240,7 @@ export interface ProviderFormProps {
onCancel: () => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
onSubmittingChange?: (isSubmitting: boolean) => void;
initialData?: {
name?: string;
@@ -243,6 +260,9 @@ export function ProviderForm(props: ProviderFormProps) {
if (props.appId === "claude-desktop") {
return <ClaudeDesktopProviderForm {...props} />;
}
if (props.appId === "grokbuild") {
return <GrokBuildProviderForm {...props} />;
}
return <ProviderFormFull {...props} />;
}
@@ -255,6 +275,7 @@ function ProviderFormFull({
onCancel,
onUniversalPresetSelect,
onManageUniversalProviders,
onManageAuthAccounts,
onSubmittingChange,
initialData,
showButtons = true,
@@ -357,6 +378,13 @@ function ProviderFormFull({
initialData?.meta?.pricingModelSource,
),
});
setSelectedGitHubAccountId(
resolveManagedAccountId(initialData?.meta, "github_copilot"),
);
setSelectedCodexAccountId(
resolveManagedAccountId(initialData?.meta, "codex_oauth"),
);
setCodexFastMode(initialData?.meta?.codexFastMode ?? false);
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
setPromptCacheRouting(initialData?.meta?.promptCacheRouting ?? "auto");
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
@@ -510,10 +538,18 @@ function ProviderFormFull({
);
// Copilot OAuth 认证状态(仅 Claude 应用需要)
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
const {
isAuthenticated: isCopilotAuthenticated,
isStatusSuccess: isCopilotStatusSuccess,
isStatusError: isCopilotStatusError,
} = useCopilotAuth();
// Codex OAuth 认证状态(ChatGPT Plus/Pro 反代)
const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth();
const {
isAuthenticated: isCodexOauthAuthenticated,
isStatusSuccess: isCodexOauthStatusSuccess,
isStatusError: isCodexOauthStatusError,
} = useCodexOauth();
// 选中的 GitHub 账号 ID(多账号支持)
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
@@ -700,6 +736,40 @@ function ProviderFormFull({
}));
}, [appId]);
const selectedPresetEntry = useMemo(
() =>
selectedPresetId && selectedPresetId !== "custom"
? (presetEntries.find((entry) => entry.id === selectedPresetId) ?? null)
: null,
[presetEntries, selectedPresetId],
);
const selectedPresetProviderType = getPresetProviderType(
selectedPresetEntry?.preset,
);
const initialProviderType = initialData?.meta?.providerType;
const isCopilotProvider =
appId === "claude" &&
(selectedPresetProviderType === "github_copilot" ||
initialProviderType === "github_copilot" ||
baseUrl.includes("githubcopilot.com"));
const isClaudeCodexOauthProvider =
appId === "claude" &&
(selectedPresetProviderType === "codex_oauth" ||
initialProviderType === "codex_oauth");
const isCodexOfficialProvider =
appId === "codex" &&
(category === "official" ||
(selectedPresetProviderType === "codex_oauth" &&
selectedPresetEntry?.preset.category === "official"));
const isCodexOfficialManagedOauthBound =
isCodexOfficialProvider && Boolean(selectedCodexAccountId);
const wasCodexOfficialManagedOauthBound =
appId === "codex" &&
initialData?.category === "official" &&
Boolean(resolveManagedAccountId(initialData?.meta, "codex_oauth"));
const requiresCodexOauthLogin =
isClaudeCodexOauthProvider || isCodexOfficialManagedOauthBound;
const {
templateValues,
templateValueEntries,
@@ -1135,13 +1205,22 @@ function ProviderFormFull({
}
// OAuth 未登录:B 类(token 根本不存在,保存了也没法建立)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
if (isCopilotProvider && isCopilotStatusError) {
toast.error(
t("copilot.statusLoadFailed", {
defaultValue: "无法加载 GitHub Copilot 账号状态,请重试。",
}),
);
return;
}
if (isCopilotProvider && !isCopilotStatusSuccess) {
toast.error(
t("copilot.statusLoading", {
defaultValue: "正在加载 GitHub Copilot 账号状态,请稍后再试。",
}),
);
return;
}
if (isCopilotProvider && !isCopilotAuthenticated) {
toast.error(
t("copilot.loginRequired", {
@@ -1150,7 +1229,23 @@ function ProviderFormFull({
);
return;
}
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
if (requiresCodexOauthLogin && isCodexOauthStatusError) {
toast.error(
t("codexOauth.statusLoadFailed", {
defaultValue: "无法加载 ChatGPT 账号状态,请重试。",
}),
);
return;
}
if (requiresCodexOauthLogin && !isCodexOauthStatusSuccess) {
toast.error(
t("codexOauth.statusLoading", {
defaultValue: "正在加载 ChatGPT 账号状态,请稍后再试。",
}),
);
return;
}
if (requiresCodexOauthLogin && !isCodexOauthAuthenticated) {
toast.error(
t("codexOauth.loginRequired", {
defaultValue: "请先登录 ChatGPT 账号",
@@ -1194,14 +1289,18 @@ function ProviderFormFull({
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
if (category !== "official" && category !== "cloud_provider") {
if (appId === "claude") {
if (!isCodexOauthProvider && !baseUrl.trim()) {
if (!isClaudeCodexOauthProvider && !baseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
if (
!isCopilotProvider &&
!isClaudeCodexOauthProvider &&
!apiKey.trim()
) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
@@ -1266,20 +1365,17 @@ function ProviderFormFull({
return;
}
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
let settingsConfig: string;
if (appId === "codex") {
try {
const authJson = JSON.parse(codexAuth);
const shouldStripManagedCodexAuth =
category === "official" &&
(isCodexOfficialManagedOauthBound ||
wasCodexOfficialManagedOauthBound);
const authJson = shouldStripManagedCodexAuth
? {}
: JSON.parse(codexAuth);
let normalizedCodexConfig =
category !== "official" && (codexConfig ?? "").trim()
? setCodexWireApi(codexConfig ?? "", "responses")
@@ -1449,9 +1545,11 @@ function ProviderFormFull({
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType =
templatePreset?.providerType || initialData?.meta?.providerType;
const providerType = isCopilotProvider
? "github_copilot"
: isClaudeCodexOauthProvider || isCodexOfficialManagedOauthBound
? "codex_oauth"
: undefined;
const nextMeta: ProviderMeta = {
...(baseMeta ?? {}),
@@ -1473,19 +1571,25 @@ function ProviderFormFull({
authProvider: "github_copilot",
accountId: selectedGitHubAccountId ?? undefined,
}
: isCodexOauthProvider
: isClaudeCodexOauthProvider
? {
source: "managed_account",
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
: isCodexOfficialManagedOauthBound
? {
source: "managed_account",
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
// GitHub Copilot 多账号:保存关联的账号 ID
githubAccountId:
isCopilotProvider && selectedGitHubAccountId
? selectedGitHubAccountId
: undefined,
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
codexFastMode: isClaudeCodexOauthProvider ? codexFastMode : undefined,
codexChatReasoning:
appId === "codex" &&
category !== "official" &&
@@ -1553,9 +1657,18 @@ function ProviderFormFull({
: undefined,
};
if (!isCodexOauthProvider && "codexFastMode" in nextMeta) {
if (!isClaudeCodexOauthProvider && "codexFastMode" in nextMeta) {
delete nextMeta.codexFastMode;
}
if (!providerType && "providerType" in nextMeta) {
delete nextMeta.providerType;
}
if (!nextMeta.authBinding && "authBinding" in nextMeta) {
delete nextMeta.authBinding;
}
if (!nextMeta.githubAccountId && "githubAccountId" in nextMeta) {
delete nextMeta.githubAccountId;
}
payload.meta = nextMeta;
@@ -2096,26 +2209,17 @@ function ProviderFormFull({
websiteUrl={claudeWebsiteUrl}
isPartner={isClaudePartner}
partnerPromotionKey={claudePartnerPromotionKey}
isCopilotPreset={
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com")
}
isCodexOauthPreset={
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
}
isCopilotPreset={isCopilotProvider}
isCodexOauthPreset={isClaudeCodexOauthProvider}
usesOAuth={
templatePreset?.requiresOAuth === true ||
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com") ||
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
isCopilotProvider ||
isClaudeCodexOauthProvider
}
isCopilotAuthenticated={isCopilotAuthenticated}
selectedGitHubAccountId={selectedGitHubAccountId}
onGitHubAccountSelect={setSelectedGitHubAccountId}
onManageAuthAccounts={onManageAuthAccounts}
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
@@ -2174,6 +2278,11 @@ function ProviderFormFull({
websiteUrl={codexWebsiteUrl}
isPartner={isCodexPartner}
partnerPromotionKey={codexPartnerPromotionKey}
isCodexOauthPreset={isCodexOfficialProvider}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
onManageAuthAccounts={onManageAuthAccounts}
codexOauthNoneOptionLabel={t("codexOauth.noneOptionLabel")}
shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl}
onBaseUrlChange={handleCodexBaseUrlChange}

Some files were not shown because too many files have changed in this diff Show More