mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 14:35:22 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc180a3d9d | |||
| c7a2bff78b | |||
| d52ab6c5f4 | |||
| 0f3991efc3 |
@@ -55,11 +55,7 @@ jobs:
|
||||
|
||||
backend:
|
||||
name: Backend Checks
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, windows-latest, macos-latest]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -70,7 +66,6 @@ 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 \
|
||||
@@ -92,7 +87,6 @@ jobs:
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Create frontend dist placeholder
|
||||
shell: bash
|
||||
run: mkdir -p dist
|
||||
|
||||
- name: Check Rust formatting
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
|
||||
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -69,11 +69,6 @@ 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 full‑modal 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, long‑task 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, end‑to‑end 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>
|
||||
@@ -174,6 +169,11 @@ 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,24 +184,19 @@ 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, 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.
|
||||
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.
|
||||
|
||||
**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, Eight Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes from a single interface
|
||||
- **One App, Seven Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, 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, Grok Build, OpenCode, and Hermes with bidirectional sync
|
||||
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, 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
|
||||
@@ -219,18 +214,18 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
|
||||
|
||||
### Provider Management
|
||||
|
||||
- **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
|
||||
- **7 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, 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, Gemini, or Grok Build, down to individual providers
|
||||
- **App-level takeover** — Independently proxy Claude, Codex, or Gemini, down to individual providers
|
||||
|
||||
### MCP, Prompts & Skills
|
||||
|
||||
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync and Deep Link import
|
||||
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, 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
|
||||
|
||||
@@ -254,7 +249,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 eight tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+13
-18
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
|
||||
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -69,11 +69,6 @@ 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>
|
||||
@@ -174,6 +169,11 @@ 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,24 +184,19 @@ 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, 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.
|
||||
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.
|
||||
|
||||
**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, acht Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
|
||||
- **Eine App, sieben Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, 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, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung
|
||||
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, 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
|
||||
@@ -219,18 +214,18 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
|
||||
|
||||
### Anbieterverwaltung
|
||||
|
||||
- **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
|
||||
- **7 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, 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, Gemini oder Grok Build unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
|
||||
- **Übernahme auf App-Ebene** — Claude, Codex oder Gemini 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, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
|
||||
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, 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
|
||||
|
||||
@@ -254,7 +249,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 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.
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+13
-18
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -69,11 +69,6 @@ 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>
|
||||
@@ -174,6 +169,11 @@ 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,24 +184,19 @@ 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、Grok Build、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
|
||||
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
|
||||
|
||||
**CC Switch** は、対応する AI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。
|
||||
|
||||
- **1 つのアプリで 8 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
|
||||
- **1 つのアプリで 7 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
|
||||
- **手動編集は不要** -- AWS Bedrock、NVIDIA NIM、コミュニティリレーなど 50 以上のプロバイダプリセットを内蔵。選んで切り替えるだけ
|
||||
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
|
||||
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
|
||||
- **システムトレイでクイック切り替え** -- トレイメニューから即座にプロバイダを切り替え。アプリを開く必要なし
|
||||
- **クラウド同期** -- Dropbox、OneDrive、iCloud、または WebDAV サーバー経由でデバイス間のプロバイダデータを同期
|
||||
- **クロスプラットフォーム** -- Tauri 2 で構築された Windows、macOS、Linux 対応のネイティブデスクトップアプリ
|
||||
@@ -219,18 +214,18 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
- **8 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
|
||||
- **7 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
|
||||
- **ユニバーサルプロバイダ** -- 1 つの設定を Claude Code、Codex、Gemini CLI に同期
|
||||
- ワンクリック切り替え、システムトレイクイックアクセス、ドラッグ&ドロップ並び替え、インポート/エクスポート
|
||||
|
||||
### プロキシ & フェイルオーバー
|
||||
|
||||
- **ローカルプロキシのホットスイッチ** -- フォーマット変換、自動フェイルオーバー、サーキットブレーカー、プロバイダヘルスモニタリング、リクエストレクティファイア
|
||||
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini、Grok Build を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
|
||||
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
|
||||
|
||||
### MCP、Prompts & Skills
|
||||
|
||||
- **統一 MCP パネル** -- Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
|
||||
- **統一 MCP パネル** -- Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
|
||||
- **Prompts** -- Markdown エディタ、クロスアプリ同期(CLAUDE.md / AGENTS.md / GEMINI.md)、バックフィル保護
|
||||
- **Skills** -- GitHub リポジトリまたは ZIP ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応
|
||||
|
||||
@@ -254,7 +249,7 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
<details>
|
||||
<summary><strong>CC Switch はどの AI ツールに対応していますか?</strong></summary>
|
||||
|
||||
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw**、**Hermes** の 8 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
|
||||
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** の 7 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+13
-18
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -69,11 +69,6 @@ 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>
|
||||
@@ -175,6 +170,11 @@ 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,24 +185,19 @@ 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、Grok Build、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
|
||||
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
|
||||
|
||||
**CC Switch** 为你提供一个桌面应用来管理所有支持的 AI 工具。无需手动编辑配置文件,你将获得一个可视化界面,一键将供应商导入应用,一键在不同的供应商之间进行切换,内置 50+ 供应商预设、统一的 MCP, SKILLS 管理以及系统托盘即时切换功能——所有操作都基于可靠的 SQLite 数据库和原子写入机制,保护你的配置不被损坏。
|
||||
|
||||
- **一个应用,八个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes
|
||||
- **一个应用,七个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes
|
||||
- **告别手动编辑** — 50+ 供应商预设,包括 AWS Bedrock、NVIDIA NIM 和社区中转服务;一键即可切换
|
||||
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
|
||||
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
|
||||
- **系统托盘快速切换** — 从托盘菜单即时切换供应商,无需打开完整应用
|
||||
- **云同步** — 通过 Dropbox、OneDrive、iCloud 或 WebDAV 服务器在不同设备之间同步供应商数据
|
||||
- **跨平台** — 基于 Tauri 2 构建的原生桌面应用,支持 Windows、macOS 和 Linux
|
||||
@@ -220,18 +215,18 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
|
||||
### 供应商管理
|
||||
|
||||
- **8 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
|
||||
- **7 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
|
||||
- **通用供应商** — 一份配置同步到 Claude Code、Codex 和 Gemini CLI
|
||||
- 一键切换、系统托盘快速访问、拖拽排序、导入导出
|
||||
|
||||
### 代理与故障转移
|
||||
|
||||
- **本地代理热切换** — 格式转换、自动故障转移、熔断器、供应商健康监控和整流器
|
||||
- **应用级代理接管** — 独立为 Claude、Codex、Gemini 或 Grok Build 配置代理,具体到单个供应商
|
||||
- **应用级代理接管** — 独立为 Claude、Codex 或 Gemini 配置代理,具体到单个供应商
|
||||
|
||||
### MCP、Prompts 与 Skills
|
||||
|
||||
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
|
||||
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
|
||||
- **Prompts** — Markdown 编辑器,跨应用同步(CLAUDE.md / AGENTS.md / GEMINI.md),回填保护
|
||||
- **Skills** — 从 GitHub 仓库或 ZIP 文件一键安装,自定义仓库管理,支持软连接和文件复制
|
||||
|
||||
@@ -255,7 +250,7 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
<details>
|
||||
<summary><strong>CC Switch 支持哪些 AI 工具?</strong></summary>
|
||||
|
||||
CC Switch 支持八个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
|
||||
CC Switch 支持七个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 861 KiB |
Generated
-10
@@ -799,7 +799,6 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
@@ -5439,15 +5438,6 @@ 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"
|
||||
|
||||
@@ -81,7 +81,6 @@ 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"
|
||||
|
||||
@@ -14,8 +14,6 @@ pub struct McpApps {
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
@@ -28,7 +26,6 @@ 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,
|
||||
@@ -42,7 +39,6 @@ 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,
|
||||
@@ -62,9 +58,6 @@ impl McpApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.grokbuild {
|
||||
apps.push(AppType::GrokBuild);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
@@ -76,12 +69,7 @@ impl McpApps {
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude
|
||||
&& !self.codex
|
||||
&& !self.gemini
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +83,6 @@ pub struct SkillApps {
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
@@ -109,7 +95,6 @@ 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
|
||||
@@ -123,7 +108,6 @@ 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
|
||||
@@ -143,9 +127,6 @@ impl SkillApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.grokbuild {
|
||||
apps.push(AppType::GrokBuild);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
@@ -157,12 +138,7 @@ impl SkillApps {
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude
|
||||
&& !self.codex
|
||||
&& !self.gemini
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
@@ -295,8 +271,6 @@ 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,
|
||||
@@ -318,7 +292,6 @@ 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(),
|
||||
@@ -350,8 +323,6 @@ pub struct PromptRoot {
|
||||
#[serde(default)]
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub grokbuild: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub openclaw: PromptConfig,
|
||||
@@ -377,7 +348,6 @@ pub enum AppType {
|
||||
ClaudeDesktop,
|
||||
Codex,
|
||||
Gemini,
|
||||
GrokBuild,
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
Hermes,
|
||||
@@ -390,7 +360,6 @@ impl AppType {
|
||||
AppType::ClaudeDesktop => "claude-desktop",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::GrokBuild => "grokbuild",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
AppType::Hermes => "hermes",
|
||||
@@ -415,7 +384,6 @@ impl AppType {
|
||||
AppType::ClaudeDesktop,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::GrokBuild,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
@@ -434,14 +402,13 @@ 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, grokbuild, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -477,7 +444,6 @@ 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(),
|
||||
@@ -491,7 +457,6 @@ 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,
|
||||
@@ -535,7 +500,6 @@ 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());
|
||||
@@ -698,7 +662,6 @@ 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,
|
||||
@@ -712,7 +675,6 @@ 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,
|
||||
@@ -729,7 +691,6 @@ 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)?;
|
||||
@@ -753,7 +714,6 @@ 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()
|
||||
@@ -768,7 +728,6 @@ impl MultiAppConfig {
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::GrokBuild,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
@@ -842,7 +801,6 @@ 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,
|
||||
@@ -885,7 +843,6 @@ 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
|
||||
@@ -1176,30 +1133,6 @@ 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() {
|
||||
|
||||
@@ -7,9 +7,7 @@ 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;
|
||||
@@ -103,174 +101,6 @@ 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.
|
||||
///
|
||||
@@ -338,288 +168,6 @@ 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 与最近一次记录的 marker(account_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(¤t_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(¤t_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")
|
||||
@@ -2673,38 +2221,6 @@ 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"
|
||||
|
||||
@@ -2137,8 +2137,7 @@ 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);
|
||||
// TOML 字面量字符串(单引号):Windows 路径含反斜杠,basic string 会解析失败。
|
||||
let config_text = format!("sqlite_home = '{}'\n", config_sqlite_home.display());
|
||||
let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display());
|
||||
|
||||
let paths = codex_state_db_paths(&codex_dir, &config_text);
|
||||
|
||||
|
||||
@@ -83,9 +83,7 @@ mod tests {
|
||||
fn includes_config_sqlite_home() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sqlite_home = temp.path().join("sqlite-home");
|
||||
// 用 TOML 字面量字符串(单引号)承载路径:Windows 路径含反斜杠,basic string(双引号)
|
||||
// 会把 `\U`/`\s` 等当作非法转义导致解析失败。
|
||||
let config_text = format!("sqlite_home = '{}'\n", sqlite_home.display());
|
||||
let config_text = format!("sqlite_home = \"{}\"\n", sqlite_home.display());
|
||||
|
||||
let paths = codex_state_db_paths(temp.path(), &config_text);
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ 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)]
|
||||
@@ -57,7 +55,6 @@ 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,
|
||||
@@ -99,7 +96,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;
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
@@ -137,7 +134,7 @@ pub async fn auth_poll_for_account(
|
||||
}
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = &codex_state.0;
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
@@ -172,7 +169,7 @@ pub async fn auth_list_accounts(
|
||||
.collect())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = &codex_state.0;
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
@@ -212,7 +209,7 @@ pub async fn auth_get_status(
|
||||
})
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = &codex_state.0;
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
@@ -250,7 +247,7 @@ pub async fn auth_remove_account(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = &codex_state.0;
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
@@ -277,7 +274,7 @@ pub async fn auth_set_default_account(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = &codex_state.0;
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
@@ -300,7 +297,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;
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
|
||||
@@ -10,13 +10,10 @@ 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 认证状态
|
||||
///
|
||||
/// `CodexOAuthManager` 内部已使用细粒度锁且所有方法均为 `&self`,因此这里
|
||||
/// 直接持有 `Arc`,不再包一层 `RwLock`——避免任一命令持有粗粒度锁跨网络刷新
|
||||
/// 时阻塞其他命令(切换 / 认证中心操作 / token 读取)。
|
||||
pub struct CodexOAuthState(pub Arc<CodexOAuthManager>);
|
||||
pub struct CodexOAuthState(pub Arc<RwLock<CodexOAuthManager>>);
|
||||
|
||||
/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度
|
||||
///
|
||||
@@ -29,7 +26,7 @@ pub async fn get_codex_oauth_quota(
|
||||
account_id: Option<String>,
|
||||
state: State<'_, CodexOAuthState>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
let manager = &state.0;
|
||||
let manager = state.0.read().await;
|
||||
|
||||
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
|
||||
let resolved = match account_id {
|
||||
@@ -72,7 +69,7 @@ pub async fn get_codex_oauth_models(
|
||||
account_id: Option<String>,
|
||||
state: State<'_, CodexOAuthState>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
let manager = &state.0;
|
||||
let manager = state.0.read().await;
|
||||
let resolved = match account_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
|
||||
@@ -99,15 +99,6 @@ 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();
|
||||
@@ -152,7 +143,6 @@ 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(),
|
||||
@@ -170,7 +160,6 @@ 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(),
|
||||
|
||||
+22
-107
@@ -111,8 +111,8 @@ pub struct ToolVersion {
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
const VALID_TOOLS: [&str; 7] = [
|
||||
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
|
||||
const VALID_TOOLS: [&str; 6] = [
|
||||
"claude", "codex", "gemini", "opencode", "openclaw", "hermes",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@@ -424,7 +424,6 @@ 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",
|
||||
@@ -493,7 +492,6 @@ 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,
|
||||
@@ -640,7 +638,7 @@ fn build_tool_action_line(
|
||||
// (npm/pnpm)或 .exe(volta),静态命令头部是 `npm`(也是 .cmd)、`py` 等——
|
||||
// 全部加 `call ` 前缀,风格统一且语义正确。含空格的头部已被 `win_quote_path_for_batch`
|
||||
// 加上双引号,call 对带引号的路径解析正常。
|
||||
Ok(format!("call {command}"))
|
||||
return Ok(format!("call {command}"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -764,7 +762,6 @@ 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
|
||||
@@ -1072,8 +1069,6 @@ 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"
|
||||
@@ -1082,7 +1077,6 @@ 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('/')
|
||||
@@ -1106,13 +1100,11 @@ 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()
|
||||
@@ -1121,7 +1113,6 @@ 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);
|
||||
|
||||
@@ -1141,7 +1132,6 @@ 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
|
||||
@@ -1162,7 +1152,6 @@ 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",
|
||||
@@ -1171,7 +1160,6 @@ 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();
|
||||
@@ -1948,7 +1936,6 @@ 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,
|
||||
@@ -2261,8 +2248,7 @@ 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 / Grok Build) → 锚定到
|
||||
/// "那处 bin 目录的 npm"。
|
||||
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 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();
|
||||
@@ -2558,7 +2544,6 @@ 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(),
|
||||
@@ -2746,7 +2731,7 @@ fn launch_terminal_with_env(
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
|
||||
Ok(())
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
@@ -3267,7 +3252,6 @@ del \"%~f0\" >nul 2>&1
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
@@ -3666,35 +3650,6 @@ 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;
|
||||
@@ -3883,8 +3838,11 @@ 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());
|
||||
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
|
||||
let expected = format!("{} install @openai/codex", expect_quoted_path(&volta_full));
|
||||
let expected = format!(
|
||||
"{} update || call {} install @openai/codex",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&volta_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
@@ -3896,9 +3854,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!(
|
||||
"{} add -g @openai/codex@latest",
|
||||
"{} update || call {} add -g @openai/codex@latest",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&pnpm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
@@ -3930,21 +3888,9 @@ 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!(
|
||||
"{} 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",
|
||||
"{} update || call {} i -g @openai/codex@latest",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
@@ -3952,11 +3898,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
|
||||
// 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);
|
||||
// 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);
|
||||
let expected = format!("{} update", expect_quoted_path(&bin_path));
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
@@ -4032,9 +3977,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!(
|
||||
"{} i -g @openai/codex@latest",
|
||||
"{} update || call {} i -g @openai/codex@latest",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
@@ -4056,10 +4001,9 @@ 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 {} i -g @openai/codex@latest",
|
||||
"call {} update || call {} i -g @openai/codex@latest",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(batch_line, expected);
|
||||
@@ -4270,9 +4214,6 @@ 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]
|
||||
@@ -4433,21 +4374,6 @@ 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`,
|
||||
@@ -4898,12 +4824,6 @@ 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");
|
||||
@@ -4926,11 +4846,6 @@ 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"
|
||||
|
||||
@@ -23,7 +23,6 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
|
||||
if takeover.claude
|
||||
|| takeover.codex
|
||||
|| takeover.gemini
|
||||
|| takeover.grokbuild
|
||||
|| takeover.opencode
|
||||
|| takeover.openclaw
|
||||
{
|
||||
|
||||
@@ -247,7 +247,7 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
|
||||
"Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。"
|
||||
)
|
||||
})?;
|
||||
Ok(true)
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
|
||||
@@ -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_grokbuild, enabled_opencode, enabled_hermes
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -30,9 +30,8 @@ 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_grokbuild: bool = row.get(10)?;
|
||||
let enabled_opencode: bool = row.get(11)?;
|
||||
let enabled_hermes: bool = row.get(12)?;
|
||||
let enabled_opencode: bool = row.get(10)?;
|
||||
let enabled_hermes: bool = row.get(11)?;
|
||||
|
||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
@@ -47,7 +46,6 @@ impl Database {
|
||||
claude: enabled_claude,
|
||||
codex: enabled_codex,
|
||||
gemini: enabled_gemini,
|
||||
grokbuild: enabled_grokbuild,
|
||||
opencode: enabled_opencode,
|
||||
hermes: enabled_hermes,
|
||||
},
|
||||
@@ -74,8 +72,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_grokbuild, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
@@ -90,7 +88,6 @@ impl Database {
|
||||
server.apps.claude,
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
server.apps.grokbuild,
|
||||
server.apps.opencode,
|
||||
server.apps.hermes,
|
||||
],
|
||||
|
||||
@@ -328,7 +328,6 @@ 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), // 默认值
|
||||
};
|
||||
|
||||
@@ -399,18 +398,6 @@ 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, 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,13 +43,12 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
},
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -68,8 +67,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_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -88,13 +87,12 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
},
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -111,9 +109,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_grokbuild, enabled_opencode, enabled_hermes,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, 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, ?17)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -126,7 +124,6 @@ impl Database {
|
||||
skill.apps.claude,
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.apps.grokbuild,
|
||||
skill.apps.opencode,
|
||||
skill.apps.hermes,
|
||||
skill.installed_at,
|
||||
@@ -160,8 +157,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_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],
|
||||
"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],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
@@ -235,30 +232,32 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化默认的 Skill 仓库(启动时调用,每个数据库仅执行一次)
|
||||
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库)
|
||||
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
||||
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 existing = self.get_skill_repos()?;
|
||||
let existing_keys: std::collections::HashSet<(String, String)> = existing
|
||||
.iter()
|
||||
.map(|r| (r.owner.clone(), r.name.clone()))
|
||||
.collect();
|
||||
|
||||
// 获取默认仓库列表
|
||||
let default_store = crate::services::skill::SkillStore::default();
|
||||
let mut count = 0;
|
||||
|
||||
// 仅插入缺失的默认仓库
|
||||
for repo in &default_store.repos {
|
||||
self.save_skill_repo(repo)?;
|
||||
count += 1;
|
||||
log::info!("初始化默认 Skill 仓库: {}/{}", repo.owner, repo.name);
|
||||
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.set_setting(INITIALIZED_KEY, "true")?;
|
||||
if count > 0 {
|
||||
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 15;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 13;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -65,8 +65,7 @@ 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_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
@@ -94,7 +93,6 @@ 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,
|
||||
@@ -124,7 +122,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','grokbuild')),
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
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,
|
||||
@@ -171,15 +169,6 @@ 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 表
|
||||
@@ -496,16 +485,6 @@ 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 到 v15(Skills/MCP 添加 Grok Build 支持)");
|
||||
Self::migrate_v14_to_v15(conn)?;
|
||||
Self::set_user_version(conn, 15)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -819,25 +798,12 @@ 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','grokbuild')),
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
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,
|
||||
@@ -848,7 +814,6 @@ 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'))
|
||||
)", [])?;
|
||||
|
||||
@@ -1389,127 +1354,6 @@ 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(©_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 返回的模型名称标准化后一致
|
||||
@@ -2922,7 +2766,7 @@ mod tests {
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert_eq!(Database::get_user_version(&conn)?, 13);
|
||||
assert!(Database::has_column(
|
||||
&conn,
|
||||
"proxy_request_logs",
|
||||
@@ -2943,82 +2787,4 @@ 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,38 +151,6 @@ 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");
|
||||
@@ -549,7 +517,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, 4, "per-app proxy_config should have 4 rows");
|
||||
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
|
||||
|
||||
// 新结构下应能按 app_type 查询
|
||||
let _: i32 = conn
|
||||
@@ -689,7 +657,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, 4);
|
||||
assert_eq!(proxy_rows, 3);
|
||||
|
||||
// model_pricing 应具备默认数据(迁移时会 seed)
|
||||
let pricing_rows: i64 = conn
|
||||
|
||||
@@ -101,7 +101,17 @@ 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 merged_apps = merge_mcp_apps(&existing.apps, &target_apps);
|
||||
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;
|
||||
}
|
||||
|
||||
McpServer {
|
||||
id: existing.id.clone(),
|
||||
@@ -156,7 +166,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
};
|
||||
@@ -166,7 +175,6 @@ 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
|
||||
@@ -189,40 +197,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', '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" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -262,17 +262,10 @@ fn parse_mcp_deeplink(
|
||||
let trimmed = app.trim();
|
||||
if !matches!(
|
||||
trimmed,
|
||||
"claude"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "grok"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes"
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,6 @@ 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),
|
||||
@@ -445,36 +444,6 @@ 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);
|
||||
@@ -631,7 +600,6 @@ 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)?;
|
||||
@@ -806,56 +774,6 @@ 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
|
||||
|
||||
@@ -87,39 +87,6 @@ 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";
|
||||
@@ -533,38 +500,6 @@ 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"}}"#;
|
||||
@@ -774,11 +709,6 @@ 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"));
|
||||
}
|
||||
@@ -801,18 +731,6 @@ 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"}}}"#;
|
||||
@@ -829,19 +747,6 @@ 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";
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-54
@@ -14,7 +14,6 @@ mod deeplink;
|
||||
mod error;
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod grok_config;
|
||||
pub mod hermes_config;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
@@ -48,11 +47,10 @@ 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, 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,
|
||||
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,
|
||||
};
|
||||
pub use prompt::Prompt;
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
@@ -785,14 +783,6 @@ 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");
|
||||
@@ -818,7 +808,6 @@ 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,
|
||||
@@ -996,11 +985,13 @@ pub fn run() {
|
||||
|
||||
// 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代)
|
||||
{
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use commands::CodexOAuthState;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
let codex_oauth_manager =
|
||||
app.state::<AppState>().codex_oauth_manager.clone();
|
||||
app.manage(CodexOAuthState(codex_oauth_manager));
|
||||
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))));
|
||||
log::info!("✓ CodexOAuthManager initialized");
|
||||
}
|
||||
|
||||
@@ -1749,25 +1740,16 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
||||
///
|
||||
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
|
||||
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if apps_to_restore.is_empty() {
|
||||
log::debug!("启动时无需恢复代理状态");
|
||||
@@ -2085,8 +2067,7 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_exit_request, enabled_proxy_apps_on_startup, ExitRequestAction};
|
||||
use crate::database::Database;
|
||||
use super::{classify_exit_request, ExitRequestAction};
|
||||
|
||||
#[test]
|
||||
fn no_code_keeps_app_alive_in_tray() {
|
||||
@@ -2112,21 +2093,4 @@ 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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -235,7 +235,6 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -557,7 +556,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. 其他未知字段使用通用转换器尝试转换
|
||||
pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
use toml_edit::{Array, Item, Table};
|
||||
|
||||
let mut t = Table::new();
|
||||
|
||||
@@ -87,7 +87,6 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
//! 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,6 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: true,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod grokbuild;
|
||||
mod hermes;
|
||||
mod opencode;
|
||||
mod validation;
|
||||
@@ -31,9 +30,6 @@ 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,
|
||||
|
||||
@@ -258,7 +258,6 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: true,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
"app.prompts_unsupported",
|
||||
"当前应用暂不支持 Prompts",
|
||||
"This app does not support Prompts",
|
||||
"claude_desktop.prompts_unsupported",
|
||||
"Claude Desktop 暂不支持 Prompts",
|
||||
"Claude Desktop does not support Prompts",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ 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(),
|
||||
@@ -33,7 +32,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
|
||||
@@ -164,11 +164,6 @@ 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")),
|
||||
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -1141,9 +1142,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 | AppType::GrokBuild)
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
|
||||
&& 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);
|
||||
@@ -1167,13 +1168,6 @@ 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);
|
||||
@@ -1517,7 +1511,7 @@ impl RequestForwarder {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
if matches!(app_type, AppType::Codex | AppType::GrokBuild) {
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
self.apply_media_prevention(&mut request_body, provider);
|
||||
}
|
||||
|
||||
@@ -1619,7 +1613,8 @@ 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 = &codex_state.0;
|
||||
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
|
||||
codex_state.0.read().await;
|
||||
|
||||
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
|
||||
let account_id = provider
|
||||
|
||||
@@ -758,30 +758,6 @@ 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();
|
||||
@@ -798,7 +774,7 @@ async fn handle_responses_for_app(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
@@ -810,7 +786,7 @@ async fn handle_responses_for_app(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&app_type,
|
||||
&AppType::Codex,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
@@ -873,30 +849,6 @@ async fn handle_responses_for_app(
|
||||
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();
|
||||
@@ -913,7 +865,7 @@ async fn handle_responses_compact_for_app(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
@@ -925,7 +877,7 @@ async fn handle_responses_compact_for_app(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&app_type,
|
||||
&AppType::Codex,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
|
||||
@@ -250,11 +250,7 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|config| {
|
||||
crate::grok_config::extract_model_config(config)
|
||||
.map(|model| model.model)
|
||||
.or_else(|| extract_codex_model_from_toml(config))
|
||||
})
|
||||
.and_then(extract_codex_model_from_toml)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -625,9 +621,6 @@ 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)
|
||||
{
|
||||
@@ -682,9 +675,6 @@ 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('"') {
|
||||
@@ -806,37 +796,6 @@ 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,10 +340,6 @@ pub struct GitHubAccount {
|
||||
/// GitHub 域名(github.com 或 GHES 域名)
|
||||
#[serde(default = "default_github_domain")]
|
||||
pub github_domain: String,
|
||||
/// 托管账号是否需要重新登录以补全缺失的凭据。
|
||||
/// Codex:缺少持久化 id_token 的旧账号为 true;Copilot 恒为 false。
|
||||
#[serde(default)]
|
||||
pub reauth_required: bool,
|
||||
}
|
||||
|
||||
impl From<&GitHubAccountData> for GitHubAccount {
|
||||
@@ -354,7 +350,6 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,7 +543,6 @@ impl CopilotAuthManager {
|
||||
avatar_url: user.avatar_url.clone(),
|
||||
authenticated_at: now,
|
||||
github_domain,
|
||||
reauth_required: false,
|
||||
};
|
||||
|
||||
{
|
||||
@@ -1552,7 +1546,6 @@ 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,
|
||||
|
||||
@@ -192,8 +192,10 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::GrokBuild => ProviderType::Codex,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex-like type
|
||||
ProviderType::Codex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,8 +246,10 @@ 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::GrokBuild => Box::new(CodexAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ 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>,
|
||||
@@ -95,7 +94,6 @@ 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,
|
||||
@@ -349,16 +347,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(ref id) = id_delta {
|
||||
if !id.is_empty() {
|
||||
state.call_id.clone_from(id);
|
||||
}
|
||||
if let Some(id) = id_delta {
|
||||
state.call_id = id;
|
||||
}
|
||||
if let Some(ref name) = name_delta {
|
||||
if !name.is_empty() {
|
||||
@@ -375,7 +373,10 @@ impl ChatToResponsesState {
|
||||
}
|
||||
}
|
||||
|
||||
if state.added {
|
||||
if !state.added && !state.call_id.is_empty() && !state.name.is_empty() {
|
||||
should_add = true;
|
||||
pending_arguments = state.arguments.clone();
|
||||
} else if state.added {
|
||||
output_index = state.output_index;
|
||||
item_id = state.item_id.clone();
|
||||
}
|
||||
@@ -385,51 +386,26 @@ impl ChatToResponsesState {
|
||||
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(¤t_name);
|
||||
let mut events = Vec::new();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if should_add {
|
||||
let assigned = self.next_output_index();
|
||||
let Some(state) = self.tools.get_mut(&key) else {
|
||||
continue;
|
||||
let Some(state) = self.tools.get_mut(&chat_index) else {
|
||||
return events;
|
||||
};
|
||||
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(
|
||||
&state.item_id,
|
||||
&item_id,
|
||||
"in_progress",
|
||||
&state.call_id,
|
||||
&state.name,
|
||||
@@ -440,16 +416,21 @@ impl ChatToResponsesState {
|
||||
|
||||
events.push(sse::output_item_added(assigned, &item));
|
||||
|
||||
if !state.arguments.is_empty()
|
||||
&& !self.tool_context.is_custom_tool_chat_name(&state.name)
|
||||
{
|
||||
if !pending_arguments.is_empty() && !is_custom_tool {
|
||||
events.push(sse::function_call_arguments_delta(
|
||||
assigned,
|
||||
&state.item_id,
|
||||
&state.arguments,
|
||||
&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,
|
||||
));
|
||||
}
|
||||
self.next_tool_index_to_add += 1;
|
||||
}
|
||||
|
||||
events
|
||||
@@ -863,16 +844,6 @@ 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![
|
||||
@@ -945,114 +916,6 @@ 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,26 +1096,6 @@ 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;
|
||||
@@ -1130,14 +1110,6 @@ 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);
|
||||
@@ -1149,7 +1121,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": normalize_function_parameters(tool.get("parameters"))
|
||||
"parameters": tool.get("parameters").cloned().unwrap_or_else(|| json!({}))
|
||||
});
|
||||
if let Some(strict) = tool.get("strict") {
|
||||
function["strict"] = strict.clone();
|
||||
|
||||
@@ -327,12 +327,6 @@ 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",
|
||||
@@ -350,10 +344,6 @@ 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` /
|
||||
|
||||
@@ -113,7 +113,6 @@ pub struct ProxyTakeoverStatus {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub grokbuild: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ impl CostCalculator {
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
|
||||
Self::calculate_with_cache_semantics(
|
||||
usage,
|
||||
pricing,
|
||||
@@ -211,25 +211,6 @@ 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 {
|
||||
|
||||
@@ -71,12 +71,11 @@ impl<'a> UsageLogger<'a> {
|
||||
};
|
||||
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
let input_token_semantics =
|
||||
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
@@ -460,39 +459,4 @@ 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,6 @@ 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(())
|
||||
}
|
||||
|
||||
@@ -126,7 +125,6 @@ impl ConfigService {
|
||||
// Claude Desktop 3P profiles are managed by claude_desktop_config.
|
||||
}
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_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
|
||||
|
||||
@@ -37,9 +37,6 @@ 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)?;
|
||||
}
|
||||
@@ -125,13 +122,6 @@ 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(),
|
||||
@@ -172,7 +162,6 @@ 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)?;
|
||||
}
|
||||
@@ -404,32 +393,6 @@ 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 导入 MCP(v3.9.2+ 新增)
|
||||
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
|
||||
// 创建临时 MultiAppConfig 用于导入
|
||||
@@ -516,11 +479,10 @@ impl McpService {
|
||||
let mut total = 0;
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
let results: [(&str, Result<usize, AppError>); 6] = [
|
||||
let results: [(&str, Result<usize, AppError>); 5] = [
|
||||
("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)),
|
||||
];
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! 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};
|
||||
@@ -14,7 +13,6 @@ 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;
|
||||
|
||||
@@ -525,11 +523,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => false,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,11 +593,9 @@ pub(crate) fn remove_common_config_from_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,11 +650,9 @@ fn apply_common_config_to_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,31 +687,14 @@ pub(crate) fn build_effective_settings_with_common_config(
|
||||
Ok(effective_settings)
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config_for_state(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
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(
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
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,
|
||||
)?;
|
||||
let mut effective_provider = provider.clone();
|
||||
effective_provider.settings_config =
|
||||
build_effective_settings_with_common_config(db, app_type, provider)?;
|
||||
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
crate::claude_desktop_config::apply_provider(db, &effective_provider)?;
|
||||
@@ -736,132 +709,6 @@ pub(crate) fn write_live_with_common_config_for_codex_oauth_manager(
|
||||
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,
|
||||
@@ -978,16 +825,6 @@ 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;
|
||||
}
|
||||
@@ -1009,8 +846,6 @@ 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) {
|
||||
@@ -1049,40 +884,6 @@ fn restore_live_settings_for_provider_backfill(
|
||||
settings
|
||||
}
|
||||
|
||||
/// 回填(backfill)托管 Codex 官方 provider 时,剥离 live 的 `auth`。
|
||||
///
|
||||
/// 托管 provider 的存储配置**永远不应**持久化真实 OAuth token:token 由
|
||||
/// `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,
|
||||
@@ -1231,22 +1032,11 @@ 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;
|
||||
@@ -1373,7 +1163,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = write_live_with_common_config_for_state(state, app_type, provider) {
|
||||
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
@@ -1403,7 +1193,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(¤t_id) {
|
||||
write_live_with_common_config_for_state(state, app_type, provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,7 +1232,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_for_state(state, app_type, provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
|
||||
} else {
|
||||
futures::executor::block_on(
|
||||
state
|
||||
@@ -1454,7 +1244,7 @@ fn sync_current_provider_for_app_respecting_takeover(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
write_live_with_common_config_for_state(state, app_type, provider)
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration
|
||||
@@ -1577,7 +1367,6 @@ 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};
|
||||
|
||||
@@ -1646,11 +1435,6 @@ 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() {
|
||||
@@ -2141,7 +1925,6 @@ 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]
|
||||
@@ -2565,249 +2348,6 @@ 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(
|
||||
@@ -2996,32 +2536,4 @@ 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
+116
-1637
File diff suppressed because it is too large
Load Diff
@@ -374,15 +374,20 @@ fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
|
||||
|
||||
/// 获取 `~/.agents/skills/` 目录(存在时返回)
|
||||
fn get_agents_skills_dir() -> Option<PathBuf> {
|
||||
let dir = crate::config::get_home_dir().join(".agents").join("skills");
|
||||
dir.exists().then_some(dir)
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".agents").join("skills"))
|
||||
.filter(|p| p.exists())
|
||||
}
|
||||
|
||||
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
|
||||
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
|
||||
let path = crate::config::get_home_dir()
|
||||
.join(".agents")
|
||||
.join(".skill-lock.json");
|
||||
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 content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -477,7 +482,12 @@ impl SkillService {
|
||||
let dir = match location {
|
||||
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
|
||||
SkillStorageLocation::Unified => {
|
||||
crate::config::get_home_dir().join(".agents").join("skills")
|
||||
let home = dirs::home_dir().context(format_skill_error(
|
||||
"GET_HOME_DIR_FAILED",
|
||||
&[],
|
||||
Some("checkPermission"),
|
||||
))?;
|
||||
home.join(".agents").join("skills")
|
||||
}
|
||||
};
|
||||
fs::create_dir_all(&dir)?;
|
||||
@@ -511,11 +521,6 @@ 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"));
|
||||
@@ -533,17 +538,18 @@ impl SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
// 默认路径:回退到用户主目录下的标准位置。
|
||||
// 必须走 get_home_dir()(可被 CC_SWITCH_TEST_HOME 覆盖):Windows 上 dirs::home_dir()
|
||||
// 走 Known Folder API,测试无法隔离真实用户目录。
|
||||
let home = crate::config::get_home_dir();
|
||||
// 默认路径:回退到用户主目录下的标准位置
|
||||
let home = dirs::home_dir().context(format_skill_error(
|
||||
"GET_HOME_DIR_FAILED",
|
||||
&[],
|
||||
Some("checkPermission"),
|
||||
))?;
|
||||
|
||||
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"),
|
||||
@@ -1161,7 +1167,8 @@ impl SkillService {
|
||||
let new_dir = match target {
|
||||
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
|
||||
SkillStorageLocation::Unified => {
|
||||
crate::config::get_home_dir().join(".agents").join("skills")
|
||||
let home = dirs::home_dir().context("Cannot determine home directory")?;
|
||||
home.join(".agents").join("skills")
|
||||
}
|
||||
};
|
||||
fs::create_dir_all(&new_dir)?;
|
||||
@@ -3062,36 +3069,6 @@ 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");
|
||||
|
||||
@@ -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", "grokbuild"];
|
||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"];
|
||||
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
|
||||
@@ -93,7 +93,6 @@ mod tests {
|
||||
assert!(!sql.contains("."));
|
||||
assert!(sql.contains("'codex'"));
|
||||
assert!(sql.contains("'gemini'"));
|
||||
assert!(sql.contains("'grokbuild'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -113,13 +112,6 @@ 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)
|
||||
@@ -131,8 +123,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: 400; Gemini: 500; Grok Build: 450; Claude: 200 unchanged.
|
||||
assert_eq!(total, 400 + 500 + 450 + 200);
|
||||
// Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged.
|
||||
assert_eq!(total, 400 + 500 + 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@ pub mod terminal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
|
||||
use providers::{claude, codex, gemini, hermes, openclaw, opencode};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -56,14 +56,13 @@ pub struct DeleteSessionOutcome {
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| {
|
||||
let (r1, r2, r3, r4, r5, r6) = 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(),
|
||||
@@ -71,7 +70,6 @@ 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(),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -82,7 +80,6 @@ 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);
|
||||
@@ -109,7 +106,6 @@ 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}")),
|
||||
}
|
||||
@@ -169,9 +165,6 @@ 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}")),
|
||||
};
|
||||
@@ -201,7 +194,6 @@ 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}")),
|
||||
};
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod gemini;
|
||||
pub mod grokbuild;
|
||||
pub mod hermes;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
@@ -39,8 +40,6 @@ 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,
|
||||
@@ -55,7 +54,6 @@ impl Default for VisibleApps {
|
||||
claude_desktop: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: false, // 默认不显示,需用户手动启用
|
||||
@@ -71,7 +69,6 @@ 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,
|
||||
@@ -415,8 +412,6 @@ 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>,
|
||||
@@ -436,9 +431,6 @@ 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>,
|
||||
@@ -529,7 +521,6 @@ 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,
|
||||
@@ -537,7 +528,6 @@ 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,
|
||||
@@ -586,13 +576,6 @@ 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()
|
||||
@@ -677,7 +660,6 @@ 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()
|
||||
@@ -901,14 +883,6 @@ 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
|
||||
@@ -966,7 +940,6 @@ 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(),
|
||||
@@ -984,7 +957,6 @@ 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(),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::database::Database;
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::services::{ProxyService, UsageCache};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -8,24 +7,17 @@ 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 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());
|
||||
let proxy_service = ProxyService::new(db.clone());
|
||||
|
||||
Self {
|
||||
db,
|
||||
proxy_service,
|
||||
usage_cache: Arc::new(UsageCache::new()),
|
||||
codex_oauth_manager,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-117
@@ -58,41 +58,6 @@ 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 {
|
||||
@@ -153,7 +118,7 @@ pub struct TrayAppSection {
|
||||
pub const AUTO_SUFFIX: &str = "auto";
|
||||
pub const TRAY_ID: &str = "cc-switch";
|
||||
|
||||
pub const TRAY_SECTIONS: [TrayAppSection; 4] = [
|
||||
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
@@ -175,13 +140,6 @@ pub const TRAY_SECTIONS: [TrayAppSection; 4] = [
|
||||
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` 语义一致)。
|
||||
@@ -639,13 +597,7 @@ pub fn create_tray_menu(
|
||||
app_state: &AppState,
|
||||
) -> Result<Menu<tauri::Wry>, AppError> {
|
||||
let app_settings = crate::settings::get_settings();
|
||||
// 用户未显式设置语言(首次安装)时,按系统区域回退而非硬编码简体,
|
||||
// 否则繁中系统的托盘会固定显示简体直到用户手动切换一次。
|
||||
let language: &str = match app_settings.language.as_deref() {
|
||||
Some(lang) => lang,
|
||||
None => detect_system_tray_language(),
|
||||
};
|
||||
let tray_texts = TrayTexts::from_language(language);
|
||||
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||
|
||||
// Get visible apps setting, default to all visible
|
||||
let visible_apps = app_settings.visible_apps.unwrap_or_default();
|
||||
@@ -1120,8 +1072,7 @@ 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, TRAY_SECTIONS};
|
||||
use crate::app_config::AppType;
|
||||
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
|
||||
@@ -1135,71 +1086,6 @@ 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(),
|
||||
|
||||
@@ -6,14 +6,6 @@ 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)
|
||||
|
||||
@@ -748,7 +748,6 @@ command = "echo"
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -878,7 +877,6 @@ fn import_from_claude_merges_into_config() {
|
||||
claude: false, // 初始未启用
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -227,7 +227,6 @@ command = "echo"
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -372,7 +371,6 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -438,7 +436,6 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -484,7 +481,6 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -519,7 +515,6 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -653,7 +648,6 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -709,7 +703,6 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -765,7 +758,6 @@ fn explicit_default_claude_dir_keeps_default_split_mcp_path() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -822,7 +814,6 @@ fn custom_claude_dir_writes_mcp_inside_config_dir() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -902,7 +893,6 @@ fn custom_claude_dir_sync_does_not_copy_default_profile() {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1042,7 +1032,6 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1065,7 +1054,6 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -19,81 +19,6 @@ 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");
|
||||
@@ -375,7 +300,6 @@ command = "say"
|
||||
claude: false,
|
||||
codex: true, // 启用 Codex
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -164,7 +164,6 @@ command = "say"
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -935,7 +934,6 @@ fn reapply_codex_official_live_resyncs_mcp_servers() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1034,7 +1032,6 @@ fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1127,7 +1124,6 @@ fn switch_codex_projects_mcp_despite_broken_claude_json() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -1191,7 +1187,6 @@ fn sync_all_enabled_reports_broken_app_but_projects_the_rest() {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -31,7 +31,6 @@ pub fn reset_test_fs() {
|
||||
".codex",
|
||||
".cc-switch",
|
||||
".gemini",
|
||||
".grok",
|
||||
".config",
|
||||
".openclaw",
|
||||
"profiles",
|
||||
|
||||
+1
-8
@@ -127,7 +127,6 @@ const VALID_APPS: AppId[] = [
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -195,7 +194,6 @@ function App() {
|
||||
"claude-desktop": true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: true,
|
||||
@@ -206,7 +204,6 @@ 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";
|
||||
@@ -225,7 +222,6 @@ function App() {
|
||||
currentView === "sessions" &&
|
||||
sharedFeatureApp !== "claude" &&
|
||||
sharedFeatureApp !== "codex" &&
|
||||
sharedFeatureApp !== "grokbuild" &&
|
||||
sharedFeatureApp !== "opencode" &&
|
||||
sharedFeatureApp !== "openclaw" &&
|
||||
sharedFeatureApp !== "gemini" &&
|
||||
@@ -295,7 +291,6 @@ function App() {
|
||||
const hasSessionSupport =
|
||||
sharedFeatureApp === "claude" ||
|
||||
sharedFeatureApp === "codex" ||
|
||||
sharedFeatureApp === "grokbuild" ||
|
||||
sharedFeatureApp === "opencode" ||
|
||||
sharedFeatureApp === "openclaw" ||
|
||||
sharedFeatureApp === "gemini" ||
|
||||
@@ -1410,9 +1405,7 @@ function App() {
|
||||
? "openclaw"
|
||||
: activeApp === "hermes"
|
||||
? "hermes"
|
||||
: activeApp === "grokbuild"
|
||||
? "grokbuild"
|
||||
: "default"
|
||||
: "default"
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
|
||||
@@ -23,7 +23,6 @@ const ALL_APPS: AppId[] = [
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -47,7 +46,6 @@ export function AppSwitcher({
|
||||
"claude-desktop": "claude",
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
grokbuild: "grok",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
hermes: "hermes",
|
||||
@@ -57,7 +55,6 @@ export function AppSwitcher({
|
||||
"claude-desktop": "Claude Desktop",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
grokbuild: "Grok Build",
|
||||
opencode: "OpenCode",
|
||||
openclaw: "OpenClaw",
|
||||
hermes: "Hermes",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCodexOauthQuotaByAccountId } from "@/lib/query/subscription";
|
||||
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
|
||||
|
||||
interface CodexOauthAccountQuotaProps {
|
||||
/** cc-switch 自管的 ChatGPT 账号 ID */
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 → 认证中心里,单个 ChatGPT (Codex OAuth) 账号的用量展示。
|
||||
*
|
||||
* 直接按 accountId 查询 cc-switch 自管 OAuth token 的订阅额度,复用
|
||||
* `SubscriptionQuotaView` 的展开布局(进度条 + 重置倒计时 + 刷新按钮),
|
||||
* 因此与供应商卡片里的额度展示保持完全一致的观感与状态处理。
|
||||
*
|
||||
* 面板打开时拉取一次,不轮询;用户可点卡片内的刷新按钮手动更新。
|
||||
*/
|
||||
const CodexOauthAccountQuota: React.FC<CodexOauthAccountQuotaProps> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
const {
|
||||
data: quota,
|
||||
isFetching: loading,
|
||||
refetch,
|
||||
} = useCodexOauthQuotaByAccountId(accountId, {
|
||||
enabled: true,
|
||||
autoQuery: false,
|
||||
});
|
||||
|
||||
// 首次加载占位:账号头部由父组件独立渲染,这里只负责用量区。
|
||||
// 用量请求是异步的(Tauri invoke + React Query),加载期间给一个
|
||||
// 与最终额度卡片同形状(rounded-xl / border / bg-card)的转圈占位,
|
||||
// 这样账号会立刻显示、用量数据到达后原地平滑替换,不产生跳版。
|
||||
if (loading && !quota) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-center rounded-xl border border-border-default bg-card py-5 shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubscriptionQuotaView
|
||||
quota={quota}
|
||||
loading={loading}
|
||||
refetch={refetch}
|
||||
appIdForExpiredHint="codex_oauth"
|
||||
inline={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexOauthAccountQuota;
|
||||
@@ -14,7 +14,6 @@ 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";
|
||||
@@ -263,15 +262,6 @@ 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 {
|
||||
|
||||
@@ -28,25 +28,6 @@ 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
|
||||
@@ -61,10 +42,12 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
contentClassName,
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
lockBodyScroll();
|
||||
return unlockBodyScroll;
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// ESC 键关闭面板
|
||||
|
||||
@@ -42,7 +42,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
onClose,
|
||||
existingIds = [],
|
||||
defaultFormat = "json",
|
||||
defaultEnabledApps = ["claude", "codex", "gemini", "grokbuild"],
|
||||
defaultEnabledApps = ["claude", "codex", "gemini"],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||
@@ -65,22 +65,17 @@ 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,
|
||||
grokbuild: initialData.apps.grokbuild ?? false,
|
||||
};
|
||||
return { ...initialData.apps };
|
||||
}
|
||||
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"),
|
||||
@@ -571,22 +566,6 @@ 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"
|
||||
|
||||
@@ -61,7 +61,6 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
"claude-desktop": 0,
|
||||
codex: 0,
|
||||
gemini: 0,
|
||||
grokbuild: 0,
|
||||
opencode: 0,
|
||||
openclaw: 0,
|
||||
hermes: 0,
|
||||
|
||||
@@ -35,7 +35,6 @@ 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,7 +29,6 @@ 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",
|
||||
|
||||
@@ -12,7 +12,6 @@ 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";
|
||||
@@ -20,10 +19,8 @@ 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;
|
||||
@@ -51,7 +48,6 @@ export function AddProviderDialog({
|
||||
appId !== "opencode" &&
|
||||
appId !== "openclaw" &&
|
||||
appId !== "hermes" &&
|
||||
appId !== "grokbuild" &&
|
||||
appId !== "claude-desktop";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
@@ -60,21 +56,6 @@ 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) => {
|
||||
@@ -274,11 +255,6 @@ 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>
|
||||
@@ -322,9 +298,9 @@ export function AddProviderDialog({
|
||||
}
|
||||
|
||||
await onSubmit(providerData);
|
||||
closeDialog();
|
||||
onOpenChange(false);
|
||||
},
|
||||
[appId, onSubmit, closeDialog],
|
||||
[appId, onSubmit, onOpenChange],
|
||||
);
|
||||
|
||||
const footer =
|
||||
@@ -335,7 +311,7 @@ export function AddProviderDialog({
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeDialog}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
@@ -354,7 +330,7 @@ export function AddProviderDialog({
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeDialog}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
@@ -373,7 +349,7 @@ export function AddProviderDialog({
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
title={t("provider.addNewProvider")}
|
||||
onClose={handlePanelClose}
|
||||
onClose={() => onOpenChange(false)}
|
||||
footer={footer}
|
||||
contentClassName="pt-3"
|
||||
>
|
||||
@@ -396,8 +372,7 @@ export function AddProviderDialog({
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeDialog}
|
||||
onManageAuthAccounts={setAuthSettingsTarget}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmittingChange={setIsFormSubmitting}
|
||||
showButtons={false}
|
||||
/>
|
||||
@@ -413,8 +388,7 @@ export function AddProviderDialog({
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeDialog}
|
||||
onManageAuthAccounts={setAuthSettingsTarget}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmittingChange={setIsFormSubmitting}
|
||||
showButtons={false}
|
||||
/>
|
||||
@@ -428,11 +402,6 @@ export function AddProviderDialog({
|
||||
initialPreset={selectedUniversalPreset}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AuthSettingsPanel
|
||||
target={authSettingsTarget}
|
||||
onClose={() => setAuthSettingsTarget(null)}
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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,14 +8,7 @@ import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { AuthSettingsPanel } from "@/components/providers/AuthSettingsPanel";
|
||||
import {
|
||||
openclawApi,
|
||||
providersApi,
|
||||
vscodeApi,
|
||||
type AppId,
|
||||
type ManagedAuthProvider,
|
||||
} from "@/lib/api";
|
||||
import { openclawApi, providersApi, vscodeApi, type AppId } from "@/lib/api";
|
||||
|
||||
interface EditProviderDialogProps {
|
||||
open: boolean;
|
||||
@@ -39,8 +32,6 @@ 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<
|
||||
@@ -51,19 +42,6 @@ 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 () => {
|
||||
@@ -234,9 +212,9 @@ export function EditProviderDialog({
|
||||
provider: updatedProvider,
|
||||
originalId: provider.id,
|
||||
});
|
||||
closeDialog();
|
||||
onOpenChange(false);
|
||||
},
|
||||
[appId, onSubmit, closeDialog, provider],
|
||||
[appId, onSubmit, onOpenChange, provider],
|
||||
);
|
||||
|
||||
if (!provider || !initialData) {
|
||||
@@ -247,7 +225,7 @@ export function EditProviderDialog({
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
title={t("provider.editProvider")}
|
||||
onClose={handlePanelClose}
|
||||
onClose={() => onOpenChange(false)}
|
||||
footer={
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -265,17 +243,12 @@ export function EditProviderDialog({
|
||||
providerId={provider.id}
|
||||
submitLabel={t("common.save")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeDialog}
|
||||
onManageAuthAccounts={setAuthSettingsTarget}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmittingChange={setIsFormSubmitting}
|
||||
initialData={initialData}
|
||||
showButtons={false}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
/>
|
||||
<AuthSettingsPanel
|
||||
target={authSettingsTarget}
|
||||
onClose={() => setAuthSettingsTarget(null)}
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ 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;
|
||||
@@ -351,11 +350,7 @@ 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={resolveProviderIcon(
|
||||
appId,
|
||||
provider.icon,
|
||||
provider.iconColor,
|
||||
)}
|
||||
icon={provider.icon}
|
||||
name={provider.name}
|
||||
color={provider.iconColor}
|
||||
size={20}
|
||||
|
||||
@@ -68,7 +68,6 @@ import {
|
||||
type ClaudeDesktopDefaultRoute,
|
||||
} from "@/lib/api/providers";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
import type { ManagedAuthProvider } from "@/lib/api";
|
||||
|
||||
export type ClaudeDesktopProviderFormValues = ProviderFormData & {
|
||||
presetId?: string;
|
||||
@@ -103,7 +102,6 @@ export interface ClaudeDesktopProviderFormProps {
|
||||
iconColor?: string;
|
||||
};
|
||||
showButtons?: boolean;
|
||||
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
|
||||
}
|
||||
|
||||
type RouteRow = {
|
||||
@@ -256,7 +254,6 @@ export function ClaudeDesktopProviderForm({
|
||||
onSubmittingChange,
|
||||
initialData,
|
||||
showButtons = true,
|
||||
onManageAuthAccounts,
|
||||
}: ClaudeDesktopProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct";
|
||||
@@ -773,25 +770,13 @@ 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,7 +54,6 @@ import type {
|
||||
ClaudeApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import type { ManagedAuthProvider } from "@/lib/api";
|
||||
import {
|
||||
hasClaudeOneMMarker,
|
||||
setClaudeOneMMarker,
|
||||
@@ -90,8 +89,6 @@ interface ClaudeFormFieldsProps {
|
||||
selectedGitHubAccountId?: string | null;
|
||||
/** GitHub 账号选择回调(多账号支持) */
|
||||
onGitHubAccountSelect?: (accountId: string | null) => void;
|
||||
/** 打开托管账号管理入口 */
|
||||
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro)
|
||||
isCodexOauthPreset?: boolean;
|
||||
@@ -171,7 +168,6 @@ export function ClaudeFormFields({
|
||||
isCopilotAuthenticated,
|
||||
selectedGitHubAccountId,
|
||||
onGitHubAccountSelect,
|
||||
onManageAuthAccounts,
|
||||
isCodexOauthPreset,
|
||||
isCodexOauthAuthenticated,
|
||||
selectedCodexAccountId,
|
||||
@@ -608,28 +604,16 @@ 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,7 +26,6 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { CodexOAuthSection } from "./CodexOAuthSection";
|
||||
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
@@ -44,15 +43,12 @@ 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;
|
||||
@@ -62,11 +58,6 @@ 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;
|
||||
@@ -164,7 +155,6 @@ function catalogRowsMatchModels(
|
||||
}
|
||||
|
||||
export function CodexFormFields({
|
||||
appId = "codex",
|
||||
providerId,
|
||||
codexApiKey,
|
||||
onApiKeyChange,
|
||||
@@ -173,11 +163,6 @@ export function CodexFormFields({
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
isCodexOauthPreset = false,
|
||||
selectedCodexAccountId,
|
||||
onCodexAccountSelect,
|
||||
onManageAuthAccounts,
|
||||
codexOauthNoneOptionLabel,
|
||||
shouldShowSpeedTest,
|
||||
codexBaseUrl,
|
||||
onBaseUrlChange,
|
||||
@@ -445,43 +430,26 @@ 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 输入框 */}
|
||||
{!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,将自动填充到配置",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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 && (
|
||||
@@ -1039,7 +1007,7 @@ export function CodexFormFields({
|
||||
{/* 端点测速弹窗 - Codex */}
|
||||
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||
<EndpointSpeedTest
|
||||
appId={appId}
|
||||
appId="codex"
|
||||
providerId={providerId}
|
||||
value={codexBaseUrl}
|
||||
onChange={onBaseUrlChange}
|
||||
|
||||
@@ -21,25 +21,19 @@ import {
|
||||
X,
|
||||
Sparkles,
|
||||
User,
|
||||
Settings2,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useCodexOauth } from "./hooks/useCodexOauth";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
|
||||
|
||||
interface CodexOAuthSectionProps {
|
||||
className?: string;
|
||||
/** select 模式只展示账号选择和管理入口;manage 模式展示完整账号管理 */
|
||||
mode?: "manage" | "select";
|
||||
/** 是否展示每个账号的订阅额度 */
|
||||
showAccountQuota?: boolean;
|
||||
/** 当前选中的 ChatGPT 账号 ID */
|
||||
selectedAccountId?: string | null;
|
||||
/** 账号选择回调 */
|
||||
onAccountSelect?: (accountId: string | null) => void;
|
||||
/** 打开账号管理入口 */
|
||||
onManageAccounts?: () => void;
|
||||
/** 空选择项文案;默认表示使用托管认证的默认账号 */
|
||||
noneOptionLabel?: string;
|
||||
/** 是否开启 Codex FAST mode */
|
||||
fastModeEnabled?: boolean;
|
||||
/** FAST mode 切换回调 */
|
||||
@@ -54,11 +48,9 @@ interface CodexOAuthSectionProps {
|
||||
*/
|
||||
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
className,
|
||||
mode = "manage",
|
||||
showAccountQuota = false,
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
onManageAccounts,
|
||||
noneOptionLabel,
|
||||
fastModeEnabled = false,
|
||||
onFastModeChange,
|
||||
}) => {
|
||||
@@ -68,8 +60,6 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
const {
|
||||
accounts,
|
||||
defaultAccountId,
|
||||
isStatusSuccess,
|
||||
isStatusError,
|
||||
hasAnyAccount,
|
||||
pollingState,
|
||||
deviceCode,
|
||||
@@ -83,7 +73,6 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
setDefaultAccount,
|
||||
cancelAuth,
|
||||
logout,
|
||||
refetchStatus,
|
||||
} = useCodexOauth();
|
||||
|
||||
const copyUserCode = async () => {
|
||||
@@ -98,25 +87,6 @@ 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();
|
||||
@@ -126,184 +96,58 @@ 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 || ""}`}>
|
||||
{/* 认证状态标题 */}
|
||||
{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"
|
||||
<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" : ""}
|
||||
>
|
||||
<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
|
||||
? t("codexOauth.accountCount", {
|
||||
count: accounts.length,
|
||||
defaultValue: `${accounts.length} 个账号`,
|
||||
})
|
||||
: t("codexOauth.notAuthenticated", "未认证")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 账号选择器 */}
|
||||
{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>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -329,7 +173,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{/* 已登录账号列表 */}
|
||||
{mode === "manage" && isStatusSuccess && hasAnyAccount && (
|
||||
{hasAnyAccount && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("codexOauth.loggedInAccounts", "已登录账号")}
|
||||
@@ -338,75 +182,52 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
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"
|
||||
}`}
|
||||
className="space-y-2 p-2 rounded-md border bg-muted/30"
|
||||
>
|
||||
<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="shrink-0 text-xs">
|
||||
{t("codexOauth.defaultAccount", "默认")}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountId === account.id && (
|
||||
<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 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 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
{defaultAccountId === account.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("codexOauth.defaultAccount", "默认")}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountId === account.id && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("codexOauth.selected", "已选中")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{defaultAccountId !== account.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setDefaultAccount(account.id)}
|
||||
disabled={isSettingDefaultAccount}
|
||||
>
|
||||
{t("codexOauth.setAsDefault", "设为默认")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setDefaultAccount(account.id)}
|
||||
disabled={isSettingDefaultAccount}
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||
onClick={(e) => handleRemoveAccount(account.id, e)}
|
||||
disabled={isRemovingAccount}
|
||||
title={t("codexOauth.removeAccount", "移除账号")}
|
||||
>
|
||||
{t("codexOauth.setAsDefault", "设为默认")}
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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", "移除账号")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showAccountQuota && (
|
||||
<CodexOauthAccountQuota accountId={account.id} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -414,40 +235,34 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{/* 未认证 - 登录按钮 */}
|
||||
{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"
|
||||
>
|
||||
<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"
|
||||
disabled={isAddingAccount}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("codexOauth.addAnotherAccount", "添加其他账号")}
|
||||
</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" && isPolling && deviceCode && (
|
||||
{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" />
|
||||
@@ -504,7 +319,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{mode === "manage" && pollingState === "error" && error && (
|
||||
{pollingState === "error" && error && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
@@ -529,20 +344,17 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{/* 注销所有账号 */}
|
||||
{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>
|
||||
)}
|
||||
{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,9 +21,6 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
User,
|
||||
Settings2,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useCopilotAuth } from "./hooks/useCopilotAuth";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
@@ -31,14 +28,10 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,10 +41,8 @@ interface CopilotAuthSectionProps {
|
||||
*/
|
||||
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
className,
|
||||
mode = "manage",
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
onManageAccounts,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
@@ -73,8 +64,6 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
accounts,
|
||||
defaultAccountId,
|
||||
migrationError,
|
||||
isStatusSuccess,
|
||||
isStatusError,
|
||||
hasAnyAccount,
|
||||
pollingState,
|
||||
deviceCode,
|
||||
@@ -88,7 +77,6 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
setDefaultAccount,
|
||||
cancelAuth,
|
||||
logout,
|
||||
refetchStatus,
|
||||
} = useCopilotAuth(effectiveGithubDomain);
|
||||
|
||||
// 复制用户码
|
||||
@@ -105,24 +93,6 @@ 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();
|
||||
@@ -139,150 +109,60 @@ 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 || ""}`}>
|
||||
{/* 认证状态标题 */}
|
||||
{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"
|
||||
<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" : ""}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{hasAnyAccount
|
||||
? t("copilot.accountCount", {
|
||||
count: accounts.length,
|
||||
defaultValue: `${accounts.length} 个账号`,
|
||||
})
|
||||
: t("copilot.notAuthenticated", "未认证")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* GitHub 部署类型选择 */}
|
||||
{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>
|
||||
)}
|
||||
<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" && migrationError && (
|
||||
{migrationError && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
{t("copilot.migrationFailed", {
|
||||
error: migrationError,
|
||||
@@ -292,27 +172,44 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{/* 账号选择器(有账号时显示) */}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
) : (
|
||||
accountSelect
|
||||
)}
|
||||
|
||||
{/* 已登录账号列表 */}
|
||||
{mode === "manage" && isStatusSuccess && hasAnyAccount && (
|
||||
{hasAnyAccount && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("copilot.loggedInAccounts", "已登录账号")}
|
||||
@@ -375,46 +272,38 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{/* 未认证状态 - 登录按钮 */}
|
||||
{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={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={
|
||||
isAddingAccount ||
|
||||
(deploymentType === "enterprise" && !enterpriseDomain.trim())
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("copilot.addAnotherAccount", "添加其他账号")}
|
||||
</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" && isPolling && deviceCode && (
|
||||
{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" />
|
||||
@@ -474,7 +363,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{mode === "manage" && pollingState === "error" && error && (
|
||||
{pollingState === "error" && error && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
@@ -499,20 +388,17 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{/* 注销所有账号按钮 */}
|
||||
{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>
|
||||
)}
|
||||
{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,7 +14,6 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||
claude: 8,
|
||||
"claude-desktop": 8,
|
||||
gemini: 8,
|
||||
grokbuild: 12,
|
||||
opencode: 8,
|
||||
openclaw: 8,
|
||||
hermes: 8,
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,7 @@ import {
|
||||
buildLocalProxyRequestOverrides,
|
||||
formatRequestOverrideObject,
|
||||
} from "@/lib/requestOverrides";
|
||||
import {
|
||||
providersApi,
|
||||
settingsApi,
|
||||
type AppId,
|
||||
type ManagedAuthProvider,
|
||||
} from "@/lib/api";
|
||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
@@ -82,7 +77,6 @@ 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";
|
||||
@@ -139,16 +133,6 @@ 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[] => {
|
||||
@@ -240,7 +224,6 @@ export interface ProviderFormProps {
|
||||
onCancel: () => void;
|
||||
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
|
||||
onManageUniversalProviders?: () => void;
|
||||
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
|
||||
onSubmittingChange?: (isSubmitting: boolean) => void;
|
||||
initialData?: {
|
||||
name?: string;
|
||||
@@ -260,9 +243,6 @@ export function ProviderForm(props: ProviderFormProps) {
|
||||
if (props.appId === "claude-desktop") {
|
||||
return <ClaudeDesktopProviderForm {...props} />;
|
||||
}
|
||||
if (props.appId === "grokbuild") {
|
||||
return <GrokBuildProviderForm {...props} />;
|
||||
}
|
||||
|
||||
return <ProviderFormFull {...props} />;
|
||||
}
|
||||
@@ -275,7 +255,6 @@ function ProviderFormFull({
|
||||
onCancel,
|
||||
onUniversalPresetSelect,
|
||||
onManageUniversalProviders,
|
||||
onManageAuthAccounts,
|
||||
onSubmittingChange,
|
||||
initialData,
|
||||
showButtons = true,
|
||||
@@ -378,13 +357,6 @@ 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 ?? "");
|
||||
@@ -538,18 +510,10 @@ function ProviderFormFull({
|
||||
);
|
||||
|
||||
// Copilot OAuth 认证状态(仅 Claude 应用需要)
|
||||
const {
|
||||
isAuthenticated: isCopilotAuthenticated,
|
||||
isStatusSuccess: isCopilotStatusSuccess,
|
||||
isStatusError: isCopilotStatusError,
|
||||
} = useCopilotAuth();
|
||||
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
|
||||
|
||||
// Codex OAuth 认证状态(ChatGPT Plus/Pro 反代)
|
||||
const {
|
||||
isAuthenticated: isCodexOauthAuthenticated,
|
||||
isStatusSuccess: isCodexOauthStatusSuccess,
|
||||
isStatusError: isCodexOauthStatusError,
|
||||
} = useCodexOauth();
|
||||
const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth();
|
||||
|
||||
// 选中的 GitHub 账号 ID(多账号支持)
|
||||
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
|
||||
@@ -736,40 +700,6 @@ 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,
|
||||
@@ -1205,22 +1135,13 @@ function ProviderFormFull({
|
||||
}
|
||||
|
||||
// OAuth 未登录:B 类(token 根本不存在,保存了也没法建立)
|
||||
if (isCopilotProvider && isCopilotStatusError) {
|
||||
toast.error(
|
||||
t("copilot.statusLoadFailed", {
|
||||
defaultValue: "无法加载 GitHub Copilot 账号状态,请重试。",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isCopilotProvider && !isCopilotStatusSuccess) {
|
||||
toast.error(
|
||||
t("copilot.statusLoading", {
|
||||
defaultValue: "正在加载 GitHub Copilot 账号状态,请稍后再试。",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
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 && !isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
t("copilot.loginRequired", {
|
||||
@@ -1229,23 +1150,7 @@ function ProviderFormFull({
|
||||
);
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
|
||||
toast.error(
|
||||
t("codexOauth.loginRequired", {
|
||||
defaultValue: "请先登录 ChatGPT 账号",
|
||||
@@ -1289,18 +1194,14 @@ function ProviderFormFull({
|
||||
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
|
||||
if (category !== "official" && category !== "cloud_provider") {
|
||||
if (appId === "claude") {
|
||||
if (!isClaudeCodexOauthProvider && !baseUrl.trim()) {
|
||||
if (!isCodexOauthProvider && !baseUrl.trim()) {
|
||||
issues.push(
|
||||
t("providerForm.endpointRequired", {
|
||||
defaultValue: "非官方供应商请填写 API 端点",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isCopilotProvider &&
|
||||
!isClaudeCodexOauthProvider &&
|
||||
!apiKey.trim()
|
||||
) {
|
||||
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
|
||||
issues.push(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
@@ -1365,17 +1266,20 @@ 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 shouldStripManagedCodexAuth =
|
||||
category === "official" &&
|
||||
(isCodexOfficialManagedOauthBound ||
|
||||
wasCodexOfficialManagedOauthBound);
|
||||
const authJson = shouldStripManagedCodexAuth
|
||||
? {}
|
||||
: JSON.parse(codexAuth);
|
||||
const authJson = JSON.parse(codexAuth);
|
||||
let normalizedCodexConfig =
|
||||
category !== "official" && (codexConfig ?? "").trim()
|
||||
? setCodexWireApi(codexConfig ?? "", "responses")
|
||||
@@ -1545,11 +1449,9 @@ function ProviderFormFull({
|
||||
const baseMeta: ProviderMeta | undefined =
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
|
||||
const providerType = isCopilotProvider
|
||||
? "github_copilot"
|
||||
: isClaudeCodexOauthProvider || isCodexOfficialManagedOauthBound
|
||||
? "codex_oauth"
|
||||
: undefined;
|
||||
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
|
||||
const providerType =
|
||||
templatePreset?.providerType || initialData?.meta?.providerType;
|
||||
|
||||
const nextMeta: ProviderMeta = {
|
||||
...(baseMeta ?? {}),
|
||||
@@ -1571,25 +1473,19 @@ function ProviderFormFull({
|
||||
authProvider: "github_copilot",
|
||||
accountId: selectedGitHubAccountId ?? undefined,
|
||||
}
|
||||
: isClaudeCodexOauthProvider
|
||||
: isCodexOauthProvider
|
||||
? {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: selectedCodexAccountId ?? undefined,
|
||||
}
|
||||
: isCodexOfficialManagedOauthBound
|
||||
? {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: selectedCodexAccountId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
: undefined,
|
||||
// GitHub Copilot 多账号:保存关联的账号 ID
|
||||
githubAccountId:
|
||||
isCopilotProvider && selectedGitHubAccountId
|
||||
? selectedGitHubAccountId
|
||||
: undefined,
|
||||
codexFastMode: isClaudeCodexOauthProvider ? codexFastMode : undefined,
|
||||
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
|
||||
codexChatReasoning:
|
||||
appId === "codex" &&
|
||||
category !== "official" &&
|
||||
@@ -1657,18 +1553,9 @@ function ProviderFormFull({
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!isClaudeCodexOauthProvider && "codexFastMode" in nextMeta) {
|
||||
if (!isCodexOauthProvider && "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;
|
||||
|
||||
@@ -2209,17 +2096,26 @@ function ProviderFormFull({
|
||||
websiteUrl={claudeWebsiteUrl}
|
||||
isPartner={isClaudePartner}
|
||||
partnerPromotionKey={claudePartnerPromotionKey}
|
||||
isCopilotPreset={isCopilotProvider}
|
||||
isCodexOauthPreset={isClaudeCodexOauthProvider}
|
||||
isCopilotPreset={
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
}
|
||||
isCodexOauthPreset={
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth"
|
||||
}
|
||||
usesOAuth={
|
||||
templatePreset?.requiresOAuth === true ||
|
||||
isCopilotProvider ||
|
||||
isClaudeCodexOauthProvider
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com") ||
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth"
|
||||
}
|
||||
isCopilotAuthenticated={isCopilotAuthenticated}
|
||||
selectedGitHubAccountId={selectedGitHubAccountId}
|
||||
onGitHubAccountSelect={setSelectedGitHubAccountId}
|
||||
onManageAuthAccounts={onManageAuthAccounts}
|
||||
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
|
||||
selectedCodexAccountId={selectedCodexAccountId}
|
||||
onCodexAccountSelect={setSelectedCodexAccountId}
|
||||
@@ -2278,11 +2174,6 @@ 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}
|
||||
|
||||
@@ -30,8 +30,6 @@ export function useManagedAuth(
|
||||
const {
|
||||
data: authStatus,
|
||||
isLoading: isLoadingStatus,
|
||||
isSuccess: isStatusSuccess,
|
||||
isError: isStatusError,
|
||||
refetch: refetchStatus,
|
||||
} = useQuery<ManagedAuthStatus>({
|
||||
queryKey,
|
||||
@@ -217,10 +215,6 @@ export function useManagedAuth(
|
||||
return {
|
||||
authStatus,
|
||||
isLoadingStatus,
|
||||
// Distinguish "status loaded successfully" from "loading / failed" so
|
||||
// callers don't treat a failed query's empty `accounts` as authoritative.
|
||||
isStatusSuccess,
|
||||
isStatusError,
|
||||
accounts,
|
||||
hasAnyAccount: accounts.length > 0,
|
||||
isAuthenticated: authStatus?.authenticated ?? false,
|
||||
|
||||
@@ -25,10 +25,7 @@ export function ClaudeDesktopRouteToggle({
|
||||
|
||||
const isBusy = isStarting || isStoppingServer;
|
||||
const otherTakeoverActive = Boolean(
|
||||
takeoverStatus?.claude ||
|
||||
takeoverStatus?.codex ||
|
||||
takeoverStatus?.gemini ||
|
||||
takeoverStatus?.grokbuild,
|
||||
takeoverStatus?.claude || takeoverStatus?.codex || takeoverStatus?.gemini,
|
||||
);
|
||||
const routeAddress = status?.address ?? "127.0.0.1";
|
||||
const routePort = status?.port ?? 15721;
|
||||
|
||||
@@ -38,9 +38,7 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
? "Claude"
|
||||
: activeApp === "codex"
|
||||
? "Codex"
|
||||
: activeApp === "grokbuild"
|
||||
? "Grok Build"
|
||||
: "Gemini";
|
||||
: "Gemini";
|
||||
|
||||
const tooltipText = !takeoverEnabled
|
||||
? t("failover.tooltip.takeoverRequired", {
|
||||
|
||||
@@ -72,7 +72,6 @@ export function ProxyPanel({
|
||||
const { data: claudeQueue = [] } = useFailoverQueue("claude");
|
||||
const { data: codexQueue = [] } = useFailoverQueue("codex");
|
||||
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
|
||||
const { data: grokQueue = [] } = useFailoverQueue("grokbuild");
|
||||
|
||||
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
|
||||
try {
|
||||
@@ -273,32 +272,30 @@ export function ProxyPanel({
|
||||
defaultValue: "应用接管",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{(["claude", "codex", "gemini", "grokbuild"] as const).map(
|
||||
(appType) => {
|
||||
const isEnabled =
|
||||
takeoverStatus?.[
|
||||
appType as keyof typeof takeoverStatus
|
||||
] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={appType}
|
||||
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{appType === "grokbuild" ? "Grok Build" : appType}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTakeoverChange(appType, checked)
|
||||
}
|
||||
disabled={setTakeoverForApp.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||||
const isEnabled =
|
||||
takeoverStatus?.[
|
||||
appType as keyof typeof takeoverStatus
|
||||
] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={appType}
|
||||
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{appType}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTakeoverChange(appType, checked)
|
||||
}
|
||||
disabled={setTakeoverForApp.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.takeover.hint", {
|
||||
@@ -418,8 +415,7 @@ export function ProxyPanel({
|
||||
{/* [6] Provider queues */}
|
||||
{(claudeQueue.length > 0 ||
|
||||
codexQueue.length > 0 ||
|
||||
geminiQueue.length > 0 ||
|
||||
grokQueue.length > 0) && (
|
||||
geminiQueue.length > 0) && (
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -463,18 +459,6 @@ export function ProxyPanel({
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
|
||||
{grokQueue.length > 0 && (
|
||||
<ProviderQueueGroup
|
||||
appType="grokbuild"
|
||||
appLabel="Grok Build"
|
||||
targets={grokQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -39,9 +39,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||
? "Codex"
|
||||
: activeApp === "gemini"
|
||||
? "Gemini"
|
||||
: activeApp === "grokbuild"
|
||||
? "Grok Build"
|
||||
: "OpenCode";
|
||||
: "OpenCode";
|
||||
|
||||
const tooltipText = takeoverEnabled
|
||||
? isRunning
|
||||
|
||||
@@ -83,7 +83,6 @@ const SESSION_GROUP_EXPANSION_STORAGE_KEY =
|
||||
type ProviderFilter =
|
||||
| "all"
|
||||
| "codex"
|
||||
| "grokbuild"
|
||||
| "claude"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
@@ -1078,16 +1077,6 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="grokbuild">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="grok"
|
||||
name="grokbuild"
|
||||
size={14}
|
||||
/>
|
||||
<span>Grok Build</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
|
||||
@@ -121,7 +121,6 @@ export const getProviderLabel = (
|
||||
// 根据 providerId 获取对应的图标名称
|
||||
export const getProviderIconName = (providerId: string) => {
|
||||
if (providerId === "codex") return "openai";
|
||||
if (providerId === "grokbuild") return "grok";
|
||||
if (providerId === "claude") return "claude";
|
||||
if (providerId === "opencode") return "opencode";
|
||||
if (providerId === "openclaw") return "openclaw";
|
||||
|
||||
@@ -63,7 +63,6 @@ const TOOL_NAMES = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grok",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -131,8 +130,6 @@ ${posixScriptInstallCommand("https://claude.ai/install.sh")} || npm i -g @anthro
|
||||
npm i -g @openai/codex@latest
|
||||
# Gemini CLI
|
||||
npm i -g @google/gemini-cli@latest
|
||||
# Grok Build
|
||||
npm i -g @xai-official/grok@latest
|
||||
# OpenCode
|
||||
${posixScriptInstallCommand("https://opencode.ai/install")} || npm i -g opencode-ai@latest
|
||||
# OpenClaw
|
||||
@@ -146,8 +143,6 @@ npm i -g @anthropic-ai/claude-code@latest
|
||||
npm i -g @openai/codex@latest
|
||||
# Gemini CLI
|
||||
npm i -g @google/gemini-cli@latest
|
||||
# Grok Build
|
||||
npm i -g @xai-official/grok@latest
|
||||
# OpenCode
|
||||
npm i -g opencode-ai@latest
|
||||
# OpenClaw
|
||||
@@ -163,7 +158,6 @@ const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini CLI",
|
||||
grok: "Grok Build",
|
||||
opencode: "OpenCode",
|
||||
openclaw: "OpenClaw",
|
||||
hermes: "Hermes",
|
||||
@@ -179,7 +173,6 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
|
||||
claude: "claude",
|
||||
codex: "codex",
|
||||
gemini: "gemini",
|
||||
grok: "grokbuild",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
hermes: "hermes",
|
||||
|
||||
@@ -26,7 +26,6 @@ const APP_CONFIG: Array<{
|
||||
},
|
||||
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
|
||||
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
|
||||
{ id: "grokbuild", icon: "grok", nameKey: "apps.grokbuild" },
|
||||
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
|
||||
{ id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" },
|
||||
{ id: "hermes", icon: "hermes", nameKey: "apps.hermes" },
|
||||
@@ -43,7 +42,6 @@ export function AppVisibilitySettings({
|
||||
"claude-desktop": true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: true,
|
||||
@@ -75,7 +73,7 @@ export function AppVisibilitySettings({
|
||||
{t("settings.appVisibility.description")}
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-1 rounded-md border border-border-default bg-background p-1">
|
||||
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
|
||||
{APP_CONFIG.map((app) => {
|
||||
const isVisible = visibleApps[app.id];
|
||||
// Disable button if this is the last visible app
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user