Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector
@@ -5,6 +5,65 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.16.4] - 2026-06-27
|
||||
|
||||
Development since v3.16.3 focuses on tightening the Codex proxy path — native OpenAI Responses migration for the major Chinese providers, a decoupled upstream-format selector, zstd request/error-body decompression, and a run of tool-call and OAuth-over-proxy fixes — alongside richer usage and pricing tooling (models.dev pricing import, Volcengine Ark coding/agent-plan quotas, live-tracking date ranges, GLM-5.2/Doubao Seed 2.1 pricing), new proxy and resilience capabilities (custom request header/body overrides, an in-app recovery screen for too-new databases, native Windows ARM64 builds), and a broad wave of preset and branding updates (the SubRouter and OpenCode Go subscriptions, the CTok→ETok rename, Kimi rebranding and prime-partner badges, and a Kimi K2.7 Code sponsor banner).
|
||||
|
||||
**Stats**: 53 commits | 126 files changed | +8,149 insertions | -1,016 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **In-App Recovery Screen for a Too-New Database**: When the SQLite `user_version` is newer than the app supports (`SCHEMA_VERSION`) — e.g. after a downgrade or because a third-party client wrote the file — startup used to dead-end in a native Retry/Exit dialog where Retry just failed again. The app now boots a dedicated recovery screen offering an in-place "Upgrade app" button (download + install + restart with a progress bar) when an update is available, or a warning that even the latest build can't read the database when none is. The too-new check runs before any schema writes so the app never runs DDL against a database it can't understand, and native-close quits cleanly in recovery mode where no tray exists. (#4575)
|
||||
- **Local Proxy Request Overrides (Custom Headers and Body)**: Provider configs can now define custom request headers and request-body overrides that the local proxy applies when forwarding, exposed via a new field in the Claude and Codex provider forms. Inputs are validated, including a protected-header-name list that blocks overriding security-sensitive headers. (#4589)
|
||||
- **Volcengine Ark Coding/Agent Plan Usage Query**: Usage panels can now query coding-plan and agent-plan quota for Volcengine Ark. Because the Ark control-plane OpenAPI (`open.volcengineapi.com`) requires account-level AccessKey signing rather than the inference API key, the usage script gains a dedicated AK/SK input block, with a clickable link straight to the Volcengine IAM key-management console (`https://console.volcengine.com/iam/keymanage`), and the proxy implements Volcengine Signature V4 (an AWS SigV4 variant with fixed canonical header order, `HMAC-SHA256` algorithm, and `ark` service scope). It auto-detects the plan by probing `GetAFPUsage` (Agent Plan five-hour/weekly/monthly quotas) before falling back to `GetCodingPlanUsage`, parses the window label from the `Level` field (guarding `ResetTimestamp <= 0`), and adds a `monthly` tier label across the footer, tray menu, and all four locales.
|
||||
- **Import Model Pricing from models.dev**: The Add Pricing panel gains an "Import from models.dev" button that fetches `https://models.dev/api.json`, lets users full-text search the catalog, and imports the selected entry through the same `update_model_pricing` path as manual entry. Imported model IDs are normalized to match the backend's `clean_model_id_for_pricing` rules (strip vendor prefix, lowercase, drop `:` suffix, map `@` to `-`, drop the `[1m]` marker) so stored rows actually match cost-attribution lookups. A companion fix makes the scoped zero-cost backfill match raw model aliases (route prefixes, `:free` variants, date suffixes) in Rust instead of by exact SQL string, so newly priced alias rows get costed immediately rather than waiting for the next startup backfill (Fixes #4017). (#4079)
|
||||
- **Windows ARM64 Release Builds**: Releases now include native Windows ARM64 artifacts so ARM-based Windows devices get a matching build instead of relying on x64 emulation. The release matrix also runs each platform independently (fail-fast disabled) so a missing-secret failure on one job — e.g. macOS signing in forks — no longer cancels its siblings before they finish. (#3950)
|
||||
- **Live End Time for Custom Date Ranges**: The custom date-range picker gains an "End time follows current time" checkbox; when enabled the end time becomes read-only and tracks the current moment, so usage data always reflects up-to-the-second consumption from the chosen start. This is especially useful for watching real-time token use within a Coding Plan 5-hour quota window. `liveEndTime` is included in the React Query cache keys so a live range and a fixed range with the same stored endpoints no longer collide on a stale cache entry. (#4438)
|
||||
- **Source File Name in Session Detail Header**: The session detail header now shows the session log's file name (with the full path on hover and click-to-copy) alongside the project directory, so users can locate and open the underlying JSONL file directly from the UI. Long, space-less basenames such as ~70-char Codex rollout files are truncated at `max-w-[200px]` to keep them from overflowing into the action-button area on narrow windows. (#4113)
|
||||
- **Unmanaged-Skill Indicator on Import Button**: The top-bar Skills Import button now shows a green dot with a tooltip when local unmanaged skills are available to import, so you can tell at a glance that on-disk skills aren't tracked yet. The scan runs once on mount and is shared across navigations (30s `staleTime` + `keepPreviousData`) to avoid repeated disk IO.
|
||||
- **OpenCode Go Subscription Presets**: New OpenCode Go (`opencode.ai/zen/go`) presets for Claude, Codex, and OpenCode, authenticated with a plain pasteable API key (no OAuth). The Codex preset uses `openai_chat` conversion with a GLM/Kimi/DeepSeek/MiMo model catalog (and no static `codexChatReasoning`, so per-model capability is inferred), while OpenCode targets `/zen/go/v1` via `@ai-sdk/openai-compatible`. All four OpenCode Go presets — Claude, Claude Desktop, Codex, and OpenCode — carry a referral link and an in-app promo phrase; the promo banner is now gated on `partnerPromotionKey` alone rather than `isPartner`, so a preset can show a referral promo without earning the gold paid-partner star (this also re-surfaces the existing MiniMax promos).
|
||||
- **SubRouter Partner Provider**: Added SubRouter (`subrouter.ai`), an AI relay aggregator that exposes many models and providers behind a single key, as a preset across all seven managed apps — the Anthropic-format endpoint for Claude Code / Claude Desktop / OpenClaw / Hermes, the OpenAI-compatible `/v1` endpoint with `gpt-5.5` for Codex and OpenCode, and the Gemini-compatible `/v1beta` endpoint with `gemini-3.5-flash` for Gemini CLI — carrying its own brand icon, the gold partner star, four-locale promotion copy, and the affiliate registration link (`?aff=l3ri`) prefilled as the API-key signup URL. (#4522)
|
||||
- **Prime-Partner Preset Badge and Ordering**: First-party Moonshot Kimi presets (Kimi / Kimi For Coding / Kimi K2.7 Code) are now flagged as prime partners: instead of the gold star, they render a solid gold heart with no badge frame, and in the default (Original) sort they float to the top right after official-category presets, ahead of the rest. Grouping is a three-way partition so each group keeps its internal order and an official preset also flagged prime-partner stays only in the official group.
|
||||
- **Pricing for GLM-5.2 and Doubao Seed 2.1**: Seed model pricing now includes GLM-5.2 (#4385) and Doubao Seed 2.1 Pro/Turbo, so usage from these models is cost-attributed correctly instead of recording zero cost. Doubao prices use Volcengine's official list price (CNY converted at ~7.14); `cache_creation` is kept at 0 because Doubao bills cache storage by time rather than per-token writes, and the existing 2.0 rows are retained for historical accounting.
|
||||
- **Kimi For Coding Auto-Compact Window**: The Kimi For Coding preset now sets `CLAUDE_CODE_AUTO_COMPACT_WINDOW` to a default of 262144 to match the official Kimi docs, exposed via `templateValues` so users can customize the value for future models or performance tuning. (#4401)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Native Responses API for CN Codex Providers**: Several Chinese providers (Qwen/DashScope Bailian, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, MiniMax CN/intl) now expose a native OpenAI Responses endpoint, so their Codex presets switch to `apiFormat: "openai_responses"` and reach the upstream directly instead of going through the Responses->Chat route-takeover conversion. Dropping the now-unused `codexChatReasoning` and `modelCatalog` also keeps the "local route mapping" toggle unchecked by default. SiliconFlow-hosted MiniMax stays `openai_chat` since it is a third-party endpoint rather than MiniMax's own base_url. Stale model ids on the remaining chat-only providers were refreshed as well (GLM 5.1->5.2, StepFun 3.5-flash-2603->3.7-flash, Ling 2.5-1T->2.6-1T).
|
||||
- **Decoupled Upstream Format Selector from Model-Mapping Toggle**: The Codex provider form used to tie Chat-format conversion and route takeover (model mapping) to a single toggle, so a provider serving a native Responses API could not use model mapping without forcing Chat Completions conversion. The upstream format (Chat Completions / Responses) is now an independent, always-visible selector, while the local-routing toggle solely gates the advanced sub-sections (model mapping catalog, plus reasoning capability when the format is Chat). Its initial state is derived from saved catalog presence with no new persisted field, and the `codexConfig` i18n strings were reworded across all four locales (zh/en/ja/zh-TW).
|
||||
- **Doubao Seed 2.1 Pro Preset**: The DouBaoSeed preset now targets `doubao-seed-2-1-pro` (replacing `doubao-seed-2-0-code-preview-latest`) across all six clients (claude, claude-desktop, codex, opencode, openclaw, hermes), with display names updated to "Doubao Seed 2.1 Pro" and the OpenClaw cost field corrected from 0.002/0.006 to 0.84/4.2 USD per 1M tokens to match the new model.
|
||||
- **CTok Rebranded to ETok**: Following the vendor's domain, endpoint, and trademark rename, all user-facing branding moves from CTok to ETok (`ctok.ai` -> `etok.ai`, `api.ctok.ai` -> `api.etok.ai`, internal id, display name, icons, and README partner banners) across every client preset. The Codex history-migration whitelist keeps `ctok` as a legacy id alongside the new `etok` so existing users' local session history stays correctly bucketed after the rename.
|
||||
- **Consistent Kimi Preset Naming**: The OpenCode and OpenClaw Kimi presets, previously labeled "Kimi K2.7 Code", are renamed to plain "Kimi" (along with the OpenCode provider display name) to match the other apps; the model label stays "Kimi K2.7 Code" since it describes the actual model.
|
||||
- **Dark Mode for JSON Editors**: The CodeMirror `JsonEditor` in the usage-script modal, provider form, and universal provider form now follows the app theme via `useDarkMode()`, switching to the `oneDark` editor theme instead of staying light while the rest of the app is dark. (#4556)
|
||||
- **Tighter Add Provider Header With Footer Hint**: The Add Provider dialog reduces the title-to-tabs and tabs-to-card vertical gaps from 24px to 12px and adds an always-visible pinned footer hint guiding users to fill in the fields below after choosing a preset. `FullScreenPanel` gains an optional `contentClassName` prop so the padding override is scoped to this panel without affecting others that share it.
|
||||
- **Theme-Adaptive Kimi Logo**: The inline Kimi placeholder mark is replaced with the vendor's refreshed logo. The K glyph uses `currentColor` so it follows the theme text color (dark in light mode, white in dark mode), while the brand accent dot is pinned to the new `#1783FF`, with the metadata fallback color aligned accordingly.
|
||||
- **Fable 5 Verified Banner Removed**: The Settings About page no longer shows the Fable 5 Verified commemorative banner that 3.16.3 added beside the app name to mark the special build; the banner image and its markup are dropped, returning the About panel to its standard version-badge layout.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Copilot/Codex OAuth Requests Now Honor the Global Proxy**: `CopilotAuthManager` and `CodexOAuthManager` hardcoded `Client::new()` at construction, so their auth flows (token exchange, `/models` listing, model-vendor checks, device-code and OAuth-refresh requests) ignored the configured global proxy and connected directly. With Copilot the direct connection returned zero Claude models, breaking live model resolution and causing the upstream to reject requests with `400 model_not_supported`. Both managers now fetch the shared client per request via `crate::proxy::http_client::get()`, so they follow the global proxy URL and pick up runtime proxy changes. Fixes #2016, #2931. (#4583)
|
||||
- **Compressed Request and Error Body Decompression**: Codex Desktop sends zstd-compressed request bodies when authenticated against the Codex backend, which broke local proxy routing because the handlers parsed the raw compressed bytes with `serde_json` directly. The proxy now decompresses request bodies (gzip/br/deflate plus new zstd support, including stacked codings like `gzip, zstd`) before JSON parsing across the three Codex handlers and strips the stale `content-encoding`/`content-length`/`transfer-encoding` headers so the forwarder regenerates them. Upstream non-2xx error bodies are decompressed the same way, so compressed rate-limit and auth details are no longer dropped and hidden from the client. Fixes #3764, #3696. (#3817)
|
||||
- **DeepSeek Endpoint `thinking: disabled` 400 Errors**: DeepSeek's Anthropic-compatible endpoint rejects requests where `thinking.type=disabled` coexists with effort parameters, returning HTTP 400, which broke Claude Code 2.1.166+ sub-agents (Workflow/Dynamic Workflow) that hardcode `thinking: disabled`. Rather than overriding the client's intent, the proxy now strips the conflicting `output_config.effort` / `reasoning_effort` parameters for the official DeepSeek endpoint, since sub-agents don't need to display reasoning. (#4239)
|
||||
- **Reverted Anthropic System-Message Hoisting**: Reverts #3775's hoisting of `role=system` messages out of `messages[]` into the top-level `system` field for Anthropic-compatible providers. DeepSeek's endpoint accepts inline system messages natively, and the rewrite altered the request prefix; leaving the messages in place preserves the prompt prefix and avoids a suspected cache hit-rate regression (refs #4297). The unrelated Windows test fixes and the tool-thinking-history normalization from #3775 are kept. (#3775)
|
||||
- **Chat Tool Calls with Missing Function Names**: Some upstreams send empty or absent function names in streaming tool-call deltas, which previously produced invalid Codex Chat output items (or an `unknown_tool` fallback). Accumulated tool-call state is no longer overwritten by empty deltas, and tool calls that never receive both a `call_id` and a valid name are skipped at finalization across the streaming, non-streaming, and legacy `function_call` paths. (#4159)
|
||||
- **Restored Cached Codex Tool Call Fields**: When Codex sends a follow-up Chat request referencing `previous_response_id`, its `function_call` items can arrive carrying only `call_id`. The history enrichment previously refilled only `reasoning`/`reasoning_content`, leaving the function `name`, `arguments`, `status`, and related fields empty; it now restores all cached tool-call fields from history so the call is reconstructed correctly for the Chat upstream. (#4160)
|
||||
- **Duplicate Codex base_url Entries in config.toml**: Writing the Codex `base_url` into `config.toml` only replaced or removed a single matching assignment per section, so a section that already contained multiple `base_url` lines kept the extras and accumulated duplicates. `setCodexBaseUrl` now collapses all matches in the target section or top level (replacing the first and removing the rest), and the TOML `base_url` regex handles escaped quotes. (#4316)
|
||||
- **CODEX_SQLITE_HOME State DB Probing for History Migration**: The Codex session-history migration only scanned `~/.codex/state_5.sqlite` and the `config.toml` `sqlite_home` location, so when Codex's SQLite state was relocated via the `CODEX_SQLITE_HOME` env var the state DB was never scanned and its threads kept their old provider bucket. The shared `codex_state_db_paths` helper used by both the third-party and unified-session migrations now falls back to `CODEX_SQLITE_HOME` (config `sqlite_home` still takes precedence).
|
||||
- **Provider Terminals Respect the User's Shell**: Launching a provider terminal on macOS/Linux hardcoded `bash`, so zsh/fish users' rc files never loaded. The launchers now detect the user's default shell from `$SHELL` (falling back to `/bin/zsh` on macOS, `/bin/bash` on Linux) and exec into it with clean-start flags, while the launch scripts themselves run through POSIX `sh` for portability (e.g. fish, NixOS where `/bin/sh` may not exist). (#4140, fixes #1546)
|
||||
- **Claude MCP Path Honors Custom Config Dir**: When a custom Claude config directory is configured, MCP server reads and writes now resolve to that directory's MCP file instead of the default location, keeping MCP state isolated per profile. The previous copy-on-access migration of the legacy file was removed in favor of resolving the override path directly. (#3431)
|
||||
- **Preset Search Results Clickable After Searching**: After searching in the Add Provider preset selector, results could no longer be clicked or selected. The `requestAnimationFrame` `select()` that raced with typing (and ate the first character, e.g. "gateway" -> "ateway") is removed, input autofocus is restored for the open-by-click path, and refocus is wired up for the Ctrl/Cmd+F shortcut while the box is open. The provider-list typing guard is also scoped to the Ctrl/Cmd+F branch so Escape still closes the search panel. (#4315)
|
||||
- **Skills Browser and Provider Card Display Fixes**: Fixed several display and interaction issues: the repo-manager action stays available while browsing skills.sh and Refresh stays available even when a repo returns no results; long provider names and website URLs on the provider card now truncate instead of overflowing; the OMO model-variant dropdown truncates its selected label with a full-text tooltip; and Select menu items show a checkmark on the active option. (#4323)
|
||||
- **Settings Scroll Resets on Tab Switch**: Switching tabs in the Settings dialog kept the previous tab's scroll position, sometimes landing partway down the new tab; the scroll container now resets to the top whenever the active tab changes. (#4165)
|
||||
|
||||
### Docs
|
||||
|
||||
- **Kimi Pinned Sponsor Banner**: The pinned sponsor banner at the top of all four README locales (en/zh/ja/de) now features Kimi K2.7 Code in place of the previous MiniMax M2.7 banner. The copy reflects the K2.7 Code release (a coding-focused agentic model that reduces thinking-token usage roughly 30% versus K2.6), the banner is served from in-repo assets (`assets/partners/banners/kimi-banner-en.png` / `kimi-banner-zh.png`) instead of the Moonshot CDN, and a clickable call-to-action links to the `aff=cc-switch` Moonshot console.
|
||||
- **Codex Unified Session-History Guide**: New trilingual (zh/en/ja) guide for the unified Codex session-history toggle, explaining what opt-in migration (on enable) and ledger-based restore (on disable) actually do, why session data is never truly deleted (tag-only rewrite plus automatic backups), and how to verify files on disk versus merely being filed under another provider drawer. It includes a symptom reference table for the common "my sessions are gone" misunderstanding plus on-disk verification commands for macOS/Linux/Windows, and is linked as the lead item in the v3.16.3 "Usage Guides" release notes.
|
||||
- **Simplified Homebrew Install Instructions**: The installation guide no longer instructs users to run `brew tap farion1231/ccswitch` before `brew install --cask cc-switch`; the deprecated tap step was removed from the en/ja/zh user manuals so the cask installs directly. (#4319)
|
||||
- **Star-History Global Rank Badge**: Added a star-history global rank badge next to the existing Trendshift badge in all four README locales, with light/dark theme variants.
|
||||
- **Volcengine Coding Plan Campaign Link**: The "中国大陆地区的开发者请点击这里" link in the ByteDance/Volcengine sponsor entry now points to the Volcengine `ai618` campaign page instead of the previous `codingplan` referral URL, updated across all four README locales.
|
||||
- **CCSub Sponsor Banner Vector Asset**: Replaced the low-res `ccsub.jpg` sponsor logo with a vector `ccsub.svg`, letterboxed from 2046x648 to 2046x850 (~2.406:1) so it matches the other sponsor-table banners and renders at the same 62px height. All four README locales point at the new asset.
|
||||
|
||||
## [3.16.3] - 2026-06-14
|
||||
|
||||
Development since v3.16.2 focuses on getting usage accounting right end-to-end — billing route-takeover and format-conversion traffic by the real upstream model and pricing basis (schema v11), counting Claude Code Workflow sub-agent sessions, folding Claude Desktop into the Claude view, refreshing the model pricing seed, and reworking the usage dashboard with global provider/model filters, brand-icon toolbars, and far more resilient quota queries — while hardening the proxy (mislabeled SSE bodies, Codex image rectification, OAuth token and takeover-residue recovery, Hermes duplicate YAML keys), reworking provider configuration (a custom User-Agent override, a unified Codex advanced section, searchable preset selection, a Fable 5 tier, and refreshed Kimi/Unity2/Volcengine/MiniMax presets), and smoothing the update, About-panel, and provider-health experiences.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.star-history.com/#farion1231/cc-switch&Date"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=farion1231/cc-switch&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=farion1231/cc-switch" width="196" height="55" /></picture></a>
|
||||
|
||||
### 🌐 The Only Official Website: **[ccswitch.io](https://ccswitch.io)**
|
||||
|
||||
@@ -24,9 +25,11 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Deutsch](README_
|
||||
<details open>
|
||||
<summary>Click to collapse</summary>
|
||||
|
||||
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K2.6 is an open-source, native multimodal agentic model from Moonshot AI, built for long-horizon coding, coding-driven design, and swarm-based task orchestration. It is designed to handle complex end-to-end engineering work across front-end, DevOps, performance optimization, and full-stack workflows, while coordinating large groups of specialized agents to plan, implement, test, and iterate on real coding tasks. **[Click here to start using Kimi](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||
Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI. It delivers stronger coding and agent performance, with substantial improvements in real-world long-horizon coding tasks. These gains translate into higher end-to-end task success rates across complex software engineering workflows. K2.7 Code also improves reasoning efficiency, reducing thinking-token usage by approximately 30% compared with K2.6. **[Click here to start using Kimi](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
Doing mostly coding work? Try the **[Kimi For Coding plan](https://www.kimi.com/code/?aff=cc-switch)** — a subscription service built for coding!
|
||||
|
||||
---
|
||||
|
||||
@@ -59,14 +62,21 @@ It also supports enterprise-grade concurrency and provides a dedicated managemen
|
||||
Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this link</a> to receive $3 in trial credit. Top-ups go as low as 60% of the original price, with a two-way referral bonus of up to $150!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>Thanks to TeamoRouter for sponsoring this project! TeamoRouter is an enterprise-grade Agentic LLM gateway built for developers, AI teams, and businesses. Without requiring any subscriptions, it lets you access Claude Code, Codex, Gemini CLI, OpenAI Codex, and other popular AI agents through a single unified API, while offering API pricing at discounts of up to 90%.
|
||||
Unlike typical API relay services, TeamoRouter aggregates hundreds of official model providers and trusted infrastructure partners, including OpenAI, Anthropic, Vertex, Azure, and AWS bedrock. Every provider is verified for 100% Agent protocol compatibility, cache performance, and request traceability, ensuring stable quality instead of reverse-engineered or diluted endpoints. The platform delivers near-official TTFT, 99.6% SLA, enterprise-scale throughput up to 5,000 QPM, and industry-leading cache hit rates that dramatically reduce token costs for long-running agent workflows.
|
||||
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://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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/drGuwc9k">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -149,6 +159,16 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
<td>Thanks to Unity2.ai for sponsoring this project! Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. Long trusted by leading companies in China, it serves over 30 billion tokens per day and supports high concurrency at the 5,000 RPM level. It offers balance-based billing, first top-up bonuses, bundle subscriptions, corporate invoicing, and dedicated support. Register via <a href="https://unity2.ai/register?source=ccs">this link</a> to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<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>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.star-history.com/#farion1231/cc-switch&Date"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=farion1231/cc-switch&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=farion1231/cc-switch" width="196" height="55" /></picture></a>
|
||||
|
||||
### 🌐 Die einzige offizielle Website: **[ccswitch.io](https://ccswitch.io)**
|
||||
|
||||
@@ -24,9 +25,11 @@
|
||||
<details open>
|
||||
<summary>Zum Einklappen klicken</summary>
|
||||
|
||||
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K2.6 ist ein quelloffenes, nativ multimodales Agenten-Modell von Moonshot AI, das für langfristige Programmierung, code-getriebenes Design und schwarmbasierte Aufgabenorchestrierung entwickelt wurde. Es ist darauf ausgelegt, komplexe End-to-End-Engineering-Arbeiten über Frontend, DevOps, Performance-Optimierung und Full-Stack-Workflows hinweg zu bewältigen und dabei große Gruppen spezialisierter Agenten zu koordinieren, um echte Programmieraufgaben zu planen, umzusetzen, zu testen und zu iterieren. **[Hier klicken, um Kimi auszuprobieren](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||
Kimi K2.7 Code ist ein quelloffenes, auf Programmierung spezialisiertes Agenten-Modell von Moonshot AI. Es bietet eine stärkere Programmier- und Agentenleistung mit erheblichen Verbesserungen bei realen, langfristigen Programmieraufgaben. Diese Fortschritte führen zu höheren End-to-End-Erfolgsraten in komplexen Software-Engineering-Workflows. Zudem verbessert K2.7 Code die Reasoning-Effizienz und reduziert den Verbrauch an Thinking-Tokens um rund 30 % gegenüber K2.6. **[Hier klicken, um Kimi auszuprobieren](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
Hauptsächlich mit Programmierung beschäftigt? Probieren Sie den **[Kimi-For-Coding-Plan](https://www.kimi.com/code/?aff=cc-switch)** – ein Abo, das speziell fürs Programmieren entwickelt wurde!
|
||||
|
||||
---
|
||||
|
||||
@@ -59,14 +62,21 @@ Er unterstützt zudem unternehmensgerechte Nebenläufigkeit und stellt Unternehm
|
||||
Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">diesen Link</a> und erhalten Sie ein Testguthaben von 3 $. Aufladungen sind ab 60 % des Originalpreises möglich, mit einem beidseitigen Empfehlungsbonus von bis zu 150 $!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>Danke an TeamoRouter für die Unterstützung dieses Projekts! TeamoRouter ist ein Agentic-LLM-Gateway in Enterprise-Qualität, das für Entwickler, KI-Teams und Unternehmen entwickelt wurde. Ganz ohne Abonnement können Sie über eine einzige einheitliche API auf Claude Code, Codex, Gemini CLI, OpenAI Codex und weitere beliebte KI-Agenten zugreifen — bei API-Preisen mit Rabatten von bis zu 90 %.
|
||||
Anders als typische API-Relay-Dienste bündelt TeamoRouter Hunderte offizieller Modellanbieter und vertrauenswürdiger Infrastrukturpartner, darunter OpenAI, Anthropic, Vertex, Azure und AWS Bedrock. Jeder Anbieter wird auf 100%ige Kompatibilität mit dem Agent-Protokoll, Cache-Performance und Nachverfolgbarkeit von Anfragen geprüft und liefert so stabile Qualität statt reverse-engineerter oder verwässerter Endpunkte. Die Plattform bietet nahezu offizielle TTFT, 99,6 % SLA, Durchsatz in Unternehmensgröße von bis zu 5.000 QPM und branchenführende Cache-Trefferquoten, die die Token-Kosten für langlaufende Agent-Workflows drastisch senken.
|
||||
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://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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Danke an SiliconFlow für die Unterstützung dieses Projekts! SiliconFlow ist eine leistungsstarke KI-Infrastruktur- und Modell-API-Plattform, die schnellen und zuverlässigen Zugriff auf Sprach-, Audio-, Bild- und Videomodelle an einem Ort bietet. Mit nutzungsbasierter Abrechnung, breiter Unterstützung multimodaler Modelle, Hochgeschwindigkeitsinferenz und unternehmensgerechter Stabilität hilft SiliconFlow Entwicklern und Teams, KI-Anwendungen effizienter zu erstellen und zu skalieren. Registrieren Sie sich über <a href="https://cloud.siliconflow.cn/i/drGuwc9k">diesen Link</a> und schließen Sie die Identitätsverifizierung ab, um ein Bonusguthaben von ¥16 zu erhalten, das für alle Modelle der Plattform nutzbar ist. SiliconFlow ist zudem nun mit OpenClaw kompatibel, sodass Nutzer einen SiliconFlow-API-Schlüssel verbinden und große KI-Modelle kostenlos aufrufen können.</td>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Danke an SiliconFlow für die Unterstützung dieses Projekts! SiliconFlow ist eine leistungsstarke KI-Infrastruktur- und Modell-API-Plattform, die schnellen und zuverlässigen Zugriff auf Sprach-, Audio-, Bild- und Videomodelle an einem Ort bietet. Mit nutzungsbasierter Abrechnung, breiter Unterstützung multimodaler Modelle, Hochgeschwindigkeitsinferenz und unternehmensgerechter Stabilität hilft SiliconFlow Entwicklern und Teams, KI-Anwendungen effizienter zu erstellen und zu skalieren. Registrieren Sie sich über <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">diesen Link</a> und schließen Sie die Identitätsverifizierung ab, um ein Bonusguthaben von ¥16 zu erhalten, das für alle Modelle der Plattform nutzbar ist. SiliconFlow ist zudem nun mit OpenClaw kompatibel, sodass Nutzer einen SiliconFlow-API-Schlüssel verbinden und große KI-Modelle kostenlos aufrufen können.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -149,6 +159,16 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
<td>Danke an Unity2.ai für die Unterstützung dieses Projekts! Unity2.ai ist eine leistungsstarke AI-Modell-API-Relay-Plattform für Einzelentwickler, Teams und Unternehmen. Sie wird seit Langem von führenden Unternehmen in China genutzt, verarbeitet täglich über 30 Milliarden Tokens und unterstützt hohe Parallelität auf 5.000-RPM-Niveau. Geboten werden Guthaben-Abrechnung, Ersteinzahlungsbonus, Kombi-Abonnements, Firmenrechnungen und persönliche Betreuung. Registrieren Sie sich über <a href="https://unity2.ai/register?source=ccs">diesen Link</a> und erhalten Sie $2 Guthaben, plus weitere $10 für den Beitritt zur offiziellen Gruppe — bis zu $12 Gratis-Guthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<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>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.star-history.com/#farion1231/cc-switch&Date"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=farion1231/cc-switch&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=farion1231/cc-switch" width="196" height="55" /></picture></a>
|
||||
|
||||
### 🌐 唯一の公式サイト:**[ccswitch.io](https://ccswitch.io)**
|
||||
|
||||
@@ -24,9 +25,11 @@
|
||||
<details open>
|
||||
<summary>クリックで折りたたむ</summary>
|
||||
|
||||
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K2.6 は Moonshot AI によるオープンソースのネイティブマルチモーダル・エージェントモデルで、長期的なコーディング、コード駆動設計、スウォーム(群)型タスクオーケストレーションのために構築されています。フロントエンド、DevOps、パフォーマンス最適化、フルスタックワークフローにわたる複雑なエンドツーエンドのエンジニアリング作業に対応できるよう設計されており、多数の専門エージェントを協調させて、実際のコーディングタスクの計画・実装・テスト・反復を行います。**[ここをクリックして Kimi を体験する](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||
Kimi K2.7 Code は Moonshot AI が開発した、コーディングに特化したオープンソースのエージェントモデルです。コーディング能力とエージェント性能が全面的に強化され、実世界の長程コーディングタスクで大幅な向上を実現し、複雑なソフトウェアエンジニアリングのワークフロー全体でエンドツーエンドのタスク成功率を高めます。さらに K2.7 Code は推論効率を改善し、K2.6 と比べて推論トークンの消費を約 30% 削減します。**[ここをクリックして Kimi を体験する](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
コーディング作業がメインですか?コーディングのために作られたサブスクリプション、**[Kimi For Coding プラン](https://www.kimi.com/code/?aff=cc-switch)** をぜひお試しください!
|
||||
|
||||
---
|
||||
|
||||
@@ -59,14 +62,21 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
現在、<a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">このリンク</a>からご登録いただくと $3 のトライアルクレジットを進呈。チャージは最安で元価格の 60%、友達紹介は双方にボーナスが付与され、紹介報酬は最大 $150!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>このプロジェクトをご支援いただいている TeamoRouter に感謝します!TeamoRouter は、開発者、AI チーム、企業向けに構築されたエンタープライズグレードの Agentic LLM ゲートウェイです。サブスクリプション不要で、単一の統合 API を通じて Claude Code、Codex、Gemini CLI、OpenAI Codex、その他の人気 AI エージェントにアクセスでき、API 料金は最大 90% オフで利用できます。
|
||||
一般的な API リレーサービスとは異なり、TeamoRouter は OpenAI、Anthropic、Vertex、Azure、AWS Bedrock など、数百の公式モデルプロバイダーと信頼できるインフラパートナーを集約しています。各プロバイダーは、Agent プロトコルとの 100% 互換性、キャッシュ性能、リクエストの追跡可能性について検証されており、リバースエンジニアリングされたものや品質が薄められたエンドポイントではなく、安定した品質を提供します。プラットフォームは公式に近い TTFT、99.6% の SLA、最大 5,000 QPM のエンタープライズ規模のスループット、そして業界トップクラスのキャッシュヒット率を提供し、長時間稼働するエージェントワークフローのトークンコストを大幅に削減します。
|
||||
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://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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/drGuwc9k">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -149,6 +159,16 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>Unity2.ai のご支援に感謝します!Unity2.ai は個人開発者・チーム・企業向けの高性能 AI モデル API リレープラットフォームです。中国の大手企業に長年利用されており、1 日 300 億トークン以上を処理し、5000 RPM クラスの高並列に対応しています。残高課金、初回チャージボーナス、組み合わせサブスクリプション、企業向け請求書発行、専任サポートを提供。<a href="https://unity2.ai/register?source=ccs">こちらのリンク</a>から登録すると $2 のクレジット、公式グループへの参加でさらに $10、最大 $12 の無料クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<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>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -228,7 +248,7 @@ CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**
|
||||
<details>
|
||||
<summary><strong>プロバイダを切り替えた後、プラグイン設定が消えてしまいました。どうすればよいですか?</strong></summary>
|
||||
|
||||
CC Switch には「共有設定スニペット」機能があり、APIキーやエンドポイント以外の共通データをプロバイダ間で引き継ぐことができます。「プロバイダ編集」→「共有設定パネル」→「現在のプロバイダから抽出」をクリックして、すべての共通データを保存してください。新しいプロバイダを作成する際に「共有設定を書き込む」にチェック(デフォルトで有効)を入れれば、プラグインなどのデータが新しいプロバイダ設定に含まれます。すべての設定項目は、アプリ初回起動時にインポートされたデフォルトプロバイダに保存されており、失われることはありません。
|
||||
CC Switch には「共有設定スニペット」機能があり、APIキーやエンドポイント以外の共通データをプロバイダ間で引き継ぐことができます。「プロバイダ編集」→「共有設定パネル」→「現在のプロバイダから抽出」をクリックして、すべての共通データを保存してください。新しいプロバイダを作成する際に「共有設定を適用」にチェック(デフォルトで有効)を入れれば、プラグインなどのデータが新しいプロバイダ設定に含まれます。すべての設定項目は、アプリ初回起動時にインポートされたデフォルトプロバイダに保存されており、失われることはありません。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.star-history.com/#farion1231/cc-switch&Date"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=farion1231/cc-switch&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=farion1231/cc-switch" width="196" height="55" /></picture></a>
|
||||
|
||||
### 🌐 唯一官方网站:**[ccswitch.io](https://ccswitch.io)**
|
||||
|
||||
@@ -24,9 +25,11 @@
|
||||
<details open>
|
||||
<summary>点击折叠</summary>
|
||||
|
||||
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||
[](https://platform.kimi.com?aff=cc-switch)
|
||||
|
||||
Kimi K2.6 是 Moonshot AI 开源的原生多模态智能体模型,面向长程编码、代码驱动设计和智能体集群任务编排而构建。它适合处理复杂的端到端工程任务,覆盖前端开发、DevOps、性能优化和全栈工作流,并能够协调大规模专业智能体,对真实编码任务进行规划、实现、测试和迭代。**[点击此处开启 Kimi 使用体验](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||
Kimi K2.7 Code 是 Moonshot AI 开发的编程专用开源智能体模型。它在编程与智能体执行能力上全面增强,在真实长程编程任务中实现显著提升,带来复杂软件工程工作流中更高的端到端任务成功率。同时,K2.7 Code 优化了推理效率,相较 K2.6 平均减少约 30% 的推理 token 消耗。**[点击此处开启 Kimi 使用体验](https://platform.kimi.com?aff=cc-switch)**
|
||||
|
||||
主要做编程工作?可以试试 **[Kimi For Coding 编程套餐](https://www.kimi.com/code/?aff=cc-switch)**,专为编程打造的订阅服务!
|
||||
|
||||
---
|
||||
|
||||
@@ -59,14 +62,21 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
现在通过<a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">此链接</a>注册即送 $3 试用额度,用户充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://teamorouter.com/zh?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>感谢 TeamoRouter 赞助本项目!TeamoRouter 是一款面向开发者、AI 团队和企业的企业级 Agentic LLM 网关。无需任何订阅,你就可以通过一个统一 API 访问 Claude Code、Codex、Gemini CLI、OpenAI Codex 以及其他热门 AI Agent,同时享受最高可达 90% 折扣的 API 价格。
|
||||
不同于常见的 API 中转服务,TeamoRouter 聚合了数百家官方模型提供商和可信基础设施合作伙伴,包括 OpenAI、Anthropic、Vertex、Azure 和 AWS Bedrock。每个提供商都经过验证,确保 100% 兼容 Agent 协议,并具备可靠的缓存性能和请求可追踪性,从而提供稳定质量,而不是反向工程或缩水后的接口。平台提供接近官方水平的 TTFT、99.6% SLA、最高 5,000 QPM 的企业级吞吐量,以及行业领先的缓存命中率,可大幅降低长时间运行的 Agent 工作流中的 token 成本。
|
||||
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://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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/drGuwc9k">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -150,6 +160,16 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过<a href="https://unity2.ai/register?source=ccs">此链接</a>注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<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>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -229,7 +249,7 @@ CC Switch 支持七个工具:**Claude Code**、**Claude Desktop**、**Codex**
|
||||
<details>
|
||||
<summary><strong>切换供应商之后我的插件配置怎么不见了?</strong></summary>
|
||||
|
||||
CC Switch 使用“通用配置片段”功能,在不同的供应商之间传递 Key 和请求地址之外的通用数据,您可以在“编辑供应商”菜单的“通用配置面板”里,点击“从当前供应商提取”,把所有的通用数据提取到通用配置中,之后在新建“供应商”的时候,只要勾选“写入通用配置”(默认勾选),就会把插件等数据写入到新的供应商配置中。您的所有配置项都会保存在运行本软件的时候,第一次导入的默认供应商里面,不会丢失。
|
||||
CC Switch 使用“通用配置片段”功能,在不同的供应商之间传递 Key 和请求地址之外的通用数据,您可以在“编辑供应商”菜单的“通用配置面板”里,点击“从当前供应商提取”,把所有的通用数据提取到通用配置中,之后在新建“供应商”的时候,只要勾选“应用通用配置”(默认勾选),就会把插件等数据写入到新的供应商配置中。您的所有配置项都会保存在运行本软件的时候,第一次导入的默认供应商里面,不会丢失。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,46 @@
|
||||
# Can't See Custom Models in the Codex Desktop App? (FAQ)
|
||||
|
||||
> Applies to CC Switch v3.16.1 and later. This article explains "why the Codex desktop app can't see custom models" and the available mitigation; for the detailed step-by-step setup with screenshots, see [Keep Codex Remote Control and Official Plugins While Using Third-Party APIs](./codex-official-auth-preservation-guide-en.md).
|
||||
|
||||
## Symptom
|
||||
|
||||
After you switch Codex to a third-party / custom model in CC Switch (DeepSeek, Kimi, GLM, MiniMax, an aggregator, etc.):
|
||||
|
||||
- The model picker in the **Codex desktop app** doesn't show these custom models — often only the official default model remains, and the reasoning level falls back to the official default;
|
||||
- but everything works fine in the **command-line `codex`** `/model` menu.
|
||||
|
||||
Many users have run into this. Here's why, and what you can do about it.
|
||||
|
||||
## Why this happens
|
||||
|
||||
This is **not a CC Switch local-config problem and not a CC Switch bug** — it is the **Codex desktop app's (the upstream closed-source client's) own model-gating behavior**.
|
||||
|
||||
The Codex desktop app's model picker decides which models to allow based on your **current login identity**: when it can't detect an official ChatGPT / Codex login state, it forces the picker back to the official default model and hides the custom models you configured through `config.toml` (the reasoning level falls back to the official default too). The upstream has marked "exposing custom-provider models in the desktop GUI" as not planned, so CC Switch cannot fully fix this at the desktop-GUI level.
|
||||
|
||||
The command-line `codex` `/model` menu and request routing both recognize the custom providers in `config.toml` correctly — **only the desktop GUI picker is constrained by this gating layer**.
|
||||
|
||||
## Mitigation: keep the official login
|
||||
|
||||
The workaround is to **keep the official login state** so the desktop app's gating allows your custom models through. The key points are below (the full step-by-step setup with screenshots is in the linked guide):
|
||||
|
||||
1. Log in once with an official ChatGPT / Codex account in Codex (a Free subscription is enough) to keep the official login state.
|
||||
2. In CC Switch, enable `Settings -> General -> Codex App Enhancements -> Keep official login when switching third-party providers` (**off by default**).
|
||||
3. Enable local routing and route Codex through it for this third-party provider (required for Chat Completions providers such as DeepSeek / Kimi / MiniMax).
|
||||
4. Fully quit and restart Codex.
|
||||
|
||||
Once enabled, CC Switch preserves the official login state in `~/.codex/auth.json` when switching to a third-party provider and writes the third-party key into `config.toml`, so the desktop app still recognizes the official login identity, the gating lets your models through, and the custom models you configured reappear in the picker. **The preserved official token is never sent to the third party** — third-party model requests still use the key you configured, forwarded through the local route.
|
||||
|
||||
> 📖 Detailed step-by-step setup: [Keep Codex Remote Control and Official Plugins While Using Third-Party APIs](./codex-official-auth-preservation-guide-en.md)
|
||||
|
||||
## Still can't see them?
|
||||
|
||||
- **Confirm the toggle is on**: this toggle is off by default, and many people overwrite the official login state the first time they switch to a third-party provider, which is exactly why the models disappear — enable it as above.
|
||||
- **The official login state expires**: if you haven't used the official login for several days, the picker may go empty again once the token expires — log in to the official account once more to restore it.
|
||||
- **Command-line fallback diagnosis**: run `codex debug models` to list the models actually available on the CLI side and confirm the model itself is configured correctly (the CLI is unaffected by this gating).
|
||||
- Individual Codex desktop versions may behave slightly differently; this is in the upstream client's domain, and no CC Switch version can fully fix it at the desktop-GUI level.
|
||||
|
||||
## References
|
||||
|
||||
- [Keep Codex Remote Control and Official Plugins While Using Third-Party APIs](./codex-official-auth-preservation-guide-en.md)
|
||||
- [Codex DeepSeek local routing hands-on guide](./codex-deepseek-routing-guide-en.md)
|
||||
- [Local Routing](../user-manual/en/4-proxy/4.2-routing.md)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Codex デスクトップアプリでカスタムモデルが見えない?(よくある質問)
|
||||
|
||||
> 対象バージョン: CC Switch v3.16.1 以降。本記事は「なぜ Codex デスクトップアプリでカスタムモデルが見えないのか」と、使える緩和策を解説します。図入りの詳細な設定手順は [サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する](./codex-official-auth-preservation-guide-ja.md) を参照してください。
|
||||
|
||||
## 現象
|
||||
|
||||
CC Switch で Codex をサードパーティ / カスタムモデル(DeepSeek、Kimi、GLM、MiniMax、中継サービスなど)へ切り替えた後:
|
||||
|
||||
- **Codex デスクトップアプリ**のモデルセレクタにこれらのカスタムモデルが表示されず、多くの場合は公式の既定モデルだけが残り、思考レベルも公式の既定へ戻ってしまう。
|
||||
- 一方で**コマンドライン `codex`** の `/model` ではすべて正常に表示される。
|
||||
|
||||
多くのユーザーがこの現象に遭遇しています。以下で原因と対処を解説します。
|
||||
|
||||
## なぜこうなるのか
|
||||
|
||||
これは **CC Switch のローカル設定の問題でも、CC Switch のバグでもありません**。**Codex デスクトップアプリ(上流のクローズドソースクライアント)自身のモデルゲーティング挙動**です。
|
||||
|
||||
Codex デスクトップアプリのモデルセレクタは、あなたの**現在のログイン ID** に応じてどのモデルを通すかを決めます。公式 ChatGPT / Codex のログイン状態を検出できないとき、セレクタを公式の既定モデルへ強制的に戻し、`config.toml` で設定したカスタムモデルを隠します(思考レベルもあわせて公式の既定へ戻ります)。公式は「デスクトップ GUI でカスタムプロバイダーのモデルを公開する」ことを not planned としてマークしているため、CC Switch がデスクトップ GUI のレベルでこれを根本的に修正することはできません。
|
||||
|
||||
コマンドライン `codex` の `/model` とリクエストルーティングは `config.toml` 内のカスタムプロバイダーを正常に認識できます。**デスクトップ GUI のセレクタだけがこのゲーティングの制限を受けます**。
|
||||
|
||||
## 緩和策: 公式ログインを保持する
|
||||
|
||||
対処は**公式ログイン状態を保持する**ことで、デスクトップアプリのゲーティングにあなたのカスタムモデルを通させます。要点は次のとおりです(完全な図入り手順は下のリンク先のガイドを参照してください):
|
||||
|
||||
1. まず Codex で公式 ChatGPT / Codex に一度ログインし(Free サブスクリプションで構いません)、公式ログイン状態を保持する。
|
||||
2. CC Switch で `設定 → 一般 → Codex アプリ拡張 → サードパーティ切替時に公式ログインを保持` をオンにする(**デフォルトはオフ**)。
|
||||
3. そのサードパーティプロバイダーでローカルルーティングを有効化し、Codex のルーティングをオンにする(DeepSeek / Kimi / MiniMax など Chat Completions プロトコルのプロバイダーでは必須)。
|
||||
4. Codex を完全に終了して再起動する。
|
||||
|
||||
オンにすると、CC Switch はサードパーティプロバイダーへ切り替える際に `~/.codex/auth.json` 内の公式ログイン状態を保持し、サードパーティの Key を `config.toml` へ書き込みます。これにより、デスクトップアプリは引き続き公式ログイン ID を認識してゲーティングを通すため、設定したカスタムモデルがセレクタに再び表示されます。**保持された公式 Token がサードパーティへ送られることはありません**——サードパーティのモデルリクエストは引き続き、設定した Key でローカルルーティング経由で転送されます。
|
||||
|
||||
> 📖 詳細な図入り手順: [サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する](./codex-official-auth-preservation-guide-ja.md)
|
||||
|
||||
## それでも見えない場合は
|
||||
|
||||
- **スイッチがオンか確認する**: このスイッチはデフォルトでオフです。多くの人は初めてサードパーティへ切り替えたときに公式ログイン状態を上書きしてしまい、その結果見えなくなっています——上記の手順でオンにしてください。
|
||||
- **公式ログイン状態は期限切れになる**: 数日間公式ログインを使わないと、Token が失効した後にセレクタが再び空になることがあります——公式に一度ログインし直せば回復します。
|
||||
- **コマンドラインでの確認**: `codex debug models` を使うと CLI 側で実際に利用可能なモデルを一覧でき、モデル自体が正しく設定されていることを確認できます(CLI はこのゲーティングの影響を受けません)。
|
||||
- 個々の Codex デスクトップ版で挙動が多少異なる場合があります。これは上流クライアントの範疇であり、CC Switch のどのバージョンでもデスクトップ GUI のレベルで根本解決することはできません。
|
||||
|
||||
## 参考リンク
|
||||
|
||||
- [サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する](./codex-official-auth-preservation-guide-ja.md)
|
||||
- [Codex DeepSeek ローカルルーティング実践ガイド](./codex-deepseek-routing-guide-ja.md)
|
||||
- [ローカルルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
|
||||
</content>
|
||||
@@ -0,0 +1,46 @@
|
||||
# Codex 桌面应用里看不到自定义模型?(常见问题)
|
||||
|
||||
> 适用版本:CC Switch v3.16.1 及以上。本文解释「为什么 Codex 桌面应用看不到自定义模型」以及可用的缓解办法;详细的图文配置步骤见 [使用第三方 API 时保留 Codex 远程操作和官方插件](./codex-official-auth-preservation-guide-zh.md)。
|
||||
|
||||
## 现象
|
||||
|
||||
在 CC Switch 里把 Codex 切换到第三方 / 自定义模型(DeepSeek、Kimi、GLM、MiniMax、中转站等)后:
|
||||
|
||||
- **Codex 桌面应用**的模型选择器里看不到这些自定义模型,往往只剩官方默认模型,思考等级也回落到官方默认;
|
||||
- 但**命令行 `codex`** 的 `/model` 里一切正常。
|
||||
|
||||
很多用户都遇到过这个现象,下面解释原因与办法。
|
||||
|
||||
## 为什么会这样
|
||||
|
||||
这**不是 CC Switch 的本地配置问题,也不是 CC Switch 的 bug**,而是 **Codex 桌面应用(上游闭源客户端)自身的模型门控行为**。
|
||||
|
||||
Codex 桌面应用的模型选择器会按你**当前的登录身份**来决定放行哪些模型:当它检测不到官方 ChatGPT / Codex 登录态时,会把选择器强制回落到官方默认模型,把你通过 `config.toml` 配置的自定义模型藏起来(思考等级也会一并回落到官方默认)。官方已把「在桌面 GUI 里暴露自定义供应商模型」标记为 not planned,因此 CC Switch 无法从桌面 GUI 层面彻底修复它。
|
||||
|
||||
命令行 `codex` 的 `/model` 与请求路由都能正常识别 `config.toml` 里的自定义供应商,**唯独桌面 GUI 的选择器受这层门控限制**。
|
||||
|
||||
## 怎么缓解:保留官方登录
|
||||
|
||||
办法是**保留官方登录态**,让桌面应用的门控放行你的自定义模型。要点如下(完整图文步骤见下方链接的攻略):
|
||||
|
||||
1. 先在 Codex 里登录一次官方 ChatGPT / Codex(Free 订阅即可),保留官方登录态。
|
||||
2. 在 CC Switch 开启 `设置 → 通用 → Codex 应用增强 → 切换第三方时保留官方登录`(**默认关闭**)。
|
||||
3. 为该第三方供应商开启本地路由并接管 Codex(Chat Completions 协议的供应商如 DeepSeek / Kimi / MiniMax 必须开启)。
|
||||
4. 完全退出并重启 Codex。
|
||||
|
||||
开启后,CC Switch 在切换第三方供应商时会保留 `~/.codex/auth.json` 里的官方登录态、把第三方 Key 写进 `config.toml`,于是桌面应用仍识别官方登录身份、门控放行,你配置的自定义模型就会重新出现在选择器里。**保留的官方 Token 不会被发往第三方**——第三方模型请求仍用你配置的 Key 经本地路由转发。
|
||||
|
||||
> 📖 详细图文步骤:[使用第三方 API 时保留 Codex 远程操作和官方插件](./codex-official-auth-preservation-guide-zh.md)
|
||||
|
||||
## 仍然看不到怎么办
|
||||
|
||||
- **确认开关已开**:该开关默认关闭,很多人第一次切到第三方就把官方登录态覆盖掉了,所以才看不到——按上面开启即可。
|
||||
- **官方登录态会过期**:如果连续几天没用过官方登录,Token 失效后选择器可能又变空——重新登录一次官方即可恢复。
|
||||
- **命令行兜底诊断**:用 `codex debug models` 可以列出 CLI 端实际可用的模型,确认模型本身已正确配置(CLI 不受此门控影响)。
|
||||
- 个别 Codex 桌面版本的行为可能略有差异;这属于上游客户端范畴,CC Switch 各版本都无法从桌面 GUI 层根治。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [使用第三方 API 时保留 Codex 远程操作和官方插件](./codex-official-auth-preservation-guide-zh.md)
|
||||
- [Codex DeepSeek 本地路由实战攻略](./codex-deepseek-routing-guide-zh.md)
|
||||
- [本地路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
||||
@@ -202,6 +202,7 @@ Because Codex App Enhancements and routing takeover can create unnecessary troub
|
||||
|
||||
## References
|
||||
|
||||
- [Can't see custom models in the Codex desktop app? (FAQ)](./codex-desktop-custom-model-visibility-en.md)
|
||||
- [Codex DeepSeek local routing hands-on guide](./codex-deepseek-routing-guide-en.md)
|
||||
- [Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)
|
||||
- [Local Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
|
||||
|
||||
@@ -202,6 +202,7 @@ Codex アプリ拡張やルーティング管理は、必要ないユーザー
|
||||
|
||||
## 参考リンク
|
||||
|
||||
- [Codex デスクトップアプリでカスタムモデルが見えない?(よくある質問)](./codex-desktop-custom-model-visibility-ja.md)
|
||||
- [Codex DeepSeek ローカルルーティング実践ガイド](./codex-deepseek-routing-guide-ja.md)
|
||||
- [Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)
|
||||
- [ローカルプロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
||||
|
||||
@@ -202,6 +202,7 @@ Codex 的模型目录是启动时读取的。即使 CC Switch 已经生成了新
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Codex 桌面应用里看不到自定义模型?(常见问题)](./codex-desktop-custom-model-visibility-zh.md)
|
||||
- [Codex DeepSeek 本地路由实战攻略](./codex-deepseek-routing-guide-zh.md)
|
||||
- [添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)
|
||||
- [本地代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
# CC Switch v3.16.4
|
||||
|
||||
> 🎉 **CC Switch is now in the global top 100 on GitHub by stars!**
|
||||
> Thank you to every user, contributor, and Star — you brought it here. 🙏
|
||||
|
||||
> After v3.16.3 made usage billing accurate, this release shifts the focus to polishing the Codex proxy chain and enriching the usage / pricing tooling — migrating Chinese providers to native Responses, decoupling the upstream-format selector from model mapping, decompressing zstd request / error bodies, and a batch of tool-call and OAuth-through-proxy fixes — while also adding local proxy request overrides, an in-app recovery screen when the database version is too new, native Windows ARM64 builds, and a wave of preset and branding updates (SubRouter, OpenCode Go, the CTok→ETok rename, the Kimi brand refresh, and a prime-partner badge).
|
||||
|
||||
**[中文版 →](v3.16.4-zh.md) | [日本語版 →](v3.16.4-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Usage Guides
|
||||
|
||||
This release is mostly polish and expansion, with the new capabilities landing mainly in the usage dashboard and the provider form's advanced options. The following docs are worth reading alongside it:
|
||||
|
||||
- **[Can't see custom models in the Codex desktop app?](../guides/codex-desktop-custom-model-visibility-en.md)**: many users report that their configured third-party / custom models do not show up in the Codex desktop app's model picker. This is the Codex desktop app's **own upstream gating behavior** (it gates the model picker by official login state), not a CC Switch local-config problem, and **this release (v3.16.4) does not change it**. The doc explains the cause and the available mitigation (keep official login + route takeover).
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources and how the statistics are counted. This release adds bulk import of model pricing from models.dev, AK/SK usage queries for Volcengine Ark Coding / Agent Plan, and a live end time for custom date ranges.
|
||||
- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: local proxy request overrides (custom headers / request body), the Codex upstream-format selector, the local routing toggle, and more live in the provider form's advanced options.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## Only Official Channels (Please Read)
|
||||
>
|
||||
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||
>
|
||||
> | Channel | Only Official |
|
||||
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.16.4 is a maintenance update following v3.16.3. This release tightens the Codex proxy chain — switching several Chinese providers that have native OpenAI Responses endpoints to the native format (dropping the Responses→Chat route-takeover conversion), promoting "upstream format" out of the "local routing" toggle into its own control, adding decompression for zstd request and error response bodies, and fixing a string of tool-call and "OAuth module bypassing the global proxy" issues.
|
||||
|
||||
Alongside that, this release enriches the usage and pricing tooling (import pricing from models.dev, AK/SK usage queries for Volcengine Ark Coding / Agent Plan, a live end time for custom date ranges, GLM-5.2 and Doubao Seed 2.1 pricing), adds a batch of proxy and resilience capabilities (custom header / request-body overrides, an in-app recovery screen when the database version is too new, native Windows ARM64 builds), and brings a wave of preset and branding updates (SubRouter and OpenCode Go subscriptions, the CTok→ETok rename, the Kimi brand refresh and prime-partner badge, and a Kimi K2.7 Code sponsor banner).
|
||||
|
||||
**Release date**: 2026-06-27
|
||||
|
||||
**Stats**: 53 commits | 126 files changed | +8,149 / -1,016 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Native Responses for Chinese Codex providers**: Qwen / DashScope, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, and MiniMax (domestic / international) now connect directly to their native Responses endpoints instead of going through the Responses→Chat format-conversion takeover, for a shorter and more stable chain.
|
||||
- **Local proxy request overrides**: providers can configure custom header and request-body overrides, applied by the local proxy when forwarding, with interception validation that blocks protected security headers.
|
||||
- **In-app recovery screen for a too-new database**: when the SQLite version is newer than the current app supports, you no longer get stuck in a native dialog where "retry just fails again"; instead you are guided to a recovery screen that can upgrade the app in one click.
|
||||
- **Richer usage / pricing tooling**: bulk import of model pricing from models.dev, AK/SK usage queries for Volcengine Ark Coding / Agent Plan, a live end time for custom date ranges, and pricing for GLM-5.2 and Doubao Seed 2.1.
|
||||
- **New presets and branding updates**: added SubRouter and OpenCode Go subscription presets, renamed CTok to ETok, refreshed the Kimi brand mark, and added a prime-partner heart badge to the official Kimi presets.
|
||||
- **Native Windows ARM64 builds**: release artifacts now include native ARM64 builds, so ARM Windows devices no longer depend on x64 emulation.
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### In-App Recovery Screen for a Too-New Database
|
||||
|
||||
When the SQLite `user_version` is newer than the current app's supported `SCHEMA_VERSION` (for example after downgrading to an older release, or because a third-party client wrote the file), startup used to die in a native "retry / quit" dialog — where "retry" just fails again. The app now routes to a dedicated recovery screen: when an update is available it offers a one-click "Upgrade App" button (download + install + restart, with a progress bar), and when none is available it explains that even the latest version cannot read this database. The "too new" check runs before any write to the database, so the app never runs DDL against a database it cannot understand; a native close in recovery mode exits cleanly (the tray has not been created yet). ([#4575](https://github.com/farion1231/cc-switch/pull/4575))
|
||||
|
||||
### Local Proxy Request Overrides (Custom Headers and Request Body)
|
||||
|
||||
Provider configs can now define custom header and request-body overrides that the local proxy applies when forwarding, exposed via new fields in the Claude and Codex provider forms. Input is validated against a protected-header list that blocks overriding security-sensitive headers. ([#4589](https://github.com/farion1231/cc-switch/pull/4589))
|
||||
|
||||
### Volcengine Ark Coding / Agent Plan Usage Queries
|
||||
|
||||
The usage panel can now query Volcengine Ark's Coding Plan and Agent Plan quotas. Because the Ark control-plane OpenAPI (`open.volcengineapi.com`) requires an account-level AccessKey signature rather than an inference API key, the usage script gains a dedicated AK/SK input area with a clickable link straight to the Volcengine IAM key-management console (`https://console.volcengine.com/iam/keymanage`); the proxy implements Volcengine Signature V4 (an AWS SigV4 variant: a fixed canonical-header order, the `HMAC-SHA256` algorithm, and the `ark` service scope). It first probes `GetAFPUsage` (the Agent Plan's 5-hour / weekly / monthly quotas) to auto-detect the plan and falls back to `GetCodingPlanUsage`, parsing the window label from the `Level` field (with a guard for `ResetTimestamp <= 0`), and adds the `monthly` tier label across the usage footer, the tray menu, and all four locales.
|
||||
|
||||
### Import Model Pricing from models.dev
|
||||
|
||||
The "Add Pricing" panel gains an "Import from models.dev" button: it fetches `https://models.dev/api.json`, supports full-text search across the entire catalog, and imports the selected entries through the same `update_model_pricing` path as manual entry. Imported model ids are normalized by the backend's `clean_model_id_for_pricing` rules (strip the provider prefix, lowercase, truncate the `:` suffix, map `@` to `-`, drop the `[1m]` marker) so the persisted rows actually match cost-attribution queries. A companion fix changes "backfill zero-cost over a range" to match in Rust by raw model alias (route prefixes, `:free` variants, date suffixes) rather than by exact SQL string match, so newly priced alias rows are priced immediately instead of waiting for the next startup backfill (fixes [#4017](https://github.com/farion1231/cc-switch/issues/4017)). ([#4079](https://github.com/farion1231/cc-switch/pull/4079))
|
||||
|
||||
### Native Windows ARM64 Builds
|
||||
|
||||
Release artifacts now include native Windows ARM64 builds, so ARM Windows devices can grab the matching native build instead of relying on x64 emulation. The release matrix now also runs each platform independently (fail-fast disabled), so a job that fails for a missing secret (e.g. macOS signing in a fork) no longer cancels its still-running siblings. ([#3950](https://github.com/farion1231/cc-switch/pull/3950))
|
||||
|
||||
### Live End Time for Custom Date Ranges
|
||||
|
||||
The custom date-range picker gains a "follow the current time as the end time" checkbox; when enabled, the end time becomes read-only and tracks now, so usage data always reflects the live consumption from the chosen start to the present moment. This is especially useful within the Coding Plan's 5-hour quota window. `liveEndTime` is now part of the React Query cache key, so a live range and a fixed range with the same endpoint no longer share the same stale cache entry. ([#4438](https://github.com/farion1231/cc-switch/pull/4438))
|
||||
|
||||
### Source File Name in the Session Detail Header
|
||||
|
||||
The session detail header now shows the session log's file name next to the project directory (hover for the full path, click to copy), so you can locate and open the underlying JSONL file directly from the UI. For long file names without spaces, such as the ~70-character Codex rollout names, it truncates at `max-w-[200px]` to avoid overflowing into the action buttons in a narrow window. ([#4113](https://github.com/farion1231/cc-switch/pull/4113))
|
||||
|
||||
### Unmanaged-Skill Hint on the Import Button
|
||||
|
||||
The Skills import button in the top bar now shows a green dot and a tooltip when there are unmanaged Skills on disk available to import, so you can tell at a glance that a Skill on disk hasn't been brought under management yet. The scan runs once on mount and is shared across navigations (30s `staleTime` + `keepPreviousData`) to avoid redundant disk IO.
|
||||
|
||||
### OpenCode Go Subscription Presets
|
||||
|
||||
Added the OpenCode Go (`opencode.ai/zen/go`) preset, covering Claude, Codex, and OpenCode, using a paste-ready bare API key (no OAuth). The Codex preset uses `openai_chat` conversion with a GLM / Kimi / DeepSeek / MiMo model catalog (and without a static `codexChatReasoning`, inferring each model's capabilities), while OpenCode points at `/zen/go/v1` via `@ai-sdk/openai-compatible`. All four OpenCode Go presets — Claude, Claude Desktop, Codex, and OpenCode — carry the referral link and in-app promotion copy; the promotion banner now shows on `partnerPromotionKey` alone (no longer bound to `isPartner`), so a preset can surface a referral promotion without earning the gold paid-partner star (which incidentally brings the existing MiniMax promotion back into view).
|
||||
|
||||
### Prime-Partner Preset Badge and Sorting
|
||||
|
||||
The first-party Moonshot Kimi presets (Kimi / Kimi For Coding / Kimi K2.7 Code) are now marked as prime partners: instead of the gold star they render a solid gold heart (no badge border) and, in the default (Original) sort, float to just after the official-category presets and before the rest. The grouping is done with a three-way partition that keeps each group's internal order, and an official preset that is also marked prime-partner stays only in the official group.
|
||||
|
||||
### GLM-5.2 and Doubao Seed 2.1 Pricing
|
||||
|
||||
The seed model pricing now includes GLM-5.2 ([#4385](https://github.com/farion1231/cc-switch/pull/4385)) and Doubao Seed 2.1 Pro / Turbo, so these models' usage is priced correctly instead of being recorded at zero cost. Doubao prices use Volcengine's official list pricing (converted at roughly 7.14); `cache_creation` stays at 0 because Doubao bills cache storage by time rather than by token writes, and the existing 2.0 rows are retained for historical accounting.
|
||||
|
||||
### Kimi For Coding Auto-Compact Window
|
||||
|
||||
The Kimi For Coding preset now defaults `CLAUDE_CODE_AUTO_COMPACT_WINDOW` to 262144, matching Kimi's official documentation, and exposes it via `templateValues` so users can customize the value for future models or performance tuning. ([#4401](https://github.com/farion1231/cc-switch/pull/4401))
|
||||
|
||||
### SubRouter Partner Provider
|
||||
|
||||
Added SubRouter (`subrouter.ai`, an AI relay aggregator that lets one key reach many models across many providers) as a preset covering all seven managed apps — an Anthropic-format endpoint for Claude Code / Claude Desktop / OpenClaw / Hermes, an OpenAI-compatible `/v1` endpoint (`gpt-5.5`) for Codex and OpenCode, and a Gemini-compatible `/v1beta` endpoint (`gemini-3.5-flash`) for Gemini CLI — with its own brand icon, a gold partner star, four-language promotion copy, and a referral signup link prefilled to the API-key registration page (`?aff=l3ri`). ([#4522](https://github.com/farion1231/cc-switch/pull/4522))
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Chinese Codex Providers Use the Native Responses API
|
||||
|
||||
Several Chinese providers (Qwen / DashScope, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, MiniMax domestic / international) now expose native OpenAI Responses endpoints, so their Codex presets switch to `apiFormat: "openai_responses"`, connecting directly to the upstream instead of going through the Responses→Chat route-takeover conversion. Dropping the no-longer-needed `codexChatReasoning` and `modelCatalog` also keeps the "local routing mapping" toggle unchecked by default. SiliconFlow-hosted MiniMax stays on `openai_chat` because that is a third-party endpoint, not MiniMax's own base_url. The remaining chat-based providers also refreshed stale model ids (GLM 5.1→5.2, StepFun 3.5-flash-2603→3.7-flash, Ling 2.5-1T→2.6-1T).
|
||||
|
||||
### Upstream-Format Selector Decoupled from the Model-Mapping Toggle
|
||||
|
||||
The Codex provider form previously bound Chat format conversion and route takeover (model mapping) to the same toggle, which meant a provider offering a native Responses API couldn't use model mapping without forcing Chat Completions conversion. "Upstream format" (Chat Completions / Responses) is now a separate, always-visible selector, while the local routing toggle only controls the advanced subsection (the model-mapping catalog, plus reasoning capabilities when the format is Chat). Its initial state is derived from whether a saved catalog exists, adding no new persisted field; the four-language (zh / en / ja / zh-TW) `codexConfig` copy was rewritten to match.
|
||||
|
||||
### Doubao Seed 2.1 Pro Preset
|
||||
|
||||
The DouBaoSeed preset now points to `doubao-seed-2-1-pro` (replacing `doubao-seed-2-0-code-preview-latest`) across all six clients (claude, claude-desktop, codex, opencode, openclaw, hermes), updates the display name to "Doubao Seed 2.1 Pro", and corrects the OpenClaw cost fields from 0.002 / 0.006 to 0.84 / 4.2 USD per million tokens to match the new model.
|
||||
|
||||
### CTok Renamed to ETok
|
||||
|
||||
Following the vendor's domain, endpoint, and trademark rename, all user-facing branding migrates from CTok to ETok (`ctok.ai`→`etok.ai`, `api.ctok.ai`→`api.etok.ai`, plus the internal id, display name, icon, and README partner banner), across every client preset. The Codex history-migration whitelist still keeps `ctok` as a legacy id alongside the new `etok`, so existing users' local session history stays correctly bucketed after the rename.
|
||||
|
||||
### Kimi Preset Naming Unified
|
||||
|
||||
The Kimi presets that OpenCode and OpenClaw previously labeled "Kimi K2.7 Code" are renamed to "Kimi" to match the other apps (OpenCode's provider display name is renamed too); the model label still keeps "Kimi K2.7 Code" because it describes the actual model.
|
||||
|
||||
### JSON Editor Dark Mode
|
||||
|
||||
The CodeMirror `JsonEditor` in the usage-script dialog, the provider form, and the universal provider form now follows the app theme via `useDarkMode()`, switching to the `oneDark` editor theme instead of staying light while the rest of the app is already dark. ([#4556](https://github.com/farion1231/cc-switch/pull/4556))
|
||||
|
||||
### More Compact "Add Provider" Header and Footer Hint
|
||||
|
||||
The "Add Provider" dialog tightens the vertical spacing from the title to the tabs and from the tabs to the cards from 24px to 12px, and adds an always-visible fixed footer hint guiding users to fill in the fields below after choosing a preset. `FullScreenPanel` gains an optional `contentClassName` prop so the padding override applies only to this panel without affecting other panels that share it.
|
||||
|
||||
### Theme-Adaptive Kimi Mark
|
||||
|
||||
The inline Kimi placeholder mark is replaced with the vendor's refreshed mark. The K glyph uses `currentColor` so it follows the theme text color (dark in light mode, white in dark mode), while the brand accent color is fixed to the new `#1783FF`, with the metadata fallback color aligned accordingly.
|
||||
|
||||
### Removed the Fable 5 Verified Banner
|
||||
|
||||
The Settings About page no longer shows the Fable 5 Verified commemorative banner that 3.16.3 added beside the app name to mark a special build; the banner image and its marker are removed, and the About panel returns to the standard version-badge layout.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Copilot / Codex OAuth Requests Now Honor the Global Proxy
|
||||
|
||||
`CopilotAuthManager` and `CodexOAuthManager` hardcoded `Client::new()` at construction, so their auth flows (token exchange, fetching the `/models` list, determining model vendor, device-code and OAuth refresh requests) ignored the configured global proxy and connected directly to the target services. On Copilot, a direct connection made `/models` return 0 Claude models, breaking live model resolution, and the upstream rejected requests with `400 model_not_supported`. Both managers now pull from the shared client on each request (`crate::proxy::http_client::get()`), honoring the global proxy URL and supporting runtime hot reload. Fixes [#2016](https://github.com/farion1231/cc-switch/issues/2016) and [#2931](https://github.com/farion1231/cc-switch/issues/2931). ([#4583](https://github.com/farion1231/cc-switch/pull/4583))
|
||||
|
||||
### Decompressing Compressed Request and Error Bodies
|
||||
|
||||
Codex Desktop sends zstd-compressed request bodies when authenticating to the Codex backend, which broke local proxy routing because the handlers parsed the raw compressed bytes directly with `serde_json`. The proxy now decompresses the request body before JSON parsing (gzip / br / deflate, plus the newly added zstd support, including stacked encodings like `gzip, zstd`), across three Codex handlers, and strips the stale `content-encoding` / `content-length` / `transfer-encoding` request headers so the forwarder regenerates them. Upstream non-2xx error bodies are decompressed the same way, so compressed rate-limit and auth details are no longer dropped and hidden from the client. Fixes [#3764](https://github.com/farion1231/cc-switch/issues/3764) and [#3696](https://github.com/farion1231/cc-switch/issues/3696). ([#3817](https://github.com/farion1231/cc-switch/pull/3817))
|
||||
|
||||
### DeepSeek Endpoint 400 with `thinking: disabled`
|
||||
|
||||
DeepSeek's Anthropic-compatible endpoint rejects requests where `thinking.type=disabled` coexists with an effort parameter, returning HTTP 400, which broke Claude Code 2.1.166+ sub-agents (Workflow / Dynamic Workflow) that hardcode `thinking: disabled`. Rather than overriding the client's intent, the proxy now strips the conflicting `output_config.effort` / `reasoning_effort` parameters for the official DeepSeek endpoint, since sub-agents don't need to surface reasoning anyway. ([#4239](https://github.com/farion1231/cc-switch/pull/4239))
|
||||
|
||||
### Reverted Hoisting Anthropic system Messages
|
||||
|
||||
Reverted the [#3775](https://github.com/farion1231/cc-switch/pull/3775) change that hoisted `role=system` messages on Anthropic-compatible providers from `messages[]` up to the top-level `system` field. The DeepSeek endpoint natively accepts inline system messages, and the rewrite changed the request prefix; keeping messages in place preserves the prompt prefix and avoids a suspected cache-hit-rate regression (see [#4297](https://github.com/farion1231/cc-switch/issues/4297)). The unrelated Windows test fix and the tool-thinking-history normalization from #3775 are retained.
|
||||
|
||||
### Chat Tool Calls Missing Function Names
|
||||
|
||||
Some upstreams send empty or missing function names in streaming tool-call deltas, which used to produce invalid Codex Chat output items (or an `unknown_tool` fallback). Accumulated tool-call state is no longer overwritten by an empty delta, and tool calls that never receive a `call_id` and a valid name are skipped at finalization, across the streaming, non-streaming, and legacy `function_call` paths. ([#4159](https://github.com/farion1231/cc-switch/pull/4159))
|
||||
|
||||
### Restore Cached Codex Tool-Call Fields
|
||||
|
||||
When Codex makes a follow-up Chat request that references a `previous_response_id`, its `function_call` items may carry only the `call_id`. History enhancement previously backfilled only `reasoning` / `reasoning_content`, leaving the function's `name`, `arguments`, `status`, and other fields empty; it now restores all cached tool-call fields from history so the call can be correctly reconstructed for the Chat upstream. ([#4160](https://github.com/farion1231/cc-switch/pull/4160))
|
||||
|
||||
### Duplicate Codex base_url Entries in config.toml
|
||||
|
||||
Writing Codex's `base_url` into `config.toml` previously replaced or removed only one matching assignment per section, so a section that already contained multiple `base_url` lines kept the extras and accumulated duplicates. `setCodexBaseUrl` now collapses all matches in the target section or at the top level (replacing the first, removing the rest), and the TOML `base_url` regex now handles escaped quotes. ([#4316](https://github.com/farion1231/cc-switch/pull/4316))
|
||||
|
||||
### History Migration Probes the CODEX_SQLITE_HOME State DB
|
||||
|
||||
Codex session-history migration previously scanned only `~/.codex/state_5.sqlite` and the `sqlite_home` location in `config.toml`, so when Codex's SQLite state was relocated via the `CODEX_SQLITE_HOME` environment variable, the state DB was never scanned and its threads stayed in the old provider bucket. The `codex_state_db_paths` helper shared by both the third-party and unified-session migrations now falls back to `CODEX_SQLITE_HOME` (the `sqlite_home` in `config` still takes precedence).
|
||||
|
||||
### Provider Terminal Honors the User Shell
|
||||
|
||||
Launching a provider terminal on macOS / Linux previously hardcoded `bash`, so zsh / fish users' rc files weren't loaded. The launcher now detects the user's default shell from `$SHELL` (falling back to `/bin/zsh` on macOS, `/bin/bash` on Linux) and execs into it with the clean-start flag, while the launch script itself now uses POSIX `sh` for portability (e.g. fish, and NixOS where `/bin/sh` may not exist). ([#4140](https://github.com/farion1231/cc-switch/pull/4140), fixes [#1546](https://github.com/farion1231/cc-switch/issues/1546))
|
||||
|
||||
### Claude MCP Paths Honor the Custom Config Directory
|
||||
|
||||
When a custom Claude config directory is configured, MCP server reads and writes now resolve to the MCP file under that directory instead of the default location, isolating MCP state per profile. The old "copy on access" migration of the legacy file was removed in favor of resolving the override path directly. ([#3431](https://github.com/farion1231/cc-switch/pull/3431))
|
||||
|
||||
### Preset Results Clickable After Search
|
||||
|
||||
After searching in the "Add Provider" preset selector, results briefly couldn't be clicked or selected. The `requestAnimationFrame` `select()` that fought the input and swallowed the first character (e.g. "gateway" → "ateway") was removed, input auto-focus on the open-and-click path was restored, and pressing Ctrl/Cmd+F while the search box is already open now refocuses it. The provider list's typing guard was also narrowed to the Ctrl/Cmd+F branch so Escape can still close the search panel. ([#4315](https://github.com/farion1231/cc-switch/pull/4315))
|
||||
|
||||
### Skills Browsing and Provider Card Display Fixes
|
||||
|
||||
Fixed several display and interaction issues: repository management actions stay available while browsing skills.sh, and refresh stays available when a repository returns empty results; overly long provider names and website URLs on provider cards now truncate instead of overflowing; the OMO model-variant dropdown truncates the selected label with a full-text tooltip; and Select menu items show a checkmark on the currently selected item. ([#4323](https://github.com/farion1231/cc-switch/pull/4323))
|
||||
|
||||
### Reset Scroll When Switching Settings Tabs
|
||||
|
||||
Switching tabs in the Settings dialog used to keep the previous tab's scroll position, sometimes landing halfway down the new tab; the scroll container now resets to the top whenever the active tab changes. ([#4165](https://github.com/farion1231/cc-switch/pull/4165))
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### Kimi Pinned Sponsor Banner
|
||||
|
||||
The pinned sponsor banner at the top of all four README languages (en / zh / ja / de) is now Kimi K2.7 Code, replacing the previous MiniMax M2.7 banner. The copy reflects the K2.7 Code release (a coding-oriented agentic model with thinking-token usage down roughly 30% from K2.6), the banner is now served from in-repo assets (`assets/partners/banners/kimi-banner-en.png` / `kimi-banner-zh.png`) instead of the Moonshot CDN, and it carries a clickable call to action pointing at the `aff=cc-switch` Moonshot console.
|
||||
|
||||
### Codex Unified Session History Guide
|
||||
|
||||
Added a three-language (zh / en / ja) guide explaining what the unified Codex session history toggle's enable-time migration (when enabled) and ledger-based restore (when disabled) actually do, why session data is never truly deleted (only re-tagged + auto-backed-up), and how to verify whether files really are on disk or were just filed into another provider's drawer. It includes a symptom table for the common "my sessions are gone" misunderstanding and disk-verification commands for macOS / Linux / Windows, and is linked as the first item in the v3.16.3 release notes' "Usage Guides".
|
||||
|
||||
### Simplified Homebrew Install Instructions
|
||||
|
||||
The install guide no longer asks users to run `brew tap farion1231/ccswitch` before `brew install --cask cc-switch`; this deprecated tap step is removed from the en / ja / zh user manuals, and the cask now installs directly. ([#4319](https://github.com/farion1231/cc-switch/pull/4319))
|
||||
|
||||
### Star-History Global Ranking Badge
|
||||
|
||||
Added a star-history global ranking badge next to the existing Trendshift badge across all four README languages, with light / dark theme variants.
|
||||
|
||||
### Volcengine Ark Coding Plan Activity Link
|
||||
|
||||
The "developers in mainland China click here" link in the ByteDance / Volcengine Ark sponsor entry now points to Volcengine's `ai618` activity page, replacing the previous `codingplan` referral URL, across all four README languages.
|
||||
|
||||
### CCSub Sponsor Banner Vector Asset
|
||||
|
||||
Replaced the low-resolution `ccsub.jpg` sponsor logo with the vector `ccsub.svg`, letterboxed from 2046x648 to 2046x850 (roughly 2.406:1) so it matches the other sponsor-table banners and renders at the same 62px height. All four README languages point to the new asset.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### Chinese Codex Providers' Native Responses Migration
|
||||
|
||||
This release switches the Codex presets of several Chinese providers with native Responses endpoints (Qwen / DashScope, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, MiniMax domestic / international) to `openai_responses` and removes their `modelCatalog`. Existing providers already configured from these presets are unaffected and keep their configuration as-is; if you want to switch to native Responses (dropping the format-conversion takeover), re-pick the preset once and save. SiliconFlow-hosted MiniMax stays on `openai_chat` and is not part of this migration.
|
||||
|
||||
### Recovery from a Too-New Database
|
||||
|
||||
If you opened the database with a higher version of CC Switch and then switched back to an older version, the older version will enter the new "database version too new" recovery screen on startup and guide you to upgrade to a version that can read the database. This is expected behavior — upgrading to the latest version restores normal operation.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notice
|
||||
|
||||
This release continues the risk notices from previous versions for reverse-proxy-style features.
|
||||
|
||||
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||
|
||||
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||
|
||||
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
|
||||
|
||||
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to the following contributors for the features and fixes in v3.16.4:
|
||||
|
||||
- [#3817](https://github.com/farion1231/cc-switch/pull/3817): decompress the request body before forwarding and add zstd support, thanks @chenx-dust.
|
||||
- [#4583](https://github.com/farion1231/cc-switch/pull/4583): fix the Copilot / Codex OAuth modules bypassing the global proxy and causing Claude model 400s, thanks @zymouse.
|
||||
- [#4589](https://github.com/farion1231/cc-switch/pull/4589): add local proxy request overrides (custom headers and request body), thanks @mfzzf.
|
||||
- [#4575](https://github.com/farion1231/cc-switch/pull/4575): add an in-app recovery screen for a too-new database version, thanks @SaladDay.
|
||||
- [#4556](https://github.com/farion1231/cc-switch/pull/4556): wire dark mode into the JsonEditor in several places, thanks @TanKimzeg.
|
||||
- [#4438](https://github.com/farion1231/cc-switch/pull/4438): add a live end time for custom date ranges, thanks @arichyx.
|
||||
- [#3950](https://github.com/farion1231/cc-switch/pull/3950): add Windows ARM64 release support, thanks @MOON-DREAM-STARS.
|
||||
- [#4401](https://github.com/farion1231/cc-switch/pull/4401): add CLAUDE_CODE_AUTO_COMPACT_WINDOW to the Kimi For Coding preset, thanks @cyijun.
|
||||
- [#4323](https://github.com/farion1231/cc-switch/pull/4323): fix the Skills management and model-config interaction display, thanks @thisTom.
|
||||
- [#3431](https://github.com/farion1231/cc-switch/pull/3431): align Claude MCP paths to the custom config directory, thanks @makoMakoGo.
|
||||
- [#4159](https://github.com/farion1231/cc-switch/pull/4159): skip Chat tool calls missing function names, thanks @hueifeng.
|
||||
- [#4385](https://github.com/farion1231/cc-switch/pull/4385): add glm-5.2 pricing, thanks @arichyx.
|
||||
- [#4079](https://github.com/farion1231/cc-switch/pull/4079): support importing model pricing from models.dev, thanks @kingcanfish.
|
||||
- [#4315](https://github.com/farion1231/cc-switch/pull/4315): fix preset results not being clickable / selectable after search, thanks @RuixeWolf.
|
||||
- [#4316](https://github.com/farion1231/cc-switch/pull/4316): prevent duplicate Codex base_url entries, thanks @jeffwcx.
|
||||
- [#4140](https://github.com/farion1231/cc-switch/pull/4140): make the provider terminal honor the user shell, thanks @zkforge.
|
||||
- [#4113](https://github.com/farion1231/cc-switch/pull/4113): show the source file name in the session detail header, thanks @xu-song.
|
||||
- [#4160](https://github.com/farion1231/cc-switch/pull/4160): restore cached Codex tool-call fields, thanks @chen-985211.
|
||||
- [#4239](https://github.com/farion1231/cc-switch/pull/4239): strip the effort parameter when thinking:disabled on DeepSeek endpoints, thanks @maskshell.
|
||||
- [#4165](https://github.com/farion1231/cc-switch/pull/4165): reset scroll when switching settings tabs, thanks @Muleizhang.
|
||||
- [#4319](https://github.com/farion1231/cc-switch/pull/4319): remove the deprecated Homebrew tap step, thanks @tianpeng-dev.
|
||||
- [#4522](https://github.com/farion1231/cc-switch/pull/4522): add the SubRouter provider preset, thanks @abingyyds.
|
||||
|
||||
Thanks also to everyone who reported Codex proxy chain, usage billing, local proxy robustness, and platform compatibility issues after the v3.16.3 release. Many of these patches came directly from real-world reproduction clues.
|
||||
|
||||
---
|
||||
|
||||
## Download & Install
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 and later | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ------------------------------------------------ |
|
||||
| `CC-Switch-v3.16.4-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.16.4-Windows-Portable.zip` | Portable build, unzip and run |
|
||||
|
||||
Windows ARM64 devices should pick the artifact whose file name carries the `arm64` tag.
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.16.4-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||
| `CC-Switch-v3.16.4-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.16.4-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||
|
||||
Homebrew install:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Upgrade:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||
|
||||
- `CC-Switch-v3.16.4-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.4-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| Distribution | Recommended Format | Install Command |
|
||||
| --------------------------------------- | ------------------ | --------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,356 @@
|
||||
# CC Switch v3.16.4
|
||||
|
||||
> 🎉 **CC Switch が GitHub の全世界 Star ランキングでトップ 100 入り!**
|
||||
> ここまで支えてくださったすべてのユーザー、コントリビューター、そして Star に感謝します。🙏
|
||||
|
||||
> v3.16.3 で「使用量の課金を正確にする」ことに取り組んだのに続き、本リリースは Codex プロキシ経路の磨き込みと、使用量 / 価格ツールの拡充に重きを置いています——国産プロバイダーのネイティブ Responses への移行、上流の形式セレクタとモデルマッピングの分離、zstd リクエスト / エラーボディの展開、そしてツール呼び出しと OAuth がプロキシを経由するようにする一連の修正です。あわせて、ローカルプロキシのリクエストオーバーライド、データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面、ネイティブ Windows ARM64 ビルドを新設し、一連のプリセットとブランドの更新(SubRouter、OpenCode Go、CTok→ETok の改名、Kimi のブランド刷新と prime-partner バッジ)を届けます。
|
||||
|
||||
**[English →](v3.16.4-en.md) | [中文版 →](v3.16.4-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースは磨き込みと拡張が中心で、新しい機能の多くは使用量パネルとプロバイダーフォームの高度なオプションに収まっています。以下のドキュメントとあわせてご覧ください:
|
||||
|
||||
- **[Codex デスクトップでカスタムモデルが見えない?](../guides/codex-desktop-custom-model-visibility-ja.md)**: Codex デスクトップアプリで、設定したサードパーティ / カスタムモデルが見えないというフィードバックが少なくありません。これは Codex デスクトップアプリ**上流自身のゲーティング挙動**(公式ログイン状態に応じてモデルセレクタを通す)であり、CC Switch のローカル設定の問題ではありません。**本リリース(v3.16.4)でこの点に変更はありません**。ドキュメントでは原因と、使える緩和策(公式ログインの保持 + ルーティングテイクオーバー)を解説しています。
|
||||
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 使用量ダッシュボードのデータソースと集計の仕組みを確認できます。本リリースでは models.dev からのモデル価格一括インポート、火山方舟 Coding / Agent Plan の AK/SK 使用量照会、カスタム日付範囲の「リアルタイム終了時刻」を追加しました。
|
||||
- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: ローカルプロキシのリクエストオーバーライド(カスタムリクエストヘッダー / リクエストボディ)、Codex の上流形式セレクタやローカルルーティングのトグルは、いずれもプロバイダーフォームの高度なオプションにあります。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一の公式チャネル(必ずお読みください)
|
||||
>
|
||||
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||
>
|
||||
> | チャネル | 唯一の公式 |
|
||||
> | ------------ | ------------------------------------------------------------------------------ |
|
||||
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.16.4 は v3.16.3 に続くメンテナンスアップデートです。本リリースは Codex プロキシ経路まわりを一通り締め直しました——ネイティブの OpenAI Responses endpoint を備える複数の国産プロバイダーをネイティブ形式へ切り替え(Responses→Chat のルーティングテイクオーバー変換を省く)、「上流形式」を「ローカルルーティング」トグルから独立させ、zstd のリクエストとエラーレスポンスボディの展開を補い、ツール呼び出しと「OAuth モジュールがグローバルプロキシをバイパスする」一連の問題を修正しました。
|
||||
|
||||
あわせて本リリースでは使用量と価格のツールを拡充し(models.dev からの価格インポート、火山方舟 Coding / Agent Plan の AK/SK 使用量照会、カスタム日付範囲のリアルタイム終了時刻、GLM-5.2 と Doubao Seed 2.1 の価格)、一連のプロキシと堅牢性の機能を新設し(カスタムリクエストヘッダー / リクエストボディのオーバーライド、データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面、ネイティブ Windows ARM64 ビルド)、一連のプリセットとブランドの更新(SubRouter と OpenCode Go のサブスクリプション、CTok→ETok の改名、Kimi のブランド刷新と prime-partner バッジ、Kimi K2.7 Code スポンサーバナー)を届けます。
|
||||
|
||||
**リリース日**: 2026-06-27
|
||||
|
||||
**Stats**: 53 commits | 126 files changed | +8,149 / -1,016 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **国産 Codex プロバイダーがネイティブ Responses を使用**: 千問 / 百炼、小米 MiMo、火山 Doubao、美団 LongCat、MiniMax(国内 / 国際)が、それぞれのネイティブ Responses endpoint に直結するようになり、Responses→Chat の形式変換テイクオーバーを経由しなくなりました。経路が短く、より安定します。
|
||||
- **ローカルプロキシのリクエストオーバーライド**: プロバイダーにカスタムリクエストヘッダーとリクエストボディのオーバーライドを設定でき、ローカルプロキシが転送時に適用します。保護対象のセキュリティ関連リクエストヘッダーにはブロック検証を行います。
|
||||
- **データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面**: SQLite のバージョンが現在のアプリのサポート範囲より新しいとき、「再試行しても再び失敗するだけ」のネイティブダイアログで詰まらず、ワンクリックでアプリを更新できるリカバリ画面へ案内します。
|
||||
- **より充実した使用量 / 価格ツール**: models.dev からのモデル価格一括インポート、火山方舟 Coding / Agent Plan の AK/SK 使用量照会、カスタム日付範囲の「リアルタイム終了時刻」、そして GLM-5.2 と Doubao Seed 2.1 の価格。
|
||||
- **新しいプリセットとブランド更新**: SubRouter と OpenCode Go のサブスクリプションプリセットを追加し、CTok を ETok へ改名し、Kimi のブランドアイコンを刷新し、公式 Kimi プリセットに prime-partner のハートバッジを付けました。
|
||||
- **ネイティブ Windows ARM64 ビルド**: 配布物にネイティブ ARM64 版を追加し、ARM アーキテクチャの Windows デバイスは x64 エミュレーションに頼る必要がなくなりました。
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面
|
||||
|
||||
SQLite の `user_version` が現在のアプリのサポートする `SCHEMA_VERSION` より新しいとき(旧版へダウングレードした、あるいはサードパーティクライアントがこのファイルを書いた場合など)、これまでは起動時にネイティブの「再試行 / 終了」ダイアログで詰まっていました——しかし「再試行」は再び失敗するだけです。現在はアプリが専用のリカバリ画面へ案内します: 利用可能な更新があればワンクリックの「アプリを更新」ボタン(ダウンロード + インストール + 再起動、プログレスバー付き)を提供し、利用可能な更新がない場合は最新版であってもこのデータベースを読めない旨を案内します。この「バージョンが新しすぎる」チェックは、あらゆる書き込み動作の前に行われるため、アプリが読めないデータベースに対して DDL を実行することは決してありません。リカバリモードでのネイティブな終了はクリーンに終了します(この時点ではトレイがまだ作成されていません)。([#4575](https://github.com/farion1231/cc-switch/pull/4575))
|
||||
|
||||
### ローカルプロキシのリクエストオーバーライド(カスタムリクエストヘッダーとリクエストボディ)
|
||||
|
||||
プロバイダー設定で、カスタムリクエストヘッダーとリクエストボディのオーバーライドを定義できるようになり、ローカルプロキシが転送時に適用します。Claude と Codex のプロバイダーフォームの新しいフィールドから公開します。入力は検証を経て、その中にセキュリティに敏感なリクエストヘッダーの上書きを防ぐ保護対象リクエストヘッダーのリストを含みます。([#4589](https://github.com/farion1231/cc-switch/pull/4589))
|
||||
|
||||
### 火山方舟 Coding / Agent Plan 使用量照会
|
||||
|
||||
使用量パネルから火山方舟(Volcengine Ark)の Coding Plan と Agent Plan のクォータを照会できるようになりました。方舟のコントロールプレーン OpenAPI(`open.volcengineapi.com`)が要求するのは推論 API key ではなくアカウント単位の AccessKey 署名であるため、使用量スクリプトに独立した AK/SK 入力欄を新設し、火山 IAM のキー管理コンソール(`https://console.volcengine.com/iam/keymanage`)へ直接飛べるクリック可能なリンクを添えました。プロキシは火山署名 V4(AWS SigV4 の変種: 固定された canonical header 順、`HMAC-SHA256` アルゴリズム、`ark` サービス scope)を実装しています。まず `GetAFPUsage`(Agent Plan の 5 時間 / 週 / 月クォータ)をプローブしてプランを自動判定し、失敗した場合は `GetCodingPlanUsage` へフォールバックして `Level` フィールドからウィンドウラベルを解析し(`ResetTimestamp <= 0` にはガードを設けます)、あわせて使用量フッター、トレイメニュー、4 言語に `monthly` 階層のラベルを補いました。
|
||||
|
||||
### models.dev からのモデル価格インポート
|
||||
|
||||
「価格を追加」パネルに「models.dev からインポート」ボタンを新設しました: `https://models.dev/api.json` を取得し、カタログ全体の全文検索に対応し、選択した項目を手入力と同じ `update_model_pricing` 経路でインポートします。インポートされた model id は、バックエンドの `clean_model_id_for_pricing` ルール(プロバイダープレフィックスの除去、小文字化、`:` サフィックスの切り捨て、`@` を `-` へマッピング、`[1m]` マーカーの除去)で正規化されるため、保存される行がコスト帰属クエリと本当にマッチするようになります。あわせて、「範囲ごとのゼロコストバックフィル」を、精密な SQL 文字列マッチではなく Rust 側で元の model エイリアス(ルーティングプレフィックス、`:free` 変種、日付サフィックス)でマッチするように修正したため、新しい価格のエイリアス行が次回起動時のバックフィルを待たず即座に課金されるようになりました([#4017](https://github.com/farion1231/cc-switch/issues/4017) を修正)。([#4079](https://github.com/farion1231/cc-switch/pull/4079))
|
||||
|
||||
### ネイティブ Windows ARM64 ビルド
|
||||
|
||||
配布物にネイティブの Windows ARM64 制品が含まれるようになり、ARM アーキテクチャの Windows デバイスは対応するネイティブビルドを入手でき、x64 エミュレーションに頼る必要がなくなりました。リリースマトリクスも各プラットフォームが独立して走るように変更し(fail-fast を無効化)、あるジョブがキー欠如で失敗しても(fork での macOS 署名など)、まだ完了していない同列のジョブをまとめてキャンセルしないようにしました。([#3950](https://github.com/farion1231/cc-switch/pull/3950))
|
||||
|
||||
### カスタム日付範囲のリアルタイム終了時刻
|
||||
|
||||
カスタム日付範囲セレクタに「終了時刻を現在時刻に追従」チェックボックスを新設しました。有効にすると終了時刻は読み取り専用になり、今この瞬間に自動追従するため、使用量データは選択した起点から現在までのリアルタイムの消費を常に反映します。これは Coding Plan の 5 時間クォータウィンドウで特に有用です。`liveEndTime` は React Query のキャッシュキーに取り込んだため、リアルタイム範囲と終点が同じ固定範囲が同一の古いキャッシュ項目を共有することはなくなりました。([#4438](https://github.com/farion1231/cc-switch/pull/4438))
|
||||
|
||||
### セッション詳細ヘッダーにソースファイル名を表示
|
||||
|
||||
セッション詳細ヘッダーが、プロジェクトディレクトリの隣にセッションログのファイル名を表示するようになりました(ホバーで完全パスを確認、クリックでコピー)。これにより、画面から直接、基礎となる JSONL ファイルを特定して開けます。~70 文字の Codex rollout のような空白を含まない長いファイル名は `max-w-[200px]` で切り詰め、狭いウィンドウで操作ボタン領域へあふれ出るのを防ぎます。([#4113](https://github.com/farion1231/cc-switch/pull/4113))
|
||||
|
||||
### インポートボタンの未管理 Skill ヒント
|
||||
|
||||
トップバーの Skills インポートボタンが、ローカルにインポート可能な未管理の Skill が存在するとき、緑のドットとヒントを表示するようになり、ディスク上の Skill がまだ管理対象になっていないことが一目で分かります。このスキャンはマウント時に一度実行され、複数のナビゲーションをまたいで共有され(30s の `staleTime` + `keepPreviousData`)、ディスク IO の重複を避けます。
|
||||
|
||||
### OpenCode Go サブスクリプションプリセット
|
||||
|
||||
OpenCode Go(`opencode.ai/zen/go`)プリセットを追加し、Claude、Codex、OpenCode をカバーし、そのまま貼り付けられる素の API key(OAuth なし)を使用します。Codex プリセットは `openai_chat` 変換を使い、GLM / Kimi / DeepSeek / MiMo のモデルカタログを備え(静的な `codexChatReasoning` は付けず、モデルごとに能力を推論します)、OpenCode は `@ai-sdk/openai-compatible` 経由で `/zen/go/v1` を指します。4 つの OpenCode Go プリセット——Claude、Claude Desktop、Codex、OpenCode——にはいずれも紹介リンクとアプリ内宣伝文を付けました。宣伝バナーは `partnerPromotionKey` だけで表示できるようになり(`isPartner` への紐付けを解除)、あるプリセットが金色の有料パートナースターを得ずに紹介宣伝を表示できるようになりました(これにより既存の MiniMax 宣伝も再表示されます)。
|
||||
|
||||
### Prime-Partner プリセットバッジとソート
|
||||
|
||||
第一方 Moonshot Kimi プリセット(Kimi / Kimi For Coding / Kimi K2.7 Code)が prime partner としてマークされるようになりました: 金色のスターは表示せず、塗りつぶしの金色ハート(バッジ枠なし)を描画し、既定(Original)ソートでは公式カテゴリプリセットの後、その他より前に浮かびます。グルーピングは 3 方向の partition で実装し、各グループは内部順序を保ち、prime-partner としてもマークされた公式プリセットは公式グループにのみ残ります。
|
||||
|
||||
### GLM-5.2 と Doubao Seed 2.1 の価格
|
||||
|
||||
シードモデル価格に GLM-5.2([#4385](https://github.com/farion1231/cc-switch/pull/4385))と Doubao Seed 2.1 Pro / Turbo を追加し、これらのモデルの使用量がゼロコストではなく正しく課金されるようにしました。Doubao の価格は火山公式の定価を採用し(約 7.14 のレートで換算)、`cache_creation` は 0 のままです。Doubao はキャッシュストレージを token 書き込みではなく時間で課金するためで、既存の 2.0 行も過去の記帳のために残します。
|
||||
|
||||
### Kimi For Coding 自動圧縮ウィンドウ
|
||||
|
||||
Kimi For Coding プリセットが `CLAUDE_CODE_AUTO_COMPACT_WINDOW` を既定で 262144 に設定するようになり、Kimi 公式ドキュメントと一致させ、`templateValues` 経由で公開して、将来のモデルや性能チューニングのためにユーザーがこの値をカスタマイズできるようにしました。([#4401](https://github.com/farion1231/cc-switch/pull/4401))
|
||||
|
||||
### SubRouter パートナープロバイダー
|
||||
|
||||
SubRouter(`subrouter.ai`、1 つの key で複数モデル・複数プロバイダーにアクセスできる AI 中継アグリゲーター)をプリセットとして追加し、管理対象の 7 アプリすべてをカバーしました——Claude Code / Claude Desktop / OpenClaw / Hermes 向けには Anthropic 形式 endpoint、Codex と OpenCode 向けには OpenAI 互換の `/v1` endpoint(`gpt-5.5`)、Gemini CLI 向けには Gemini 互換の `/v1beta` endpoint(`gemini-3.5-flash`)——自前のブランドアイコン、金色のパートナースター、4 言語の宣伝文、そして API key の登録ページへ事前入力された紹介登録リンク(`?aff=l3ri`)を備えます。([#4522](https://github.com/farion1231/cc-switch/pull/4522))
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### 国産 Codex プロバイダーがネイティブ Responses API を使用
|
||||
|
||||
複数の国産プロバイダー(千問 / DashScope 百炼、小米 MiMo、火山 Doubao、美団 LongCat、MiniMax 国内 / 国際)がネイティブの OpenAI Responses endpoint を公開したため、それらの Codex プリセットを `apiFormat: "openai_responses"` へ切り替え、Responses→Chat のルーティングテイクオーバー変換を経由せず上流に直結するようにしました。不要になった `codexChatReasoning` と `modelCatalog` を外したことで、「ローカルルーティングマッピング」トグルも既定で未選択のままになります。SiliconFlow がホストする MiniMax は `openai_chat` のままです。これは MiniMax 自身の base_url ではなくサードパーティの endpoint だからです。引き続き chat を使う他のプロバイダーも、古くなった model id を更新しました(GLM 5.1→5.2、StepFun 3.5-flash-2603→3.7-flash、Ling 2.5-1T→2.6-1T)。
|
||||
|
||||
### 上流形式セレクタとモデルマッピングトグルの分離
|
||||
|
||||
Codex プロバイダーフォームは以前、Chat 形式変換とルーティングテイクオーバー(モデルマッピング)を同じトグルに束ねていたため、ネイティブ Responses API を提供するプロバイダーが Chat Completions 変換を強制せずにモデルマッピングを使うことができませんでした。現在は「上流形式」(Chat Completions / Responses)が独立して常に見えるセレクタになり、ローカルルーティングトグルは高度なサブ領域(モデルマッピングカタログ、および形式が Chat のときの推論能力)の制御だけを担います。その初期状態は保存済みカタログの有無から導かれ、永続化フィールドは増やしません。`codexConfig` の 4 言語(zh / en / ja / zh-TW)の文言もあわせて書き直しました。
|
||||
|
||||
### Doubao Seed 2.1 Pro プリセット
|
||||
|
||||
DouBaoSeed プリセットが、6 つのクライアントすべて(claude、claude-desktop、codex、opencode、openclaw、hermes)で `doubao-seed-2-1-pro` を指すようになり(`doubao-seed-2-0-code-preview-latest` を置き換え)、表示名を「Doubao Seed 2.1 Pro」に更新し、OpenClaw のコストフィールドを新モデルに合わせて 0.002 / 0.006 から 0.84 / 4.2 ドル毎 100 万 token へ訂正しました。
|
||||
|
||||
### CTok を ETok へ改名
|
||||
|
||||
ベンダーによるドメイン・endpoint・商標の改名に合わせ、ユーザーに見えるブランドをすべて CTok から ETok へ移行しました(`ctok.ai`→`etok.ai`、`api.ctok.ai`→`api.etok.ai`、および内部 id、表示名、アイコン、README パートナーバナー)。各クライアントプリセットを網羅します。Codex 履歴移行のホワイトリストでは、改名後も既存ユーザーのローカルセッション履歴が正しく分類されるよう、旧 id の `ctok` を新しい `etok` と並存させたまま残します。
|
||||
|
||||
### Kimi プリセットの命名統一
|
||||
|
||||
OpenCode と OpenClaw で以前「Kimi K2.7 Code」とマークされていた Kimi プリセットを、他のアプリと一致する「Kimi」へ改名しました(OpenCode のプロバイダー表示名もあわせて改名)。モデルラベルは引き続き「Kimi K2.7 Code」のままです。これは実際のモデルを表しているためです。
|
||||
|
||||
### JSON エディタのダークモード
|
||||
|
||||
使用量スクリプトのダイアログ、プロバイダーフォーム、ユニバーサルプロバイダーフォーム内の CodeMirror `JsonEditor` が、`useDarkMode()` を通じてアプリのテーマに追従し、`oneDark` エディタテーマへ切り替わるようになり、アプリの他の部分がすでにダークなのにライトのままになることがなくなりました。([#4556](https://github.com/farion1231/cc-switch/pull/4556))
|
||||
|
||||
### よりコンパクトな「プロバイダーを追加」のタイトルとフッターヒント
|
||||
|
||||
「プロバイダーを追加」ダイアログで、タイトルからタブ、タブからカードへの縦方向の間隔を 24px から 12px へ詰め、プリセットを選んだ後に下のフィールドを記入するよう案内する、常に見える固定フッターヒントを新設しました。`FullScreenPanel` には任意の `contentClassName` プロパティを追加し、パディングの上書きをこのパネルだけに作用させ、これを共有する他のパネルに影響しないようにしました。
|
||||
|
||||
### テーマ追従の Kimi アイコン
|
||||
|
||||
インラインの Kimi プレースホルダーマーカーを、ベンダーが刷新したアイコンへ置き換えました。K 字形は `currentColor` を使うため、テーマのテキスト色に追従し(ライトモードは濃く、ダークモードは白く)、ブランドのアクセント色は新しい `#1783FF` に固定し、メタデータのフォールバック色もそれに合わせました。
|
||||
|
||||
### Fable 5 Verified 記念バナーの削除
|
||||
|
||||
設定の「バージョン情報」ページが、3.16.3 で特別ビルドを示すためにアプリ名の隣に付けていた Fable 5 Verified 記念バナーを表示しなくなりました。バナー画像とそのマーカーを削除し、「バージョン情報」パネルは標準のバージョンバッジレイアウトに戻りました。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### Copilot / Codex OAuth リクエストがグローバルプロキシに従うように
|
||||
|
||||
`CopilotAuthManager` と `CodexOAuthManager` は構築時に `Client::new()` をハードコードしていたため、それらの認証フロー(token の交換、`/models` リストの取得、model vendor の判定、device-code と OAuth のリフレッシュリクエスト)が設定済みのグローバルプロキシを無視し、対象サービスへ直結していました。Copilot では、直結により `/models` が Claude モデルを 0 個返し、live のモデル解決が失効し、上流が `400 model_not_supported` でリクエストを拒否していました。現在は両 manager が、リクエストのたびに共有クライアント(`crate::proxy::http_client::get()`)からその場で取得するように変更され、グローバルプロキシ URL に従い、ランタイムのホット更新にも対応します。[#2016](https://github.com/farion1231/cc-switch/issues/2016)、[#2931](https://github.com/farion1231/cc-switch/issues/2931) を修正。([#4583](https://github.com/farion1231/cc-switch/pull/4583))
|
||||
|
||||
### 圧縮されたリクエストボディとエラーボディの展開
|
||||
|
||||
Codex Desktop は Codex バックエンドへ認証するとき zstd 圧縮のリクエストボディを送ります。これがローカルプロキシのルーティングを壊していました。ハンドラーが生の圧縮バイトをそのまま `serde_json` で解析していたためです。プロキシは現在、JSON 解析の前にリクエストボディを展開し(gzip / br / deflate に加え、新たに zstd に対応、`gzip, zstd` のような積み重ねエンコーディングを含む)、3 つの Codex ハンドラーをカバーし、古くなった `content-encoding` / `content-length` / `transfer-encoding` リクエストヘッダーを剥がして転送器に再生成させます。上流の非 2xx のエラーボディも同様に展開されるため、圧縮されたレート制限や認証の詳細がクライアントに対して破棄・隠蔽されることがなくなりました。[#3764](https://github.com/farion1231/cc-switch/issues/3764)、[#3696](https://github.com/farion1231/cc-switch/issues/3696) を修正。([#3817](https://github.com/farion1231/cc-switch/pull/3817))
|
||||
|
||||
### DeepSeek endpoint で `thinking: disabled` のときの 400 エラー
|
||||
|
||||
DeepSeek の Anthropic 互換 endpoint は、`thinking.type=disabled` と effort パラメータが共存するリクエストを HTTP 400 で拒否します。これは Claude Code 2.1.166+ で `thinking: disabled` をハードコードするサブ agent(Workflow / Dynamic Workflow)を壊していました。プロキシは現在、クライアントの意図を上書きするのではなく、公式 DeepSeek endpoint に対しては競合する `output_config.effort` / `reasoning_effort` パラメータを剥がします。サブ agent はそもそも推論の表示を必要としないためです。([#4239](https://github.com/farion1231/cc-switch/pull/4239))
|
||||
|
||||
### Anthropic system メッセージの引き上げをロールバック
|
||||
|
||||
Anthropic 互換プロバイダーの `role=system` メッセージを `messages[]` からトップレベルの `system` フィールドへ引き上げる [#3775](https://github.com/farion1231/cc-switch/pull/3775) の変更をロールバックしました。DeepSeek endpoint はそもそもインラインの system メッセージをネイティブに受け付けますが、この書き換えはリクエストのプレフィックスを変えてしまいました。メッセージを元の位置に保つことで prompt プレフィックスを保持し、一見キャッシュヒット率の後退と思われる現象を回避します([#4297](https://github.com/farion1231/cc-switch/issues/4297) を参照)。#3775 由来の、無関係な Windows テスト修正と tool-thinking-history の正規化は残します。
|
||||
|
||||
### Chat ツール呼び出しの関数名欠落
|
||||
|
||||
一部の上流は、ストリーミングのツール呼び出し増分で空の、または欠落した関数名を送ります。これは以前、無効な Codex Chat の出力項(または `unknown_tool` フォールバック)を生んでいました。現在は累積したツール呼び出し状態が空の増分で上書きされることがなくなり、最後まで `call_id` と有効な名前を得られなかったツール呼び出しは最終化フェーズでスキップされます。ストリーミング、非ストリーミング、旧版 `function_call` の 3 経路をカバーします。([#4159](https://github.com/farion1231/cc-switch/pull/4159))
|
||||
|
||||
### Codex のキャッシュされたツール呼び出しフィールドの復元
|
||||
|
||||
Codex が `previous_response_id` を参照する後続の Chat リクエストを発行するとき、その `function_call` 項が `call_id` だけを携える場合があります。履歴拡張は以前 `reasoning` / `reasoning_content` だけをバックフィルし、関数の `name`、`arguments`、`status` などのフィールドを空のまま残していました。現在は履歴からキャッシュされたツール呼び出しフィールドをすべて復元し、その呼び出しを Chat 上流向けに正しく再構築できるようにします。([#4160](https://github.com/farion1231/cc-switch/pull/4160))
|
||||
|
||||
### config.toml 内の重複した Codex base_url 項
|
||||
|
||||
Codex の `base_url` を `config.toml` へ書き込むとき、以前は各セクションで一致する代入を 1 つだけ置換または削除していたため、すでに複数行の `base_url` を含むセクションでは余分な項が残り、重複が累積していました。`setCodexBaseUrl` は現在、対象セクションまたはトップレベルの一致をすべて折りたたみ(最初の 1 つを置換し、残りを削除)、TOML の `base_url` 正規表現もエスケープされた引用符を処理します。([#4316](https://github.com/farion1231/cc-switch/pull/4316))
|
||||
|
||||
### 履歴移行が CODEX_SQLITE_HOME の状態 DB をプローブ
|
||||
|
||||
Codex セッション履歴の移行は、以前 `~/.codex/state_5.sqlite` と `config.toml` の `sqlite_home` の場所だけをスキャンしていたため、Codex の SQLite 状態が `CODEX_SQLITE_HOME` 環境変数で再配置されたとき、状態 DB は一度もスキャンされず、その threads は古いプロバイダーバケットに残ったままでした。サードパーティ移行と統一セッション移行の両方が共有する `codex_state_db_paths` ヘルパーが、現在は `CODEX_SQLITE_HOME` へフォールバックします(`config` 内の `sqlite_home` は引き続き優先)。
|
||||
|
||||
### プロバイダーターミナルがユーザーの shell を尊重
|
||||
|
||||
macOS / Linux でプロバイダーターミナルを起動するとき、以前は `bash` をハードコードしていたため、zsh / fish ユーザーの rc ファイルが読み込まれませんでした。ランチャーは現在、`$SHELL` からユーザーの既定 shell を検出し(macOS は `/bin/zsh`、Linux は `/bin/bash` へフォールバック)、クリーンスタートのフラグ付きで exec します。一方、起動スクリプト自体は移植性のために POSIX `sh` を使うようにしました(fish や、`/bin/sh` が存在しないことのある NixOS など)。([#4140](https://github.com/farion1231/cc-switch/pull/4140)、[#1546](https://github.com/farion1231/cc-switch/issues/1546) を修正)
|
||||
|
||||
### Claude MCP のパスがカスタム設定ディレクトリを尊重
|
||||
|
||||
カスタムの Claude 設定ディレクトリが設定されているとき、MCP server の読み書きが、既定の場所ではなくそのディレクトリ配下の MCP ファイルへ解決されるようになり、MCP の状態が profile ごとに分離されます。旧ファイルに対する以前の「アクセス時コピー」移行は削除し、オーバーライドパスへ直接解決するようにしました。([#3431](https://github.com/farion1231/cc-switch/pull/3431))
|
||||
|
||||
### 検索後にプリセット結果をクリック可能に
|
||||
|
||||
「プロバイダーを追加」のプリセットセレクタで検索した後、結果がクリックも選択もできなくなることがありました。入力と競合して先頭文字を飲み込んでいた(「gateway」→「ateway」など)`requestAnimationFrame` の `select()` を削除し、すぐクリックできる経路の入力オートフォーカスを復元し、検索ボックスが開いているときに Ctrl/Cmd+F を押せば再フォーカスするようにもしました。プロバイダーリストのタイピングガードも Ctrl/Cmd+F 分岐に絞り込み、Escape で引き続き検索パネルを閉じられるようにしました。([#4315](https://github.com/farion1231/cc-switch/pull/4315))
|
||||
|
||||
### Skills ブラウズとプロバイダーカードの表示修正
|
||||
|
||||
いくつかの表示とインタラクションの問題を修正しました: skills.sh をブラウズ中もリポジトリ管理操作が引き続き使え、リポジトリが空の結果を返したときも更新が引き続き使え、プロバイダーカード上の長すぎるプロバイダー名やウェブサイト URL があふれずに切り詰められ、OMO のモデル変種ドロップダウンが選択ラベルを切り詰めて完全な内容をツールチップで示し、Select のメニュー項目が現在選択中の項目にチェックマークを表示します。([#4323](https://github.com/farion1231/cc-switch/pull/4323))
|
||||
|
||||
### 設定タブ切り替え時のスクロールリセット
|
||||
|
||||
設定ダイアログ内でタブを切り替えると前のタブのスクロール位置が引き継がれ、新しいタブの途中で止まることがありました。現在はアクティブなタブが変わるたびに、スクロールコンテナがトップへリセットされます。([#4165](https://github.com/farion1231/cc-switch/pull/4165))
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### Kimi ピン留めスポンサーバナー
|
||||
|
||||
4 言語すべての README(en / zh / ja / de)の冒頭のピン留めスポンサーバナーが、これまでの MiniMax M2.7 バナーに代わって Kimi K2.7 Code になりました。文言は K2.7 Code のリリース(コーディング向けの agentic モデルで、思考 token の使用量が K2.6 比で約 30% 低減)を反映し、バナーは Moonshot CDN ではなくリポジトリ内のリソース(`assets/partners/banners/kimi-banner-en.png` / `kimi-banner-zh.png`)から提供し、`aff=cc-switch` の Moonshot コンソールを指すクリック可能な行動喚起を添えました。
|
||||
|
||||
### Codex 統一セッション履歴ガイド
|
||||
|
||||
3 言語(zh / en / ja)のガイドを新設し、Codex 統一セッション履歴トグルの有効化時の移行(有効化時)と台帳に基づく復元(無効化時)が実際に何をするのか、なぜセッションデータが本当に削除されないのか(マーカーの変更 + 自動バックアップのみ)、そしてファイルが本当にディスク上にあるのか、それとも別のプロバイダードロワーに分類されただけなのかを照合する方法を解説しました。「セッションが消えた」という、よくある誤解に対する症状の対照表と、macOS / Linux / Windows のディスク照合コマンドを含み、v3.16.3 の「利用ガイド」release notes の冒頭項としてリンクしました。
|
||||
|
||||
### Homebrew インストール手順の簡素化
|
||||
|
||||
インストールガイドが、`brew install --cask cc-switch` の前に `brew tap farion1231/ccswitch` を実行するようユーザーに求めなくなりました。この廃止された tap 手順を en / ja / zh のユーザーマニュアルから削除し、cask を直接インストールできるようにしました。([#4319](https://github.com/farion1231/cc-switch/pull/4319))
|
||||
|
||||
### Star-History 世界ランキングバッジ
|
||||
|
||||
4 言語すべての README で、既存の Trendshift バッジの隣に star-history の世界ランキングバッジを新設し、ライト / ダークテーマの変種を付けました。
|
||||
|
||||
### 火山方舟 Coding Plan キャンペーンリンク
|
||||
|
||||
ByteDance / 火山方舟スポンサー項目内の「中国本土の開発者はこちらをクリック」リンクが、これまでの `codingplan` 紹介 URL に代わって火山の `ai618` キャンペーンページを指すようになり、4 言語すべての README をカバーします。
|
||||
|
||||
### CCSub スポンサーバナーのベクター素材
|
||||
|
||||
低解像度の `ccsub.jpg` スポンサーロゴをベクターの `ccsub.svg` へ置き換え、2046x648 のレターボックスから 2046x850(約 2.406:1)へ拡げ、他のスポンサー表バナーと揃えて同じ 62px の高さで描画されるようにしました。4 言語すべての README が新しい素材を指します。
|
||||
|
||||
---
|
||||
|
||||
## アップグレード時の注意
|
||||
|
||||
### 国産 Codex プロバイダーのネイティブ Responses 移行
|
||||
|
||||
本リリースは、ネイティブ Responses endpoint を備える複数の国産プロバイダー(千問 / 百炼、小米 MiMo、火山 Doubao、美団 LongCat、MiniMax 国内 / 国際)の Codex プリセットを `openai_responses` へ切り替え、`modelCatalog` を削除しました。すでにこれらのプリセットをもとに設定済みの既存プロバイダーは影響を受けず、設定はそのまま保たれます。ネイティブ Responses(形式変換テイクオーバーを省く)へ切り替えたい場合は、プリセットからもう一度選び直して保存してください。SiliconFlow がホストする MiniMax は引き続き `openai_chat` を使い、今回の移行の対象外です。
|
||||
|
||||
### データベースのバージョンが新しすぎる場合の復旧
|
||||
|
||||
より高いバージョンの CC Switch でデータベースを開いた後、旧版へ戻した場合、旧版は起動時に新しい「データベースのバージョンが新しすぎる」リカバリ画面に入り、そのデータベースを読めるバージョンへのアップグレードへ案内します。これは期待される動作です——最新版へアップグレードすれば正常に戻ります。
|
||||
|
||||
---
|
||||
|
||||
## リスク通知
|
||||
|
||||
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
|
||||
|
||||
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||
|
||||
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金・コンプライアンス・データ保持に関する規約に従う必要があります。
|
||||
|
||||
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
v3.16.4 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||
|
||||
- [#3817](https://github.com/farion1231/cc-switch/pull/3817): 転送前にリクエストボディを展開し zstd に対応、@chenx-dust に感謝。
|
||||
- [#4583](https://github.com/farion1231/cc-switch/pull/4583): Copilot / Codex OAuth モジュールがグローバルプロキシをバイパスし Claude モデルが 400 になる問題を修正、@zymouse に感謝。
|
||||
- [#4589](https://github.com/farion1231/cc-switch/pull/4589): ローカルプロキシのリクエストオーバーライド(カスタムリクエストヘッダーとリクエストボディ)を追加、@mfzzf に感謝。
|
||||
- [#4575](https://github.com/farion1231/cc-switch/pull/4575): データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面を追加、@SaladDay に感謝。
|
||||
- [#4556](https://github.com/farion1231/cc-switch/pull/4556): 複数箇所の JsonEditor にダークモードを導入、@TanKimzeg に感謝。
|
||||
- [#4438](https://github.com/farion1231/cc-switch/pull/4438): カスタム日付範囲のリアルタイム終了時刻を追加、@arichyx に感謝。
|
||||
- [#3950](https://github.com/farion1231/cc-switch/pull/3950): Windows ARM64 リリースのサポートを追加、@MOON-DREAM-STARS に感謝。
|
||||
- [#4401](https://github.com/farion1231/cc-switch/pull/4401): Kimi For Coding プリセットに CLAUDE_CODE_AUTO_COMPACT_WINDOW を追加、@cyijun に感謝。
|
||||
- [#4323](https://github.com/farion1231/cc-switch/pull/4323): Skills 管理とモデル設定のインタラクション表示を修正、@thisTom に感謝。
|
||||
- [#3431](https://github.com/farion1231/cc-switch/pull/3431): カスタム設定ディレクトリの Claude MCP パスを揃える、@makoMakoGo に感謝。
|
||||
- [#4159](https://github.com/farion1231/cc-switch/pull/4159): 関数名を欠く Chat ツール呼び出しをスキップ、@hueifeng に感謝。
|
||||
- [#4385](https://github.com/farion1231/cc-switch/pull/4385): glm-5.2 の価格を追加、@arichyx に感謝。
|
||||
- [#4079](https://github.com/farion1231/cc-switch/pull/4079): models.dev からのモデル価格インポートに対応、@kingcanfish に感謝。
|
||||
- [#4315](https://github.com/farion1231/cc-switch/pull/4315): プリセット検索後に結果をクリック選択できない問題を修正、@RuixeWolf に感謝。
|
||||
- [#4316](https://github.com/farion1231/cc-switch/pull/4316): 重複した Codex base_url 項を防止、@jeffwcx に感謝。
|
||||
- [#4140](https://github.com/farion1231/cc-switch/pull/4140): プロバイダーターミナルがユーザーの shell を尊重するように、@zkforge に感謝。
|
||||
- [#4113](https://github.com/farion1231/cc-switch/pull/4113): セッション詳細ヘッダーにソースファイル名を表示、@xu-song に感謝。
|
||||
- [#4160](https://github.com/farion1231/cc-switch/pull/4160): Codex のキャッシュされたツール呼び出しフィールドを復元、@chen-985211 に感謝。
|
||||
- [#4239](https://github.com/farion1231/cc-switch/pull/4239): DeepSeek endpoint で thinking:disabled のとき effort パラメータを剥がす、@maskshell に感謝。
|
||||
- [#4165](https://github.com/farion1231/cc-switch/pull/4165): 設定タブ切り替え時にスクロールをリセット、@Muleizhang に感謝。
|
||||
- [#4319](https://github.com/farion1231/cc-switch/pull/4319): 廃止された Homebrew tap 手順を削除、@tianpeng-dev に感謝。
|
||||
- [#4522](https://github.com/farion1231/cc-switch/pull/4522): SubRouter プロバイダープリセットを追加、@abingyyds に感謝。
|
||||
|
||||
v3.16.3 リリース後に Codex プロキシ経路、使用量の課金、ローカルプロキシの堅牢性、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くのパッチは、こうした実際の利用シーンから得られた再現の手がかりに基づいています。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表を参照 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | -------------------------------------------- |
|
||||
| `CC-Switch-v3.16.4-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||
| `CC-Switch-v3.16.4-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||
|
||||
Windows ARM64 デバイスをお使いの場合は、ファイル名に `arm64` 識別子が含まれる対応する制品を選択してください。
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.16.4-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||
| `CC-Switch-v3.16.4-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.16.4-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
Homebrew インストール:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
|
||||
|
||||
- `CC-Switch-v3.16.4-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.4-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
|
||||
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,354 @@
|
||||
# CC Switch v3.16.4
|
||||
|
||||
> 🎉 **CC Switch 跻身 GitHub 全球 Star 排行榜前 100!**
|
||||
> 感谢每一位用户、贡献者与 Star —— 是你们让它走到这里。🙏
|
||||
|
||||
> 继 v3.16.3 把「用量计费做准」之后,这一版把重心放在打磨 Codex 代理链路与丰富用量 / 定价工具上——国产供应商原生 Responses 迁移、上游格式选择器与模型映射解耦、zstd 请求 / 错误体解压,以及一批工具调用与 OAuth 走代理的修复;同时新增本地代理请求覆盖、数据库版本过新时的应用内恢复屏、原生 Windows ARM64 构建,并带来一波预设与品牌更新(SubRouter、OpenCode Go、CTok→ETok 改名、Kimi 品牌刷新与 prime-partner 徽标)。
|
||||
|
||||
**[English →](v3.16.4-en.md) | [日本語版 →](v3.16.4-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
本版以打磨与扩展为主,新增的能力主要落在用量面板与供应商表单的高级选项里,建议结合以下文档了解:
|
||||
|
||||
- **[Codex 桌面看不到自定义模型?](../guides/codex-desktop-custom-model-visibility-zh.md)**:不少用户反馈在 Codex 桌面应用里看不到配置的第三方 / 自定义模型。这是 Codex 桌面应用**上游自身的门控行为**(按官方登录状态放行模型选择器),并非 CC Switch 的本地配置问题,**本版(v3.16.4)未对此做改动**;文档里说明了原因,以及可用的缓解办法(保留官方登录 + 路由接管)。
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版新增了从 models.dev 批量导入模型定价、火山方舟 Coding / Agent Plan 的 AK/SK 用量查询,以及自定义日期范围的「实时结束时间」。
|
||||
- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:本地代理请求覆盖(自定义请求头 / 请求体)、Codex 上游格式选择器与本地路由开关等都在供应商表单的高级选项里。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一官方渠道声明(请务必阅读)
|
||||
>
|
||||
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||
>
|
||||
> | 类别 | 唯一官方 |
|
||||
> | -------- | ------------------------------------------------------------------------------ |
|
||||
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.16.4 是 v3.16.3 之后的一版维护更新。这一版围绕 Codex 代理链路做了一轮收紧——为多家具备原生 OpenAI Responses 端点的国产供应商切换到原生格式(省去 Responses→Chat 的路由接管转换)、把「上游格式」从「本地路由」开关里独立出来、补上 zstd 请求与错误响应体的解压,并修了一串工具调用与「OAuth 模块绕过全局代理」的问题。
|
||||
|
||||
与此同时,本版还丰富了用量与定价工具(从 models.dev 导入定价、火山方舟 Coding / Agent Plan 的 AK/SK 用量查询、自定义日期范围的实时结束时间、GLM-5.2 与豆包 Seed 2.1 定价),新增了一批代理与韧性能力(自定义请求头 / 请求体覆盖、数据库版本过新时的应用内恢复屏、原生 Windows ARM64 构建),并带来一波预设与品牌更新(SubRouter 与 OpenCode Go 订阅、CTok→ETok 改名、Kimi 品牌刷新与 prime-partner 徽标、Kimi K2.7 Code 赞助横幅)。
|
||||
|
||||
**发布日期**:2026-06-27
|
||||
|
||||
**更新规模**:53 commits | 126 files changed | +8,149 / -1,016 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **国产 Codex 供应商走原生 Responses**:千问 / 百炼、小米 MiMo、火山豆包、美团 LongCat、MiniMax(国内 / 国际)现在直连各自的原生 Responses 端点,不再经过 Responses→Chat 的格式转换接管,链路更短、更稳。
|
||||
- **本地代理请求覆盖**:供应商可配置自定义请求头与请求体覆盖,由本地代理在转发时应用,并对受保护的安全请求头做了拦截校验。
|
||||
- **数据库版本过新的应用内恢复屏**:当 SQLite 版本比当前应用支持的更新时,不再死在「重试只会再次失败」的原生弹窗里,而是引导到一个可一键升级应用的恢复界面。
|
||||
- **更丰富的用量 / 定价工具**:从 models.dev 批量导入模型定价、火山方舟 Coding / Agent Plan 的 AK/SK 用量查询、自定义日期范围的「实时结束时间」,以及 GLM-5.2 与豆包 Seed 2.1 的定价。
|
||||
- **新预设与品牌更新**:新增 SubRouter 与 OpenCode Go 订阅预设,CTok 改名为 ETok,刷新 Kimi 品牌标识并为官方 Kimi 预设加上 prime-partner 心形徽标。
|
||||
- **原生 Windows ARM64 构建**:发布产物新增原生 ARM64 版本,ARM 架构的 Windows 设备不再依赖 x64 模拟。
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### 数据库版本过新时的应用内恢复屏
|
||||
|
||||
当 SQLite 的 `user_version` 比当前应用支持的 `SCHEMA_VERSION` 更新时(例如降级回旧版、或被第三方客户端写过该文件),启动过去会死在一个原生的「重试 / 退出」弹窗里——而「重试」只会再次失败。现在应用会引导到一个专门的恢复界面:有可用更新时提供一键「升级应用」按钮(下载 + 安装 + 重启,带进度条),没有可用更新时则提示即便是最新版也读不了这个数据库。该「版本过新」检查在任何写库动作之前进行,因此应用永远不会对一个读不懂的数据库执行 DDL;恢复模式下的原生关闭会干净退出(此时托盘尚未创建)。([#4575](https://github.com/farion1231/cc-switch/pull/4575))
|
||||
|
||||
### 本地代理请求覆盖(自定义请求头与请求体)
|
||||
|
||||
供应商配置现在可以定义自定义请求头与请求体覆盖,由本地代理在转发时应用,并通过 Claude 与 Codex 供应商表单里的新字段暴露。输入会经过校验,其中包含一份受保护的请求头名单,用于阻止覆盖安全敏感的请求头。([#4589](https://github.com/farion1231/cc-switch/pull/4589))
|
||||
|
||||
### 火山方舟 Coding / Agent Plan 用量查询
|
||||
|
||||
用量面板现在可以查询火山方舟(Volcengine Ark)的 Coding Plan 与 Agent Plan 配额。由于方舟控制面 OpenAPI(`open.volcengineapi.com`)要求的是账号级 AccessKey 签名、而非推理 API key,用量脚本新增了独立的 AK/SK 输入区,并配有一个直达火山 IAM 密钥管理控制台(`https://console.volcengine.com/iam/keymanage`)的可点击链接;代理实现了火山签名 V4(一个 AWS SigV4 变体:固定的 canonical header 顺序、`HMAC-SHA256` 算法、`ark` 服务 scope)。它会先探测 `GetAFPUsage`(Agent Plan 的 5 小时 / 周 / 月配额)自动判定套餐,失败再回退到 `GetCodingPlanUsage`,从 `Level` 字段解析窗口标签(并对 `ResetTimestamp <= 0` 做守卫),同时在用量页脚、托盘菜单与四种语言里补上了 `monthly` 档标签。
|
||||
|
||||
### 从 models.dev 导入模型定价
|
||||
|
||||
「添加定价」面板新增了一个「从 models.dev 导入」按钮:拉取 `https://models.dev/api.json`,支持全文搜索整个目录,并通过与手动录入相同的 `update_model_pricing` 路径导入所选条目。导入的 model id 会按后端的 `clean_model_id_for_pricing` 规则归一化(剥供应商前缀、转小写、截断 `:` 后缀、把 `@` 映射为 `-`、丢掉 `[1m]` 标记),让落库的行真正能匹配成本归因查询。配套修复让「按范围回填零成本」改用 Rust 端按原始 model 别名(路由前缀、`:free` 变体、日期后缀)匹配,而不再用精确 SQL 字符串匹配,从而新定价的别名行能立刻被计价、而不必等下次启动回填(修复 [#4017](https://github.com/farion1231/cc-switch/issues/4017))。([#4079](https://github.com/farion1231/cc-switch/pull/4079))
|
||||
|
||||
### 原生 Windows ARM64 构建
|
||||
|
||||
发布产物现在包含原生的 Windows ARM64 制品,ARM 架构的 Windows 设备可以拿到对应的原生构建,不必再依赖 x64 模拟。发布矩阵也改为各平台独立运行(关闭 fail-fast),因此某个任务缺少密钥而失败(例如 fork 里的 macOS 签名)不会再把尚未完成的同级任务一并取消。([#3950](https://github.com/farion1231/cc-switch/pull/3950))
|
||||
|
||||
### 自定义日期范围的实时结束时间
|
||||
|
||||
自定义日期范围选择器新增了一个「结束时间跟随当前时间」勾选框;开启后结束时间变为只读并自动跟随此刻,因此用量数据始终反映从所选起点到当下的实时消耗。这在 Coding Plan 的 5 小时配额窗口里尤其有用。`liveEndTime` 已纳入 React Query 的缓存键,因此一个实时范围和一个端点相同的固定范围不会再共用同一个陈旧缓存项。([#4438](https://github.com/farion1231/cc-switch/pull/4438))
|
||||
|
||||
### 会话详情头显示源文件名
|
||||
|
||||
会话详情头现在会在项目目录旁显示会话日志的文件名(悬停看完整路径、可点击复制),方便用户直接从界面定位并打开底层的 JSONL 文件。对于像 ~70 字符的 Codex rollout 这类没有空格的长文件名,会截断到 `max-w-[200px]`,避免在窄窗口里溢出到操作按钮区。([#4113](https://github.com/farion1231/cc-switch/pull/4113))
|
||||
|
||||
### 导入按钮的未托管 Skill 提示
|
||||
|
||||
顶栏的 Skills 导入按钮现在会在本地存在未托管的 Skill 可导入时显示一个绿点与提示,让你一眼看出磁盘上的 Skill 还没被纳管。该扫描在挂载时执行一次,并在多次导航间共享(30s `staleTime` + `keepPreviousData`),避免重复磁盘 IO。
|
||||
|
||||
### OpenCode Go 订阅预设
|
||||
|
||||
新增 OpenCode Go(`opencode.ai/zen/go`)预设,覆盖 Claude、Codex 与 OpenCode,使用可直接粘贴的纯 API key(无 OAuth)。Codex 预设走 `openai_chat` 转换并带 GLM / Kimi / DeepSeek / MiMo 模型目录(且不带静态 `codexChatReasoning`,按每个模型推断能力),OpenCode 则通过 `@ai-sdk/openai-compatible` 指向 `/zen/go/v1`。四个 OpenCode Go 预设——Claude、Claude Desktop、Codex、OpenCode——都带上了推荐链接与应用内推广文案;推广横幅现在仅凭 `partnerPromotionKey` 即可展示(不再绑定 `isPartner`),因此一个预设可以展示推荐推广却不获得金色付费合作伙伴星标(这也顺带让既有的 MiniMax 推广重新显示出来)。
|
||||
|
||||
### Prime-Partner 预设徽标与排序
|
||||
|
||||
第一方 Moonshot Kimi 预设(Kimi / Kimi For Coding / Kimi K2.7 Code)现在被标记为 prime partner:不再显示金色星标,而是渲染一颗实心金色心形(无徽标边框),并在默认(Original)排序里浮到官方分类预设之后、其余之前。分组用三路 partition 实现,每组保持内部顺序,且一个同时被标为 prime-partner 的官方预设只会留在官方组里。
|
||||
|
||||
### GLM-5.2 与豆包 Seed 2.1 定价
|
||||
|
||||
种子模型定价现在包含 GLM-5.2([#4385](https://github.com/farion1231/cc-switch/pull/4385))与豆包 Seed 2.1 Pro / Turbo,让这些模型的用量被正确计价、而不是记成零成本。豆包价格采用火山官方 list 价(按约 7.14 的汇率折算);`cache_creation` 保持为 0,因为豆包按时间而非按 token 写入计费缓存存储,既有的 2.0 行也保留以供历史记账。
|
||||
|
||||
### Kimi For Coding 自动压缩窗口
|
||||
|
||||
Kimi For Coding 预设现在把 `CLAUDE_CODE_AUTO_COMPACT_WINDOW` 默认设为 262144,与 Kimi 官方文档一致,并通过 `templateValues` 暴露,方便用户为将来的模型或性能调优自定义该值。([#4401](https://github.com/farion1231/cc-switch/pull/4401))
|
||||
|
||||
### SubRouter 合作伙伴供应商
|
||||
|
||||
新增 SubRouter(`subrouter.ai`,一个让一把 key 访问多模型多供应商的 AI 中转聚合商)作为预设,覆盖全部 7 个受管应用——Anthropic 格式端点用于 Claude Code / Claude Desktop / OpenClaw / Hermes,OpenAI 兼容的 `/v1` 端点(`gpt-5.5`)用于 Codex 与 OpenCode,Gemini 兼容的 `/v1beta` 端点(`gemini-3.5-flash`)用于 Gemini CLI——带上自有品牌图标、金色合作伙伴星标、四语推广文案,以及预填为 API key 注册地址的推荐注册链接(`?aff=l3ri`)。([#4522](https://github.com/farion1231/cc-switch/pull/4522))
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### 国产 Codex 供应商走原生 Responses API
|
||||
|
||||
多家国产供应商(千问 / DashScope 百炼、小米 MiMo、火山豆包、美团 LongCat、MiniMax 国内 / 国际)现在暴露了原生的 OpenAI Responses 端点,因此它们的 Codex 预设切换到 `apiFormat: "openai_responses"`,直连上游而不再经过 Responses→Chat 的路由接管转换。丢掉不再需要的 `codexChatReasoning` 与 `modelCatalog` 也让「本地路由映射」开关默认保持未勾选。SiliconFlow 托管的 MiniMax 仍保持 `openai_chat`,因为那是第三方端点、并非 MiniMax 自家 base_url。其余仍走 chat 的供应商也刷新了过期的 model id(GLM 5.1→5.2、StepFun 3.5-flash-2603→3.7-flash、Ling 2.5-1T→2.6-1T)。
|
||||
|
||||
### 上游格式选择器与模型映射开关解耦
|
||||
|
||||
Codex 供应商表单此前把 Chat 格式转换与路由接管(模型映射)绑在同一个开关上,导致一个提供原生 Responses API 的供应商无法在不强制 Chat Completions 转换的情况下使用模型映射。现在「上游格式」(Chat Completions / Responses)成了一个独立、始终可见的选择器,而本地路由开关只负责控制高级子区(模型映射目录,以及格式为 Chat 时的推理能力)。它的初始状态由已保存目录是否存在派生,不新增持久化字段;`codexConfig` 的四语(zh / en / ja / zh-TW)文案也随之重写。
|
||||
|
||||
### 豆包 Seed 2.1 Pro 预设
|
||||
|
||||
DouBaoSeed 预设现在在全部 6 个客户端(claude、claude-desktop、codex、opencode、openclaw、hermes)指向 `doubao-seed-2-1-pro`(替换 `doubao-seed-2-0-code-preview-latest`),展示名更新为「Doubao Seed 2.1 Pro」,并把 OpenClaw 的成本字段从 0.002 / 0.006 订正为 0.84 / 4.2 美元每百万 token 以匹配新模型。
|
||||
|
||||
### CTok 改名为 ETok
|
||||
|
||||
随着厂商对域名、端点与商标的更名,所有面向用户的品牌从 CTok 迁移到 ETok(`ctok.ai`→`etok.ai`、`api.ctok.ai`→`api.etok.ai`,以及内部 id、展示名、图标和 README 合作伙伴横幅),覆盖每一个客户端预设。Codex 历史迁移白名单里仍保留 `ctok` 作为旧 id、与新 `etok` 并存,以保证改名后存量用户的本地会话历史仍被正确分桶。
|
||||
|
||||
### Kimi 预设命名统一
|
||||
|
||||
OpenCode 与 OpenClaw 此前被标为「Kimi K2.7 Code」的 Kimi 预设,更名为与其它应用一致的「Kimi」(OpenCode 的供应商展示名也一并更名);模型标签仍保留「Kimi K2.7 Code」,因为它描述的是实际模型。
|
||||
|
||||
### JSON 编辑器暗色模式
|
||||
|
||||
用量脚本弹窗、供应商表单与通用供应商表单里的 CodeMirror `JsonEditor` 现在会通过 `useDarkMode()` 跟随应用主题,切换到 `oneDark` 编辑器主题,而不再在应用其余部分已是暗色时仍停留在亮色。([#4556](https://github.com/farion1231/cc-switch/pull/4556))
|
||||
|
||||
### 更紧凑的「添加供应商」标题与底部提示
|
||||
|
||||
「添加供应商」对话框把标题到页签、页签到卡片的纵向间距从 24px 收到 12px,并新增一个始终可见的固定底部提示,引导用户在选好预设后填写下方字段。`FullScreenPanel` 新增可选的 `contentClassName` 属性,让内边距覆盖只作用于此面板、不影响其它共用它的面板。
|
||||
|
||||
### 主题自适应的 Kimi 标识
|
||||
|
||||
内联的 Kimi 占位标记替换为厂商刷新后的标识。K 字形使用 `currentColor`,因此会跟随主题文字色(亮色模式深、暗色模式白),而品牌点缀色固定为新的 `#1783FF`,元数据回退色也相应对齐。
|
||||
|
||||
### 移除 Fable 5 Verified 纪念横幅
|
||||
|
||||
设置「关于」页不再显示 3.16.3 为标明特别构建而加在应用名旁的 Fable 5 Verified 纪念横幅;横幅图片及其标记被移除,「关于」面板回到标准的版本徽标布局。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### Copilot / Codex OAuth 请求现在遵循全局代理
|
||||
|
||||
`CopilotAuthManager` 与 `CodexOAuthManager` 在构造时写死了 `Client::new()`,导致它们的认证流程(换 token、拉 `/models` 列表、判定 model vendor、device-code 与 OAuth 刷新请求)无视配置的全局代理、直连目标服务。在 Copilot 上,直连会让 `/models` 返回 0 个 Claude 模型,使 live 模型解析失效,上游以 `400 model_not_supported` 拒绝请求。现在两个 manager 都改为每次请求从共享客户端现取(`crate::proxy::http_client::get()`),从而遵循全局代理 URL 并支持运行时热更新。修复 [#2016](https://github.com/farion1231/cc-switch/issues/2016)、[#2931](https://github.com/farion1231/cc-switch/issues/2931)。([#4583](https://github.com/farion1231/cc-switch/pull/4583))
|
||||
|
||||
### 压缩请求体与错误体的解压
|
||||
|
||||
Codex Desktop 在对 Codex 后端认证时会发送 zstd 压缩的请求体,这会破坏本地代理路由,因为处理器直接用 `serde_json` 解析原始压缩字节。代理现在会在 JSON 解析前对请求体解压(gzip / br / deflate,外加新增的 zstd 支持,包括 `gzip, zstd` 这类堆叠编码),覆盖三个 Codex 处理器,并剥掉过期的 `content-encoding` / `content-length` / `transfer-encoding` 请求头让转发器重新生成。上游非 2xx 的错误体也以同样方式解压,因此压缩过的限流与鉴权细节不再被丢弃、对客户端隐藏。修复 [#3764](https://github.com/farion1231/cc-switch/issues/3764)、[#3696](https://github.com/farion1231/cc-switch/issues/3696)。([#3817](https://github.com/farion1231/cc-switch/pull/3817))
|
||||
|
||||
### DeepSeek 端点 `thinking: disabled` 的 400 错误
|
||||
|
||||
DeepSeek 的 Anthropic 兼容端点会拒绝 `thinking.type=disabled` 与 effort 参数共存的请求、返回 HTTP 400,这会破坏 Claude Code 2.1.166+ 那些硬编码 `thinking: disabled` 的子 agent(Workflow / Dynamic Workflow)。代理现在不是去覆盖客户端的意图,而是对官方 DeepSeek 端点剥掉冲突的 `output_config.effort` / `reasoning_effort` 参数,因为子 agent 本就不需要展示推理。([#4239](https://github.com/farion1231/cc-switch/pull/4239))
|
||||
|
||||
### 回滚 Anthropic system 消息上提
|
||||
|
||||
回滚了 [#3775](https://github.com/farion1231/cc-switch/pull/3775) 把 Anthropic 兼容供应商的 `role=system` 消息从 `messages[]` 上提到顶层 `system` 字段的改动。DeepSeek 端点本就原生接受内联的 system 消息,而该重写改变了请求前缀;保持消息原位能保留 prompt 前缀,避免一处疑似的缓存命中率回退(参见 [#4297](https://github.com/farion1231/cc-switch/issues/4297))。来自 #3775 的、不相关的 Windows 测试修复以及 tool-thinking-history 归一化都保留。
|
||||
|
||||
### Chat 工具调用缺函数名
|
||||
|
||||
一些上游会在流式工具调用增量里发送空的或缺失的函数名,过去这会产生无效的 Codex Chat 输出项(或一个 `unknown_tool` 回退)。现在累积的工具调用状态不会再被空增量覆盖,而那些始终没拿到 `call_id` 与有效名字的工具调用会在最终化阶段被跳过,覆盖流式、非流式与旧版 `function_call` 三条路径。([#4159](https://github.com/farion1231/cc-switch/pull/4159))
|
||||
|
||||
### 恢复 Codex 缓存的工具调用字段
|
||||
|
||||
当 Codex 发起一个引用 `previous_response_id` 的后续 Chat 请求时,它的 `function_call` 项可能只携带 `call_id`。历史增强此前只回填 `reasoning` / `reasoning_content`,留空了函数的 `name`、`arguments`、`status` 等字段;现在它会从历史里恢复全部缓存的工具调用字段,让该调用能为 Chat 上游正确重建。([#4160](https://github.com/farion1231/cc-switch/pull/4160))
|
||||
|
||||
### config.toml 里重复的 Codex base_url 条目
|
||||
|
||||
把 Codex 的 `base_url` 写入 `config.toml` 时此前每个区段只替换或移除一个匹配的赋值,因此一个已经含多行 `base_url` 的区段会留下多余项、累积重复。`setCodexBaseUrl` 现在会折叠目标区段或顶层的所有匹配(替换第一处、移除其余),TOML 的 `base_url` 正则也处理了转义引号。([#4316](https://github.com/farion1231/cc-switch/pull/4316))
|
||||
|
||||
### 历史迁移探测 CODEX_SQLITE_HOME 的状态库
|
||||
|
||||
Codex 会话历史迁移此前只扫描 `~/.codex/state_5.sqlite` 与 `config.toml` 的 `sqlite_home` 位置,因此当 Codex 的 SQLite 状态通过 `CODEX_SQLITE_HOME` 环境变量被重定位时,状态库从未被扫描、其 threads 仍留在旧的供应商分桶里。第三方与统一会话两套迁移共用的 `codex_state_db_paths` 辅助函数现在会回退到 `CODEX_SQLITE_HOME`(`config` 里的 `sqlite_home` 仍优先)。
|
||||
|
||||
### 供应商终端尊重用户 shell
|
||||
|
||||
在 macOS / Linux 上启动供应商终端时此前硬编码了 `bash`,导致 zsh / fish 用户的 rc 文件不会加载。启动器现在会从 `$SHELL` 检测用户默认 shell(macOS 回退 `/bin/zsh`、Linux 回退 `/bin/bash`)并以干净启动的 flag exec 进去,而启动脚本本身改走 POSIX `sh` 以保证可移植性(例如 fish,以及 `/bin/sh` 可能不存在的 NixOS)。([#4140](https://github.com/farion1231/cc-switch/pull/4140),修复 [#1546](https://github.com/farion1231/cc-switch/issues/1546))
|
||||
|
||||
### Claude MCP 路径尊重自定义配置目录
|
||||
|
||||
当配置了自定义的 Claude 配置目录时,MCP server 的读写现在会解析到该目录下的 MCP 文件、而非默认位置,让 MCP 状态按 profile 隔离。此前对旧文件的「访问即拷贝」迁移被移除,改为直接解析覆盖路径。([#3431](https://github.com/farion1231/cc-switch/pull/3431))
|
||||
|
||||
### 搜索后预设结果可点击
|
||||
|
||||
在「添加供应商」预设选择器里搜索后,结果一度无法点击或选中。那个与输入打架、会吃掉首字符(如「gateway」→「ateway」)的 `requestAnimationFrame` `select()` 被移除,开箱即点路径的输入自动聚焦被恢复,当搜索框已打开时按 Ctrl/Cmd+F 也接上了重新聚焦。供应商列表的打字守卫也被收窄到 Ctrl/Cmd+F 分支,从而 Escape 仍能关闭搜索面板。([#4315](https://github.com/farion1231/cc-switch/pull/4315))
|
||||
|
||||
### Skills 浏览与供应商卡片显示修复
|
||||
|
||||
修复了若干显示与交互问题:浏览 skills.sh 时仓库管理操作保持可用,仓库返回空结果时刷新也保持可用;供应商卡片上过长的供应商名与网站 URL 现在会截断而非溢出;OMO 模型变体下拉会截断所选标签并配全文提示;Select 菜单项会在当前选中项上显示对勾。([#4323](https://github.com/farion1231/cc-switch/pull/4323))
|
||||
|
||||
### 切换设置页签时重置滚动
|
||||
|
||||
在设置对话框里切换页签会保留上一个页签的滚动位置,有时会停在新页签的中途;现在每当激活页签变化时,滚动容器都会重置到顶部。([#4165](https://github.com/farion1231/cc-switch/pull/4165))
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### Kimi 置顶赞助横幅
|
||||
|
||||
全部四种 README 语言(en / zh / ja / de)顶部的置顶赞助横幅现在换成了 Kimi K2.7 Code,取代此前的 MiniMax M2.7 横幅。文案反映 K2.7 Code 发布(一个面向编程的 agentic 模型,思考 token 用量较 K2.6 降低约 30%),横幅改由仓库内资源(`assets/partners/banners/kimi-banner-en.png` / `kimi-banner-zh.png`)提供、不再走 Moonshot CDN,并附一个指向 `aff=cc-switch` Moonshot 控制台的可点击行动号召。
|
||||
|
||||
### Codex 统一会话历史攻略
|
||||
|
||||
新增三语(zh / en / ja)攻略,讲清统一 Codex 会话历史开关的开启迁移(启用时)与按账本还原(禁用时)到底做了什么、为什么会话数据从不会真正删除(只改标记 + 自动备份),以及如何核对文件是真在磁盘上、还是只是被归到了另一个供应商抽屉里。它包含一张针对常见「我的会话不见了」误解的症状对照表,以及 macOS / Linux / Windows 的磁盘核对命令,并作为首项链入 v3.16.3 的「使用攻略」release notes。
|
||||
|
||||
### 简化 Homebrew 安装说明
|
||||
|
||||
安装指南不再要求用户在 `brew install --cask cc-switch` 之前先运行 `brew tap farion1231/ccswitch`;这个已废弃的 tap 步骤已从 en / ja / zh 用户手册里移除,cask 现在可直接安装。([#4319](https://github.com/farion1231/cc-switch/pull/4319))
|
||||
|
||||
### Star-History 全球排名徽标
|
||||
|
||||
在全部四种 README 语言里、既有的 Trendshift 徽标旁新增了一个 star-history 全球排名徽标,并带亮 / 暗主题变体。
|
||||
|
||||
### 火山方舟 Coding Plan 活动链接
|
||||
|
||||
ByteDance / 火山方舟赞助条目里的「中国大陆地区的开发者请点击这里」链接现在指向火山的 `ai618` 活动页,取代此前的 `codingplan` 推荐 URL,覆盖全部四种 README 语言。
|
||||
|
||||
### CCSub 赞助横幅矢量资源
|
||||
|
||||
把低分辨率的 `ccsub.jpg` 赞助 logo 替换为矢量的 `ccsub.svg`,并从 2046x648 letterbox 到 2046x850(约 2.406:1),使其与其它赞助表横幅匹配、以相同的 62px 高度渲染。全部四种 README 语言都指向新资源。
|
||||
|
||||
---
|
||||
|
||||
## 升级提醒
|
||||
|
||||
### 国产 Codex 供应商原生 Responses 迁移
|
||||
|
||||
本版把多家具备原生 Responses 端点的国产供应商(千问 / 百炼、小米 MiMo、火山豆包、美团 LongCat、MiniMax 国内 / 国际)的 Codex 预设切换为 `openai_responses` 并移除了 `modelCatalog`。已经基于这些预设配置过的存量供应商不受影响、配置保持原样;如果你希望改用原生 Responses(省去格式转换接管),可以重新从预设选择一次并保存。SiliconFlow 托管的 MiniMax 仍走 `openai_chat`,不在此次迁移之列。
|
||||
|
||||
### 数据库版本过新的恢复
|
||||
|
||||
如果你曾用更高版本的 CC Switch 打开过数据库、再切回旧版,旧版启动时会进入新的「数据库版本过新」恢复屏,并引导你升级到能读懂该数据库的版本。这是预期行为——升级到最新版即可恢复正常。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
本版本继续沿用此前版本对反向代理类功能的风险提示。
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
|
||||
|
||||
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下贡献者在 v3.16.4 中提交的功能与修复:
|
||||
|
||||
- [#3817](https://github.com/farion1231/cc-switch/pull/3817):转发前解压请求体并支持 zstd,感谢 @chenx-dust。
|
||||
- [#4583](https://github.com/farion1231/cc-switch/pull/4583):修复 Copilot / Codex OAuth 模块绕过全局代理导致 Claude 模型 400,感谢 @zymouse。
|
||||
- [#4589](https://github.com/farion1231/cc-switch/pull/4589):新增本地代理请求覆盖(自定义请求头与请求体),感谢 @mfzzf。
|
||||
- [#4575](https://github.com/farion1231/cc-switch/pull/4575):新增数据库版本过新时的应用内恢复屏,感谢 @SaladDay。
|
||||
- [#4556](https://github.com/farion1231/cc-switch/pull/4556):为多处 JsonEditor 接入暗色模式,感谢 @TanKimzeg。
|
||||
- [#4438](https://github.com/farion1231/cc-switch/pull/4438):新增自定义日期范围的实时结束时间,感谢 @arichyx。
|
||||
- [#3950](https://github.com/farion1231/cc-switch/pull/3950):新增 Windows ARM64 发布支持,感谢 @MOON-DREAM-STARS。
|
||||
- [#4401](https://github.com/farion1231/cc-switch/pull/4401):为 Kimi For Coding 预设添加 CLAUDE_CODE_AUTO_COMPACT_WINDOW,感谢 @cyijun。
|
||||
- [#4323](https://github.com/farion1231/cc-switch/pull/4323):修复 Skills 管理与模型配置的交互展示,感谢 @thisTom。
|
||||
- [#3431](https://github.com/farion1231/cc-switch/pull/3431):对齐自定义配置目录的 Claude MCP 路径,感谢 @makoMakoGo。
|
||||
- [#4159](https://github.com/farion1231/cc-switch/pull/4159):跳过缺函数名的 Chat 工具调用,感谢 @hueifeng。
|
||||
- [#4385](https://github.com/farion1231/cc-switch/pull/4385):新增 glm-5.2 定价,感谢 @arichyx。
|
||||
- [#4079](https://github.com/farion1231/cc-switch/pull/4079):支持从 models.dev 导入模型定价,感谢 @kingcanfish。
|
||||
- [#4315](https://github.com/farion1231/cc-switch/pull/4315):修复搜索预设后结果无法点击选中,感谢 @RuixeWolf。
|
||||
- [#4316](https://github.com/farion1231/cc-switch/pull/4316):防止重复的 Codex base_url 条目,感谢 @jeffwcx。
|
||||
- [#4140](https://github.com/farion1231/cc-switch/pull/4140):让供应商终端尊重用户 shell,感谢 @zkforge。
|
||||
- [#4113](https://github.com/farion1231/cc-switch/pull/4113):在会话详情头显示源文件名,感谢 @xu-song。
|
||||
- [#4160](https://github.com/farion1231/cc-switch/pull/4160):恢复 Codex 缓存的工具调用字段,感谢 @chen-985211。
|
||||
- [#4239](https://github.com/farion1231/cc-switch/pull/4239):DeepSeek 端点 thinking:disabled 时剥掉 effort 参数,感谢 @maskshell。
|
||||
- [#4165](https://github.com/farion1231/cc-switch/pull/4165):切换设置页签时重置滚动,感谢 @Muleizhang。
|
||||
- [#4319](https://github.com/farion1231/cc-switch/pull/4319):移除已废弃的 Homebrew tap 步骤,感谢 @tianpeng-dev。
|
||||
- [#4522](https://github.com/farion1231/cc-switch/pull/4522):新增 SubRouter 供应商预设,感谢 @abingyyds。
|
||||
|
||||
也感谢所有在 v3.16.3 发布后反馈 Codex 代理链路、用量计费、本地代理稳健性与平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.16.4-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.16.4-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.16.4-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.16.4-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.16.4-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
Homebrew 安装:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||
|
||||
- `CC-Switch-v3.16.4-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.4-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -554,7 +554,7 @@ When editing Claude providers, a set of **quick toggles** is available above the
|
||||
|
||||
When a toggle is unchecked, its corresponding config entry is removed entirely. Changes are reflected in the JSON editor in real-time.
|
||||
|
||||
Additionally, the **Write Common Config** checkbox enables merging a global config snippet into the provider. Click **Edit Common Config** to customize the shared snippet.
|
||||
Additionally, the **Apply Common Config** checkbox enables merging a global config snippet into the provider. Click **Edit Common Config** to customize the shared snippet.
|
||||
|
||||
### Codex Local Routing and Model Mapping
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ Claude プロバイダーの編集時、JSON エディタの上部に **クイ
|
||||
|
||||
トグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。
|
||||
|
||||
また、**共通設定を書き込み** チェックボックスを有効にすると、グローバル設定スニペットをプロバイダーにマージできます。**共通設定を編集** をクリックして共有スニペットをカスタマイズできます。
|
||||
また、**共通設定を適用** チェックボックスを有効にすると、グローバル設定スニペットをプロバイダーにマージできます。**共通設定を編集** をクリックして共有スニペットをカスタマイズできます。
|
||||
|
||||
### Codex ローカルルーティングとモデルマッピング
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ v3.13.0 起新增的高级选项。默认情况下,CC Switch 会把配置的 `
|
||||
|
||||
取消勾选开关时,对应的配置项会被完全移除。更改会实时反映在 JSON 编辑器中。
|
||||
|
||||
此外,**写入通用配置** 复选框可将全局配置片段合并到供应商中。点击 **编辑通用配置** 可自定义共享的配置片段。
|
||||
此外,**应用通用配置** 复选框可将全局配置片段合并到供应商中。点击 **编辑通用配置** 可自定义共享的配置片段。
|
||||
|
||||
### Codex 本地路由与模型映射
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.16.3",
|
||||
"version": "3.16.4",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -758,7 +758,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.16.3"
|
||||
version = "3.16.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -825,6 +825,7 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.16.3"
|
||||
version = "3.16.4"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -43,6 +43,7 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks
|
||||
arboard = "3.6"
|
||||
flate2 = "1"
|
||||
brotli = "7"
|
||||
zstd = "0.13"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
|
||||
@@ -15,6 +15,86 @@ use toml_edit::DocumentMut;
|
||||
|
||||
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
|
||||
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
|
||||
|
||||
/// Top-level `config.toml` key that controls Codex's built-in web-search tool.
|
||||
const CODEX_WEB_SEARCH_FIELD: &str = "web_search";
|
||||
/// Value that disables the web-search tool. Some native `/responses` gateways
|
||||
/// reject a `web_search` tool with `responses_feature_not_supported` ("tool type
|
||||
/// 'web_search' is not supported by this gateway phase"), so for those we write
|
||||
/// this per the vendors' official Codex docs. Also doubles as cc-switch's
|
||||
/// ownership sentinel: we only ever remove a `web_search` key whose value equals
|
||||
/// this string, never a user's own setting.
|
||||
const CODEX_WEB_SEARCH_DISABLED: &str = "disabled";
|
||||
|
||||
/// Native `/responses` gateways whose first-party models do NOT support the Codex
|
||||
/// `web_search` hosted tool. A BLACKLIST (default-on): everything not listed keeps
|
||||
/// Codex's default, so relays/aggregators fronting real GPT — and any unknown
|
||||
/// provider — are never touched. This avoids a whitelist's dangerous failure mode
|
||||
/// (a fragile "is this GPT?" heuristic wrongly keeping web_search ON → hard 400);
|
||||
/// the blacklist's failure mode is the safe, recoverable one (a not-yet-listed
|
||||
/// broken gateway errors once → add it here).
|
||||
///
|
||||
/// Matched two ways so an aggregator (e.g. SiliconFlow) fronting these vendors'
|
||||
/// models is also caught:
|
||||
/// - `base_url` host substring, and
|
||||
/// - the model id's brand prefix (after stripping any `vendor/` path segment).
|
||||
///
|
||||
/// Verified 2026-06-28 doc audit — reject: MiMo (hard 400), LongCat (official
|
||||
/// config ships `web_search = "disabled"`), MiniMax (tool-type enum `['function']`
|
||||
/// only), and Qwen3-Coder models (百炼 marks built-in tools unsupported for
|
||||
/// the coder series). Deliberately NOT listed by host: 火山方舟豆包, general
|
||||
/// 阿里百炼 Qwen models that support built-in web_search, and GPT-native relays.
|
||||
const CODEX_WEB_SEARCH_REJECT_HOSTS: &[&str] = &[
|
||||
"xiaomimimo.com", // Xiaomi MiMo (api.xiaomimimo.com, token-plan-cn.xiaomimimo.com)
|
||||
"longcat.chat", // Meituan LongCat (api.longcat.chat)
|
||||
"minimax.io", // MiniMax global (api.minimax.io)
|
||||
"minimaxi.com", // MiniMax CN (api.minimaxi.com)
|
||||
];
|
||||
|
||||
/// Brand prefixes of models whose native gateways reject `web_search`, matched
|
||||
/// against the model id's last `/`-segment so aggregator ids like
|
||||
/// `MiniMaxAI/MiniMax-M3` are caught. Exact brand names (not a fuzzy heuristic),
|
||||
/// so a supporting gateway is never wrongly matched.
|
||||
const CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES: &[&str] =
|
||||
&["mimo", "longcat", "minimax", "qwen3-coder"];
|
||||
|
||||
/// Top-level `model` id from a Codex `config.toml`.
|
||||
fn codex_top_level_model(config_text: &str) -> Option<String> {
|
||||
let doc = config_text.parse::<toml::Value>().ok()?;
|
||||
doc.get("model")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Whether a native `/responses` provider's gateway is known to reject the Codex
|
||||
/// `web_search` hosted tool — by `base_url` host OR by the active model's brand
|
||||
/// (so an aggregator fronting a reject vendor's model is caught too). Driven by
|
||||
/// the live `config.toml`, so it applies to existing providers without a re-save.
|
||||
fn codex_native_gateway_rejects_web_search(config_text: &str) -> bool {
|
||||
if let Some(base_url) = extract_codex_base_url(config_text) {
|
||||
let base_url = base_url.to_ascii_lowercase();
|
||||
if CODEX_WEB_SEARCH_REJECT_HOSTS
|
||||
.iter()
|
||||
.any(|host| base_url.contains(host))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(model) = codex_top_level_model(config_text) {
|
||||
let model = model.to_ascii_lowercase();
|
||||
// Strip any aggregator "vendor/" prefix, e.g. "MiniMaxAI/MiniMax-M3"
|
||||
// or "qwen/qwen3-coder-plus".
|
||||
let model = model.rsplit('/').next().unwrap_or(model.as_str());
|
||||
if CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| model.starts_with(prefix))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -25,6 +105,33 @@ struct CodexManagedOAuthLiveAuthMarker {
|
||||
access_token_sha256: String,
|
||||
}
|
||||
|
||||
/// Which Codex tool surface the generated model catalog should target.
|
||||
///
|
||||
/// - `ProxyChat`: cc-switch's proxy takes over and converts Responses<->Chat,
|
||||
/// so the catalog keeps Codex's default tool set (incl. the freeform
|
||||
/// `apply_patch` custom tool, which the proxy rewrites to a function tool).
|
||||
/// - `NativeResponses`: Codex talks directly to a provider's native
|
||||
/// `/responses` endpoint (no proxy). Such gateways (e.g. Xiaomi MiMo,
|
||||
/// MiniMax) reject `type=="custom"` tools, so the catalog must suppress the
|
||||
/// freeform `apply_patch` and rely on `shell_type="shell_command"` for edits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CodexCatalogToolProfile {
|
||||
ProxyChat,
|
||||
NativeResponses,
|
||||
}
|
||||
|
||||
impl CodexCatalogToolProfile {
|
||||
/// Pick the catalog tool profile from a provider's `apiFormat` meta value.
|
||||
/// Native (direct) Responses providers must suppress the custom apply_patch
|
||||
/// tool; everything else keeps the proxy-chat behavior.
|
||||
pub fn from_api_format(api_format: Option<&str>) -> Self {
|
||||
match api_format {
|
||||
Some("openai_responses") => CodexCatalogToolProfile::NativeResponses,
|
||||
_ => CodexCatalogToolProfile::ProxyChat,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
|
||||
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
|
||||
/// removed provider aliases.
|
||||
@@ -440,27 +547,63 @@ fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option<u64> {
|
||||
|
||||
fn codex_catalog_model_entry(
|
||||
template: &Value,
|
||||
model: &str,
|
||||
display_name: &str,
|
||||
context_window: u64,
|
||||
spec: &CodexCatalogModelSpec,
|
||||
priority: usize,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Value {
|
||||
let mut entry = template.clone();
|
||||
let Some(entry_obj) = entry.as_object_mut() else {
|
||||
return json!({});
|
||||
};
|
||||
|
||||
entry_obj.insert("slug".to_string(), json!(model));
|
||||
entry_obj.insert("display_name".to_string(), json!(display_name));
|
||||
entry_obj.insert("description".to_string(), json!(display_name));
|
||||
entry_obj.insert("context_window".to_string(), json!(context_window));
|
||||
entry_obj.insert("max_context_window".to_string(), json!(context_window));
|
||||
entry_obj.insert("slug".to_string(), json!(spec.model));
|
||||
entry_obj.insert("display_name".to_string(), json!(spec.display_name));
|
||||
entry_obj.insert("description".to_string(), json!(spec.display_name));
|
||||
entry_obj.insert("context_window".to_string(), json!(spec.context_window));
|
||||
entry_obj.insert("max_context_window".to_string(), json!(spec.context_window));
|
||||
entry_obj.insert("priority".to_string(), json!(1000 + priority));
|
||||
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
|
||||
entry_obj.insert("service_tiers".to_string(), json!([]));
|
||||
entry_obj.insert("availability_nux".to_string(), Value::Null);
|
||||
entry_obj.insert("upgrade".to_string(), Value::Null);
|
||||
|
||||
if profile == CodexCatalogToolProfile::NativeResponses {
|
||||
// Native `/responses` gateways reject Codex's freeform `apply_patch`
|
||||
// (type=="custom") tool. Strip any key that would make Codex emit a
|
||||
// custom/freeform tool, and rely on shell_type="shell_command" for
|
||||
// edits. Defensive even though the native template is already clean
|
||||
// (guards against template drift / an accidental gpt-5.5 clone).
|
||||
//
|
||||
// NOTE: `base_instructions` is NOT stripped — Codex's catalog parser
|
||||
// treats it as a REQUIRED field and refuses to load the file without
|
||||
// it ("missing field `base_instructions`"). The template carries a
|
||||
// neutral identity default; per-vendor official text overrides below.
|
||||
for key in [
|
||||
"apply_patch_tool_type",
|
||||
"web_search_tool_type",
|
||||
"tools",
|
||||
"model_messages",
|
||||
] {
|
||||
entry_obj.remove(key);
|
||||
}
|
||||
entry_obj.insert("shell_type".to_string(), json!("shell_command"));
|
||||
|
||||
if let Some(base_instructions) = spec
|
||||
.base_instructions
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
entry_obj.insert("base_instructions".to_string(), json!(base_instructions));
|
||||
}
|
||||
if let Some(parallel) = spec.supports_parallel_tool_calls {
|
||||
entry_obj.insert("supports_parallel_tool_calls".to_string(), json!(parallel));
|
||||
}
|
||||
if let Some(modalities) = &spec.input_modalities {
|
||||
entry_obj.insert("input_modalities".to_string(), json!(modalities));
|
||||
}
|
||||
}
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
@@ -469,6 +612,18 @@ struct CodexCatalogModelSpec {
|
||||
model: String,
|
||||
display_name: String,
|
||||
context_window: u64,
|
||||
/// Per-row override for the native template's `supports_parallel_tool_calls`
|
||||
/// (e.g. MiniMax=true, MiMo=false). Only consulted for `NativeResponses`.
|
||||
supports_parallel_tool_calls: Option<bool>,
|
||||
/// Per-row override for the native template's `input_modalities`
|
||||
/// (e.g. `["text","image"]`). Only consulted for `NativeResponses`.
|
||||
input_modalities: Option<Vec<String>>,
|
||||
/// Per-row override for the native template's `base_instructions` (the
|
||||
/// model identity / system preamble). Carries each vendor's OFFICIAL value
|
||||
/// (e.g. MiMo "developed by Xiaomi", MiniMax "based on MiniMax-M3"); falls
|
||||
/// back to the template default when absent. Only consulted for
|
||||
/// `NativeResponses`.
|
||||
base_instructions: Option<String>,
|
||||
}
|
||||
|
||||
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
|
||||
@@ -513,10 +668,38 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
|
||||
)
|
||||
.unwrap_or(default_context_window);
|
||||
|
||||
let supports_parallel_tool_calls = model_config
|
||||
.get("supportsParallelToolCalls")
|
||||
.or_else(|| model_config.get("supports_parallel_tool_calls"))
|
||||
.and_then(|value| value.as_bool());
|
||||
let input_modalities = model_config
|
||||
.get("inputModalities")
|
||||
.or_else(|| model_config.get("input_modalities"))
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|items| !items.is_empty());
|
||||
|
||||
let base_instructions = model_config
|
||||
.get("baseInstructions")
|
||||
.or_else(|| model_config.get("base_instructions"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
specs.push(CodexCatalogModelSpec {
|
||||
model: model.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
context_window,
|
||||
supports_parallel_tool_calls,
|
||||
input_modalities,
|
||||
base_instructions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -755,6 +938,18 @@ fn load_codex_model_template_static() -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled clean template for native `/responses` providers. Unlike the
|
||||
/// gpt-5.5 template it carries NO freeform `apply_patch` / `web_search` tool
|
||||
/// declarations and no GPT-5 base_instructions, so Codex never emits a
|
||||
/// `type=="custom"` tool that native gateways (MiMo/MiniMax/…) reject. Edits
|
||||
/// flow through `shell_type="shell_command"` instead. We deliberately do NOT
|
||||
/// fall back to `models_cache.json` here (that would reintroduce gpt-5.5's
|
||||
/// freeform apply_patch).
|
||||
fn load_codex_native_responses_template() -> Value {
|
||||
let text = include_str!("resources/codex_native_responses_template.json");
|
||||
serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON")
|
||||
}
|
||||
|
||||
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
|
||||
// ① models_cache.json (created by Codex when it connects to OpenAI)
|
||||
if let Some(template) = load_codex_model_template_from_cache()? {
|
||||
@@ -774,19 +969,15 @@ fn load_codex_model_catalog_template() -> Result<Value, AppError> {
|
||||
)))
|
||||
}
|
||||
|
||||
fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Value) -> Value {
|
||||
fn codex_model_catalog_from_specs(
|
||||
specs: &[CodexCatalogModelSpec],
|
||||
template: &Value,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Value {
|
||||
let entries: Vec<Value> = specs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, spec)| {
|
||||
codex_catalog_model_entry(
|
||||
template,
|
||||
&spec.model,
|
||||
&spec.display_name,
|
||||
spec.context_window,
|
||||
index,
|
||||
)
|
||||
})
|
||||
.map(|(index, spec)| codex_catalog_model_entry(template, spec, index, profile))
|
||||
.collect();
|
||||
|
||||
json!({ "models": entries })
|
||||
@@ -795,14 +986,23 @@ fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Va
|
||||
fn codex_model_catalog_from_settings(
|
||||
settings: &Value,
|
||||
config_text: &str,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Result<Option<Value>, AppError> {
|
||||
let specs = codex_catalog_model_specs(settings, config_text);
|
||||
if specs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let template = load_codex_model_catalog_template()?;
|
||||
Ok(Some(codex_model_catalog_from_specs(&specs, &template)))
|
||||
// Native providers use the bundled clean template (no freeform apply_patch,
|
||||
// no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5
|
||||
// entry so the proxy can rewrite custom<->function tools as before.
|
||||
let template = match profile {
|
||||
CodexCatalogToolProfile::NativeResponses => load_codex_native_responses_template(),
|
||||
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
|
||||
};
|
||||
Ok(Some(codex_model_catalog_from_specs(
|
||||
&specs, &template, profile,
|
||||
)))
|
||||
}
|
||||
|
||||
fn set_codex_model_catalog_json_field(
|
||||
@@ -835,20 +1035,60 @@ fn set_codex_model_catalog_json_field(
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Pure toggle for the top-level `web_search` field that turns Codex's built-in
|
||||
/// web-search tool off. When `disable` is true we write `web_search = "disabled"`
|
||||
/// (the catalog's `supports_search_tool` does NOT gate this — the request-time
|
||||
/// tool comes from the config, defaulting on). When false we *remove* the field,
|
||||
/// but only when it carries cc-switch's own `"disabled"` sentinel, so switching
|
||||
/// back to a web-search-capable provider re-enables it without clobbering a
|
||||
/// user's manual setting.
|
||||
///
|
||||
/// The caller decides `disable` (see `codex_native_gateway_rejects_web_search`);
|
||||
/// lifecycle is bound to the cc-switch catalog pointer so the field is set/cleaned
|
||||
/// up wherever the native catalog is written/removed.
|
||||
fn set_codex_native_web_search_field(config_text: &str, disable: bool) -> Result<String, AppError> {
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||
|
||||
if disable {
|
||||
doc[CODEX_WEB_SEARCH_FIELD] = toml_edit::value(CODEX_WEB_SEARCH_DISABLED);
|
||||
} else {
|
||||
let owned = doc
|
||||
.get(CODEX_WEB_SEARCH_FIELD)
|
||||
.and_then(|item| item.as_str())
|
||||
== Some(CODEX_WEB_SEARCH_DISABLED);
|
||||
if owned {
|
||||
doc.as_table_mut().remove(CODEX_WEB_SEARCH_FIELD);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Generate Codex `model_catalog_json` from provider settings and inject/remove
|
||||
/// the top-level TOML field that points Codex to the generated file.
|
||||
pub fn prepare_codex_config_text_with_model_catalog(
|
||||
settings: &Value,
|
||||
config_text: &str,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Result<String, AppError> {
|
||||
let catalog_path = get_codex_model_catalog_path();
|
||||
|
||||
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text)? {
|
||||
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text, profile)? {
|
||||
let config_text = set_codex_model_catalog_json_field(config_text, Some(&catalog_path))?;
|
||||
// Disable web_search only for native gateways on the reject blacklist
|
||||
// (MiMo/LongCat/MiniMax by host or model brand; Qwen3-Coder by model).
|
||||
// Everything else — relays, DouBao, web-search-capable Qwen models,
|
||||
// unknown providers — keeps Codex's default.
|
||||
let disable_web_search = profile == CodexCatalogToolProfile::NativeResponses
|
||||
&& codex_native_gateway_rejects_web_search(&config_text);
|
||||
let config_text = set_codex_native_web_search_field(&config_text, disable_web_search)?;
|
||||
write_json_file(&catalog_path, &catalog)?;
|
||||
Ok(config_text)
|
||||
} else {
|
||||
set_codex_model_catalog_json_field(config_text, None)
|
||||
let config_text = set_codex_model_catalog_json_field(config_text, None)?;
|
||||
set_codex_native_web_search_field(&config_text, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +1206,26 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
|
||||
obj.insert("contextWindow".to_string(), json!(context_window));
|
||||
}
|
||||
|
||||
// Preserve native-profile per-row overrides so a DB-SSOT-missing
|
||||
// fallback round-trip doesn't silently drop them (they are ignored by
|
||||
// the ProxyChat profile, so carrying them is harmless).
|
||||
if let Some(parallel) = entry
|
||||
.get("supports_parallel_tool_calls")
|
||||
.and_then(|v| v.as_bool())
|
||||
{
|
||||
obj.insert("supportsParallelToolCalls".to_string(), json!(parallel));
|
||||
}
|
||||
if let Some(modalities) = entry.get("input_modalities").and_then(|v| v.as_array()) {
|
||||
let mods: Vec<String> = modalities
|
||||
.iter()
|
||||
.filter_map(|m| m.as_str())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
if !mods.is_empty() {
|
||||
obj.insert("inputModalities".to_string(), json!(mods));
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(Value::Object(obj));
|
||||
}
|
||||
|
||||
@@ -1003,9 +1263,10 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
|
||||
pub fn prepare_codex_live_config_text_with_optional_catalog(
|
||||
settings: &Value,
|
||||
config_text: &str,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Result<String, AppError> {
|
||||
if settings.get("modelCatalog").is_some() {
|
||||
prepare_codex_config_text_with_model_catalog(settings, config_text)
|
||||
prepare_codex_config_text_with_model_catalog(settings, config_text, profile)
|
||||
} else {
|
||||
Ok(config_text.to_string())
|
||||
}
|
||||
@@ -1016,9 +1277,10 @@ pub fn write_codex_provider_live_with_catalog(
|
||||
category: Option<&str>,
|
||||
auth: &Value,
|
||||
config_text: Option<&str>,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Result<(), AppError> {
|
||||
let prepared_config = config_text
|
||||
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text))
|
||||
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text, profile))
|
||||
.transpose()?;
|
||||
|
||||
write_codex_live_for_provider(category, auth, prepared_config.as_deref())
|
||||
@@ -2310,7 +2572,8 @@ base_url = "https://production.api/v1"
|
||||
}
|
||||
});
|
||||
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
|
||||
let catalog = codex_model_catalog_from_specs(&specs, &template);
|
||||
let catalog =
|
||||
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
|
||||
let models = catalog
|
||||
.get("models")
|
||||
.and_then(|value| value.as_array())
|
||||
@@ -2361,6 +2624,137 @@ base_url = "https://production.api/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_responses_profile_suppresses_apply_patch_and_keeps_shell() {
|
||||
// Native (direct) /responses providers must NOT emit a freeform
|
||||
// apply_patch (type=="custom") tool — gateways like MiMo reject it.
|
||||
// The native profile uses the bundled clean template and relies on
|
||||
// shell_type="shell_command" for edits, plus per-row overrides.
|
||||
let settings = json!({
|
||||
"modelCatalog": {
|
||||
"models": [
|
||||
{
|
||||
"model": "MiniMax-M3",
|
||||
"displayName": "MiniMax-M3",
|
||||
"contextWindow": 1_000_000,
|
||||
"supportsParallelToolCalls": true,
|
||||
"inputModalities": ["text", "image"],
|
||||
"baseInstructions": "You are Codex, a coding agent based on MiniMax-M3."
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let catalog = codex_model_catalog_from_settings(
|
||||
&settings,
|
||||
"",
|
||||
CodexCatalogToolProfile::NativeResponses,
|
||||
)
|
||||
.expect("native catalog generation should not error")
|
||||
.expect("non-empty modelCatalog must yield a catalog");
|
||||
|
||||
let entry = &catalog["models"][0];
|
||||
assert_eq!(
|
||||
entry.get("slug").and_then(|v| v.as_str()),
|
||||
Some("MiniMax-M3")
|
||||
);
|
||||
assert_eq!(
|
||||
entry.get("shell_type").and_then(|v| v.as_str()),
|
||||
Some("shell_command"),
|
||||
"native entries edit via shell, not the custom apply_patch tool"
|
||||
);
|
||||
assert!(
|
||||
entry.get("apply_patch_tool_type").is_none(),
|
||||
"native entries must NOT declare a freeform apply_patch tool"
|
||||
);
|
||||
// `base_instructions` is REQUIRED by Codex's catalog parser, so it must
|
||||
// be present — and the per-row official override must win over the
|
||||
// template default.
|
||||
assert_eq!(
|
||||
entry.get("base_instructions").and_then(|v| v.as_str()),
|
||||
Some("You are Codex, a coding agent based on MiniMax-M3."),
|
||||
"per-row baseInstructions override must apply (and field must exist)"
|
||||
);
|
||||
assert!(
|
||||
entry.get("model_messages").is_none(),
|
||||
"native entries must not carry the gpt-5.5 model_messages persona text"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.get("supports_parallel_tool_calls"),
|
||||
Some(&json!(true)),
|
||||
"per-row supportsParallelToolCalls override must apply"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.get("input_modalities"),
|
||||
Some(&json!(["text", "image"])),
|
||||
"per-row inputModalities override must apply"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.get("context_window").and_then(|v| v.as_u64()),
|
||||
Some(1_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_responses_catalog_always_carries_base_instructions() {
|
||||
// Regression guard for the "missing field `base_instructions`" parse
|
||||
// error: Codex refuses to load a model catalog whose entries lack
|
||||
// base_instructions. Synthesized presets carry no per-row override, so
|
||||
// the entry MUST inherit the template's neutral default rather than
|
||||
// dropping the field entirely.
|
||||
let settings = json!({
|
||||
"modelCatalog": { "models": [{ "model": "qwen3-coder-plus" }] }
|
||||
});
|
||||
|
||||
let catalog = codex_model_catalog_from_settings(
|
||||
&settings,
|
||||
"",
|
||||
CodexCatalogToolProfile::NativeResponses,
|
||||
)
|
||||
.expect("native catalog generation should not error")
|
||||
.expect("non-empty modelCatalog must yield a catalog");
|
||||
|
||||
let base = catalog["models"][0]
|
||||
.get("base_instructions")
|
||||
.and_then(|v| v.as_str());
|
||||
assert!(
|
||||
base.is_some_and(|s| !s.trim().is_empty()),
|
||||
"every native entry must carry a non-empty base_instructions (Codex requires it)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_chat_profile_still_keeps_apply_patch() {
|
||||
// Regression guard for Mode A: the proxy-chat profile must keep the
|
||||
// freeform apply_patch tool (the proxy rewrites custom<->function).
|
||||
let template = load_codex_native_responses_template();
|
||||
let specs = vec![CodexCatalogModelSpec {
|
||||
model: "x".to_string(),
|
||||
display_name: "x".to_string(),
|
||||
context_window: 128_000,
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
}];
|
||||
// Using a gpt-5.5-shaped template under ProxyChat must NOT strip
|
||||
// apply_patch_tool_type. (The native template lacks it, so synthesize
|
||||
// one with the field present to prove ProxyChat leaves it intact.)
|
||||
let mut proxy_template = template.clone();
|
||||
proxy_template["apply_patch_tool_type"] = json!("freeform");
|
||||
let catalog = codex_model_catalog_from_specs(
|
||||
&specs,
|
||||
&proxy_template,
|
||||
CodexCatalogToolProfile::ProxyChat,
|
||||
);
|
||||
assert_eq!(
|
||||
catalog["models"][0]
|
||||
.get("apply_patch_tool_type")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("freeform"),
|
||||
"ProxyChat must preserve apply_patch_tool_type (no native stripping)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_catalog_json_field_writes_relative_filename() {
|
||||
let input = r#"model_provider = "any"
|
||||
@@ -2388,6 +2782,131 @@ name = "any"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_web_search_field_disables_at_top_level() {
|
||||
// Native `/responses` gateways reject the web_search tool, so the
|
||||
// NativeResponses profile must write the top-level disable line even
|
||||
// when sections are present (it must NOT land inside a section).
|
||||
let input = r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "xiaomi_mimo"
|
||||
"#;
|
||||
let result = set_codex_native_web_search_field(input, true).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert_eq!(
|
||||
parsed.get("web_search").and_then(|value| value.as_str()),
|
||||
Some("disabled")
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.get("model_providers")
|
||||
.and_then(|value| value.get("custom"))
|
||||
.and_then(|value| value.get("web_search"))
|
||||
.is_none(),
|
||||
"web_search should stay top-level"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_web_search_field_removes_own_sentinel_when_not_disabled() {
|
||||
// Switching away from a native provider must re-enable web search by
|
||||
// removing cc-switch's own "disabled" sentinel.
|
||||
let input = r#"model = "gpt-5.5"
|
||||
web_search = "disabled"
|
||||
"#;
|
||||
let result = set_codex_native_web_search_field(input, false).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert!(
|
||||
parsed.get("web_search").is_none(),
|
||||
"cc-switch's disabled sentinel should be removed when not native"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_web_search_field_preserves_user_value() {
|
||||
// A user's own web_search value must never be clobbered by cleanup,
|
||||
// only cc-switch's "disabled" sentinel is owned/removable.
|
||||
let input = r#"web_search = "enabled"
|
||||
"#;
|
||||
let result = set_codex_native_web_search_field(input, false).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert_eq!(
|
||||
parsed.get("web_search").and_then(|value| value.as_str()),
|
||||
Some("enabled"),
|
||||
"a user-set web_search value must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_blacklist_disables_only_known_reject_gateways() {
|
||||
let cfg = |model: &str, base_url: &str| {
|
||||
format!(
|
||||
"model_provider = \"custom\"\nmodel = \"{model}\"\n\n[model_providers.custom]\nname = \"x\"\nbase_url = \"{base_url}\"\nwire_api = \"responses\"\n"
|
||||
)
|
||||
};
|
||||
|
||||
// Blacklisted by host (first-party reject gateways) → disable.
|
||||
for (model, host) in [
|
||||
("mimo-v2.5-pro", "https://api.xiaomimimo.com/v1"),
|
||||
("mimo-v2.5", "https://token-plan-cn.xiaomimimo.com/v1"),
|
||||
("LongCat-2.0-Preview", "https://api.longcat.chat/openai/v1"),
|
||||
("MiniMax-M3", "https://api.minimax.io/v1"),
|
||||
("MiniMax-M3", "https://api.minimaxi.com/v1"),
|
||||
] {
|
||||
assert!(
|
||||
codex_native_gateway_rejects_web_search(&cfg(model, host)),
|
||||
"{host} should be blacklisted"
|
||||
);
|
||||
}
|
||||
|
||||
// Blacklisted by MODEL brand even on an aggregator host (SiliconFlow
|
||||
// fronting a reject vendor's model) → disable.
|
||||
for (model, host) in [
|
||||
("MiniMax-M3", "https://api.siliconflow.cn/v1"),
|
||||
("MiniMaxAI/MiniMax-M3", "https://api.siliconflow.cn/v1"),
|
||||
("mimo-v2.5-pro", "https://some-aggregator.example/v1"),
|
||||
(
|
||||
"qwen/qwen3-coder-plus",
|
||||
"https://some-aggregator.example/v1",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
codex_native_gateway_rejects_web_search(&cfg(model, host)),
|
||||
"{model} @ {host} should be blacklisted by model brand"
|
||||
);
|
||||
}
|
||||
|
||||
// Qwen3-Coder is blacklisted by model, not by DashScope host. This keeps
|
||||
// general Qwen models that support built-in web_search on the same host
|
||||
// enabled while protecting the native qwen3-coder-plus preset.
|
||||
assert!(codex_native_gateway_rejects_web_search(&cfg(
|
||||
"qwen3-coder-plus",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
)));
|
||||
assert!(!codex_native_gateway_rejects_web_search(&cfg(
|
||||
"qwen3.7-plus",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
)));
|
||||
|
||||
// NOT blacklisted → keep Codex default (relays/GPT, DouBao, general Qwen,
|
||||
// and any unknown provider incl. an aggregator serving a non-reject model).
|
||||
for (model, host) in [
|
||||
("gpt-5.5", "https://www.packyapi.com/v1"),
|
||||
("gpt-5-codex", "https://aihubmix.com/v1"),
|
||||
(
|
||||
"doubao-seed-2-1-pro-260628",
|
||||
"https://ark.cn-beijing.volces.com/api/v3",
|
||||
),
|
||||
("Pro/moonshotai/Kimi-K2.6", "https://api.siliconflow.cn/v1"),
|
||||
] {
|
||||
assert!(
|
||||
!codex_native_gateway_rejects_web_search(&cfg(model, host)),
|
||||
"{model} @ {host} should NOT be blacklisted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_path_returns_none_when_config_missing_field() {
|
||||
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
|
||||
|
||||
@@ -1445,11 +1445,15 @@ fn opencode_extra_search_paths(
|
||||
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
vec![
|
||||
let extensionless = dir.join(tool);
|
||||
let mut candidates = vec![
|
||||
dir.join(format!("{tool}.cmd")),
|
||||
dir.join(format!("{tool}.exe")),
|
||||
dir.join(tool),
|
||||
]
|
||||
];
|
||||
if windows_runnable_sibling_for_extensionless_tool(&extensionless).is_none() {
|
||||
candidates.push(extensionless);
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -1619,6 +1623,18 @@ fn is_windows_command_script(path: &Path) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_runnable_sibling_for_extensionless_tool(path: &Path) -> Option<std::path::PathBuf> {
|
||||
if path.extension().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
["cmd", "exe"]
|
||||
.iter()
|
||||
.map(|ext| path.with_extension(ext))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_tool_version_command(
|
||||
tool_path: &Path,
|
||||
@@ -1821,7 +1837,10 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
|
||||
if first.is_empty() {
|
||||
return None;
|
||||
}
|
||||
std::fs::canonicalize(first).ok()
|
||||
let path = Path::new(first);
|
||||
let preferred =
|
||||
windows_runnable_sibling_for_extensionless_tool(path).unwrap_or_else(|| path.to_path_buf());
|
||||
std::fs::canonicalize(preferred).ok()
|
||||
}
|
||||
|
||||
/// 枚举工具在系统中的所有安装(不短路)。与 `scan_cli_version` 共用
|
||||
@@ -5063,6 +5082,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn tool_executable_candidates_windows_skips_shadowed_npm_unix_shim() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let extensionless = dir.path().join("codex");
|
||||
let cmd = dir.path().join("codex.cmd");
|
||||
std::fs::write(&extensionless, "").expect("extensionless shim should be created");
|
||||
std::fs::write(&cmd, "").expect("cmd shim should be created");
|
||||
|
||||
let candidates = tool_executable_candidates("codex", dir.path());
|
||||
|
||||
assert_eq!(candidates, vec![cmd.clone(), dir.path().join("codex.exe")]);
|
||||
assert!(!candidates.contains(&extensionless));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_runnable_sibling_prefers_cmd_over_extensionless_tool() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let extensionless = dir.path().join("codex");
|
||||
let cmd = dir.path().join("codex.cmd");
|
||||
std::fs::write(&extensionless, "").expect("extensionless shim should be created");
|
||||
std::fs::write(&cmd, "").expect("cmd shim should be created");
|
||||
|
||||
let preferred = windows_runnable_sibling_for_extensionless_tool(&extensionless);
|
||||
|
||||
assert_eq!(preferred.as_deref(), Some(cmd.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_cwd_accepts_existing_directory() {
|
||||
let resolved =
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
/// 应用更新下载进度(通过 `update-download-progress` 事件发给前端)。
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct UpdateDownloadProgress {
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
fn merge_settings_for_save(
|
||||
mut incoming: crate::settings::AppSettings,
|
||||
existing: &crate::settings::AppSettings,
|
||||
@@ -203,8 +210,22 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
|
||||
};
|
||||
|
||||
log::info!("开始下载应用更新: {}", update.version);
|
||||
let progress_handle = app.clone();
|
||||
let mut downloaded: u64 = 0;
|
||||
let bytes = update
|
||||
.download(|_, _| {}, || {})
|
||||
.download(
|
||||
move |chunk_len, content_len| {
|
||||
downloaded = downloaded.saturating_add(chunk_len as u64);
|
||||
let _ = progress_handle.emit(
|
||||
"update-download-progress",
|
||||
UpdateDownloadProgress {
|
||||
downloaded,
|
||||
total: content_len,
|
||||
},
|
||||
);
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("下载更新失败: {e}"))?;
|
||||
|
||||
@@ -245,6 +266,24 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否有可用的应用更新,返回可用的新版本号(无更新时返回 None)。
|
||||
///
|
||||
/// 数据库版本过新的恢复界面用它判断:升级应用能否解决问题。若返回 None,说明
|
||||
/// 已是最新版本,但数据库仍不兼容(通常由第三方客户端或更高版本创建),应提示用户
|
||||
/// 升级无法解决,而不是让其反复尝试。
|
||||
#[tauri::command]
|
||||
pub async fn check_app_update_available(app: AppHandle) -> Result<Option<String>, String> {
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.build()
|
||||
.map_err(|e| format!("初始化更新器失败: {e}"))?;
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("检查更新失败: {e}"))?;
|
||||
Ok(update.map(|u| u.version))
|
||||
}
|
||||
|
||||
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
||||
#[tauri::command]
|
||||
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
|
||||
|
||||
@@ -159,6 +159,22 @@ impl Database {
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// 读取磁盘上数据库的 `user_version`;仅当它比应用支持的 [`SCHEMA_VERSION`]
|
||||
/// 更新时返回 `Some(version)`。
|
||||
///
|
||||
/// 用于初始化失败后判断是否为「数据库版本过新(应用过旧,需升级应用)」的可恢复
|
||||
/// 场景——此时不应反复弹出无效的重试对话框,而应引导用户在应用内升级。
|
||||
pub fn stored_user_version_exceeds_supported(
|
||||
db_path: &std::path::Path,
|
||||
) -> Result<Option<i32>, AppError> {
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let conn = Connection::open(db_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let version = Self::get_user_version(&conn)?;
|
||||
Ok((version > SCHEMA_VERSION).then_some(version))
|
||||
}
|
||||
|
||||
/// 创建内存数据库(用于测试)
|
||||
pub fn memory() -> Result<Self, AppError> {
|
||||
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -1684,6 +1684,26 @@ impl Database {
|
||||
),
|
||||
// ====== 国产模型 (USD/1M tokens) ======
|
||||
// Doubao (字节跳动)
|
||||
// Seed 2.1 系列(2026-06 火山引擎官方 list 价,CNY 按 ~7.14 折算):
|
||||
// pro 输入 6 元 / 输出 30 元 / 命中 1.2 元
|
||||
// turbo 输入 3 元 / 输出 15 元 / 命中 0.6 元
|
||||
// 「缓存存储 0.017 元/M/小时」是按时长计费的存储费,与本表 cache_creation(按 token 写入价)口径不同,置 0。
|
||||
(
|
||||
"doubao-seed-2-1-pro",
|
||||
"Doubao Seed 2.1 Pro",
|
||||
"0.84",
|
||||
"4.2",
|
||||
"0.17",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-1-turbo",
|
||||
"Doubao Seed 2.1 Turbo",
|
||||
"0.42",
|
||||
"2.1",
|
||||
"0.08",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-code",
|
||||
"Doubao Seed Code",
|
||||
|
||||
@@ -185,6 +185,48 @@ fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_deeplink_api_key(api_key: &str) -> String {
|
||||
api_key.trim().to_string()
|
||||
}
|
||||
|
||||
fn normalize_deeplink_base_url(base_url: &str) -> String {
|
||||
base_url.trim().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn usage_api_key_override(request: &DeepLinkImportRequest) -> Option<String> {
|
||||
let usage_api_key = normalize_deeplink_api_key(request.usage_api_key.as_deref()?);
|
||||
if usage_api_key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider_api_key = request
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(normalize_deeplink_api_key)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !provider_api_key.is_empty() && usage_api_key == provider_api_key {
|
||||
None
|
||||
} else {
|
||||
Some(usage_api_key)
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_base_url_override(request: &DeepLinkImportRequest) -> Option<String> {
|
||||
let usage_base_url = normalize_deeplink_base_url(request.usage_base_url.as_deref()?);
|
||||
if usage_base_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider_base_url = normalize_deeplink_base_url(&get_primary_endpoint(request));
|
||||
|
||||
if !provider_base_url.is_empty() && usage_base_url == provider_base_url {
|
||||
None
|
||||
} else {
|
||||
Some(usage_base_url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build provider meta with usage script configuration
|
||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||
// Check if any usage script fields are provided
|
||||
@@ -211,25 +253,13 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
// Determine enabled state: explicit param > has code > false
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
|
||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||
// Note: use primary endpoint only (first one if comma-separated)
|
||||
let usage_script = UsageScript {
|
||||
enabled,
|
||||
language: "javascript".to_string(),
|
||||
code,
|
||||
timeout: Some(10),
|
||||
api_key: request
|
||||
.usage_api_key
|
||||
.clone()
|
||||
.or_else(|| request.api_key.clone()),
|
||||
base_url: request.usage_base_url.clone().or_else(|| {
|
||||
let primary = get_primary_endpoint(request);
|
||||
if primary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}),
|
||||
api_key: usage_api_key_override(request),
|
||||
base_url: usage_base_url_override(request),
|
||||
access_token: request.usage_access_token.clone(),
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
|
||||
@@ -260,6 +260,154 @@ fn test_build_gemini_provider_without_model() {
|
||||
assert!(env.get("GEMINI_MODEL").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
let request = DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: Some("claude".to_string()),
|
||||
name: Some("Test Claude".to_string()),
|
||||
homepage: Some("https://example.com".to_string()),
|
||||
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||||
api_key: Some("sk-main".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
opus_model: None,
|
||||
config: None,
|
||||
config_format: None,
|
||||
config_url: None,
|
||||
apps: None,
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
content: None,
|
||||
description: None,
|
||||
enabled: None,
|
||||
usage_enabled: Some(true),
|
||||
usage_script: None,
|
||||
usage_api_key: None,
|
||||
usage_base_url: None,
|
||||
usage_access_token: None,
|
||||
usage_user_id: None,
|
||||
usage_auto_interval: None,
|
||||
};
|
||||
|
||||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||||
let script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should be created");
|
||||
|
||||
assert!(script.enabled);
|
||||
assert_eq!(script.api_key, None);
|
||||
assert_eq!(script.base_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
let request = DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: Some("claude".to_string()),
|
||||
name: Some("Test Claude".to_string()),
|
||||
homepage: Some("https://example.com".to_string()),
|
||||
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||||
api_key: Some("sk-main".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
opus_model: None,
|
||||
config: None,
|
||||
config_format: None,
|
||||
config_url: None,
|
||||
apps: None,
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
content: None,
|
||||
description: None,
|
||||
enabled: None,
|
||||
usage_enabled: Some(true),
|
||||
usage_script: None,
|
||||
usage_api_key: Some(" sk-main ".to_string()),
|
||||
usage_base_url: Some(" https://api.example.com/v1/ ".to_string()),
|
||||
usage_access_token: None,
|
||||
usage_user_id: None,
|
||||
usage_auto_interval: None,
|
||||
};
|
||||
|
||||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||||
let script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should be created");
|
||||
|
||||
assert_eq!(script.api_key, None);
|
||||
assert_eq!(script.base_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
let request = DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: Some("claude".to_string()),
|
||||
name: Some("Test Claude".to_string()),
|
||||
homepage: Some("https://example.com".to_string()),
|
||||
endpoint: Some("https://api.example.com/v1".to_string()),
|
||||
api_key: Some("sk-main".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
opus_model: None,
|
||||
config: None,
|
||||
config_format: None,
|
||||
config_url: None,
|
||||
apps: None,
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
content: None,
|
||||
description: None,
|
||||
enabled: None,
|
||||
usage_enabled: Some(true),
|
||||
usage_script: None,
|
||||
usage_api_key: Some(" sk-usage ".to_string()),
|
||||
usage_base_url: Some(" https://usage.example/api/ ".to_string()),
|
||||
usage_access_token: None,
|
||||
usage_user_id: None,
|
||||
usage_auto_interval: None,
|
||||
};
|
||||
|
||||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||||
let script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should be created");
|
||||
|
||||
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
|
||||
assert_eq!(
|
||||
script.base_url.as_deref(),
|
||||
Some("https://usage.example/api")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_claude() {
|
||||
// Prepare Base64 encoded Claude config
|
||||
|
||||
@@ -46,16 +46,55 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// 获取 Hermes 配置目录
|
||||
///
|
||||
/// 默认路径: `~/.hermes/`
|
||||
/// 可通过 settings.hermes_config_dir 覆盖
|
||||
/// 解析顺序对齐 Hermes 自身的 `get_hermes_home()`:
|
||||
/// 1. CCS 设置 `hermes_config_dir`(显式覆盖)
|
||||
/// 2. `HERMES_HOME` 环境变量(trim 后非空;按原样,不展开 `~`,与 Hermes `Path(val)` 一致)
|
||||
/// 3. 平台默认(Windows: `%LOCALAPPDATA%\hermes`,Mac/Linux: `~/.hermes`)
|
||||
pub fn get_hermes_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_hermes_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
if let Some(raw) = std::env::var_os("HERMES_HOME") {
|
||||
let value = raw.to_string_lossy();
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
default_hermes_dir()
|
||||
}
|
||||
|
||||
/// 平台默认 Hermes 目录(Windows):对齐 Hermes `_get_platform_default_hermes_home()`——
|
||||
/// 读 `LOCALAPPDATA` 环境变量,缺失/空时回退 `~\AppData\Local`,再拼 `hermes`。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn default_hermes_dir() -> PathBuf {
|
||||
windows_local_hermes_dir(
|
||||
std::env::var_os("LOCALAPPDATA").as_deref(),
|
||||
&crate::config::get_home_dir(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 平台默认 Hermes 目录(Mac/Linux):`~/.hermes`。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn default_hermes_dir() -> PathBuf {
|
||||
crate::config::get_home_dir().join(".hermes")
|
||||
}
|
||||
|
||||
/// Windows `%LOCALAPPDATA%\hermes` 路径计算(纯函数,便于跨平台单测)。
|
||||
/// 对齐 Hermes 的 `os.environ.get("LOCALAPPDATA", "").strip()`:trim 后为空
|
||||
/// (缺失/空/纯空白)则回退 `<home>\AppData\Local\hermes`。
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) -> PathBuf {
|
||||
localappdata
|
||||
.map(|value| value.to_string_lossy().trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join("AppData").join("Local"))
|
||||
.join("hermes")
|
||||
}
|
||||
|
||||
/// 获取 Hermes 配置文件路径
|
||||
///
|
||||
/// 返回 `~/.hermes/config.yaml`
|
||||
@@ -1120,7 +1159,22 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", tmp.path());
|
||||
// Neutralize the env vars get_hermes_dir() consults, so an ambient
|
||||
// HERMES_HOME / LOCALAPPDATA (e.g. set by a Hermes install) can't make
|
||||
// tests escape the temp home. Restored below.
|
||||
let old_hermes_home = std::env::var_os("HERMES_HOME");
|
||||
let old_local_appdata = std::env::var_os("LOCALAPPDATA");
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
std::env::remove_var("LOCALAPPDATA");
|
||||
let result = test_fn();
|
||||
match old_local_appdata {
|
||||
Some(value) => std::env::set_var("LOCALAPPDATA", value),
|
||||
None => std::env::remove_var("LOCALAPPDATA"),
|
||||
}
|
||||
match old_hermes_home {
|
||||
Some(value) => std::env::set_var("HERMES_HOME", value),
|
||||
None => std::env::remove_var("HERMES_HOME"),
|
||||
}
|
||||
match old_test_home {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
@@ -2193,4 +2247,198 @@ user_profile_enabled: false
|
||||
assert_eq!(user, MemoryKind::User);
|
||||
assert!(serde_json::from_str::<MemoryKind>("\"bogus\"").is_err());
|
||||
}
|
||||
|
||||
// ---- get_hermes_dir resolution (platform default + HERMES_HOME) ----
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn hermes_home_env_takes_precedence_over_platform_default() {
|
||||
with_test_home(|| {
|
||||
// Clear any settings override so resolution reaches the HERMES_HOME branch.
|
||||
let mut s = crate::settings::get_settings();
|
||||
s.hermes_config_dir = None;
|
||||
crate::settings::update_settings(s).unwrap();
|
||||
|
||||
let old = std::env::var_os("HERMES_HOME");
|
||||
let custom = std::env::temp_dir().join("ccs-hermes-home-precedence");
|
||||
std::env::set_var("HERMES_HOME", &custom);
|
||||
|
||||
let dir = get_hermes_dir();
|
||||
|
||||
match old {
|
||||
Some(v) => std::env::set_var("HERMES_HOME", v),
|
||||
None => std::env::remove_var("HERMES_HOME"),
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
dir, custom,
|
||||
"HERMES_HOME should take precedence over the platform default, got {dir:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn settings_override_takes_precedence_over_hermes_home() {
|
||||
with_test_home(|| {
|
||||
let custom = tempfile::tempdir().unwrap();
|
||||
let custom_path = custom.path().to_path_buf();
|
||||
|
||||
let mut s = crate::settings::get_settings();
|
||||
s.hermes_config_dir = Some(custom_path.to_string_lossy().to_string());
|
||||
crate::settings::update_settings(s).unwrap();
|
||||
|
||||
let old = std::env::var_os("HERMES_HOME");
|
||||
std::env::set_var("HERMES_HOME", "/tmp/should-be-ignored");
|
||||
|
||||
let dir = get_hermes_dir();
|
||||
|
||||
match old {
|
||||
Some(v) => std::env::set_var("HERMES_HOME", v),
|
||||
None => std::env::remove_var("HERMES_HOME"),
|
||||
}
|
||||
let mut s = crate::settings::get_settings();
|
||||
s.hermes_config_dir = None;
|
||||
crate::settings::update_settings(s).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
dir, custom_path,
|
||||
"settings override should win over HERMES_HOME, got {dir:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn blank_hermes_home_falls_through_to_platform_default() {
|
||||
with_test_home(|| {
|
||||
let mut s = crate::settings::get_settings();
|
||||
s.hermes_config_dir = None;
|
||||
crate::settings::update_settings(s).unwrap();
|
||||
|
||||
let old = std::env::var_os("HERMES_HOME");
|
||||
std::env::set_var("HERMES_HOME", " ");
|
||||
|
||||
let dir = get_hermes_dir();
|
||||
|
||||
match old {
|
||||
Some(v) => std::env::set_var("HERMES_HOME", v),
|
||||
None => std::env::remove_var("HERMES_HOME"),
|
||||
}
|
||||
|
||||
// Blank HERMES_HOME is ignored (matches Hermes' `.strip()` non-empty check),
|
||||
// so resolution must reach the platform default, never the literal blank path.
|
||||
assert_ne!(dir, PathBuf::from(" "));
|
||||
assert_eq!(dir, default_hermes_dir());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn default_hermes_dir_without_override_is_platform_correct() {
|
||||
with_test_home(|| {
|
||||
let mut s = crate::settings::get_settings();
|
||||
s.hermes_config_dir = None;
|
||||
crate::settings::update_settings(s).unwrap();
|
||||
|
||||
let old = std::env::var_os("HERMES_HOME");
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
|
||||
let dir = get_hermes_dir();
|
||||
|
||||
if let Some(v) = old {
|
||||
std::env::set_var("HERMES_HOME", v);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(
|
||||
dir.ends_with("hermes") && dir.to_string_lossy().to_lowercase().contains("local"),
|
||||
"Windows default should be %LOCALAPPDATA%\\hermes, got {dir:?}"
|
||||
);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert!(
|
||||
dir.ends_with(".hermes"),
|
||||
"Unix default should be ~/.hermes, got {dir:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_local_hermes_dir_uses_localappdata_when_set() {
|
||||
let local = std::ffi::OsString::from("C:\\Users\\tester\\AppData\\Local");
|
||||
let home = Path::new("C:\\Users\\tester");
|
||||
// Uses LOCALAPPDATA (ignoring home) and appends `hermes`. Build the expected
|
||||
// path with `join` so the separator matches the host OS.
|
||||
assert_eq!(
|
||||
windows_local_hermes_dir(Some(local.as_os_str()), home),
|
||||
PathBuf::from(&local).join("hermes"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_local_hermes_dir_falls_back_to_home_when_localappdata_missing_or_empty() {
|
||||
let home = Path::new("C:\\Users\\tester");
|
||||
let expected = home.join("AppData").join("Local").join("hermes");
|
||||
assert_eq!(windows_local_hermes_dir(None, home), expected);
|
||||
let empty = std::ffi::OsString::from("");
|
||||
assert_eq!(
|
||||
windows_local_hermes_dir(Some(empty.as_os_str()), home),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_local_hermes_dir_trims_localappdata() {
|
||||
// Mirror Hermes' `os.environ.get("LOCALAPPDATA", "").strip()`.
|
||||
let home = Path::new("C:\\Users\\tester");
|
||||
// Whitespace-only -> treated as unset, fall back to <home>\AppData\Local.
|
||||
let blank = std::ffi::OsString::from(" ");
|
||||
assert_eq!(
|
||||
windows_local_hermes_dir(Some(blank.as_os_str()), home),
|
||||
home.join("AppData").join("Local").join("hermes"),
|
||||
);
|
||||
// Padded value -> trimmed before use.
|
||||
let padded = std::ffi::OsString::from(" C:\\Custom\\Local ");
|
||||
assert_eq!(
|
||||
windows_local_hermes_dir(Some(padded.as_os_str()), home),
|
||||
PathBuf::from("C:\\Custom\\Local").join("hermes"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn with_test_home_neutralizes_hermes_env_vars() {
|
||||
// An ambient HERMES_HOME / LOCALAPPDATA (e.g. set by a Hermes install)
|
||||
// must not leak into the test home — otherwise other tests in this
|
||||
// module would read/write a real Hermes config via get_hermes_config_path().
|
||||
let saved_hh = std::env::var_os("HERMES_HOME");
|
||||
let saved_la = std::env::var_os("LOCALAPPDATA");
|
||||
std::env::set_var("HERMES_HOME", "/ambient/hermes-home");
|
||||
std::env::set_var("LOCALAPPDATA", "/ambient/local-appdata");
|
||||
|
||||
let inside = with_test_home(|| {
|
||||
(
|
||||
std::env::var_os("HERMES_HOME"),
|
||||
std::env::var_os("LOCALAPPDATA"),
|
||||
)
|
||||
});
|
||||
|
||||
match saved_hh {
|
||||
Some(v) => std::env::set_var("HERMES_HOME", v),
|
||||
None => std::env::remove_var("HERMES_HOME"),
|
||||
}
|
||||
match saved_la {
|
||||
Some(v) => std::env::set_var("LOCALAPPDATA", v),
|
||||
None => std::env::remove_var("LOCALAPPDATA"),
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
inside.0, None,
|
||||
"with_test_home must clear ambient HERMES_HOME"
|
||||
);
|
||||
assert_eq!(
|
||||
inside.1, None,
|
||||
"with_test_home must clear ambient LOCALAPPDATA"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,17 @@ use std::sync::{OnceLock, RwLock};
|
||||
pub struct InitErrorPayload {
|
||||
pub path: String,
|
||||
pub error: String,
|
||||
/// 错误类别。`Some("db_version_too_new")` 表示数据库版本过新(应用过旧),
|
||||
/// 前端据此展示「升级应用」恢复界面而非直接退出。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
/// 磁盘上数据库的 user_version(数据库版本过新时填充)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub db_version: Option<i32>,
|
||||
/// 当前应用支持的 SCHEMA_VERSION(数据库版本过新时填充)。
|
||||
/// 当升级到最新版后 db_version 仍 > supported_version,说明可能由第三方客户端创建。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supported_version: Option<i32>,
|
||||
}
|
||||
|
||||
static INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
|
||||
@@ -13,7 +24,6 @@ fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
|
||||
INIT_ERROR.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_init_error(payload: InitErrorPayload) {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
if let Ok(mut guard) = cell().write() {
|
||||
@@ -102,6 +112,9 @@ mod tests {
|
||||
let payload = InitErrorPayload {
|
||||
path: "/tmp/config.json".into(),
|
||||
error: "broken json".into(),
|
||||
kind: None,
|
||||
db_version: None,
|
||||
supported_version: None,
|
||||
};
|
||||
set_init_error(payload.clone());
|
||||
let got = get_init_error().expect("should get payload back");
|
||||
|
||||
@@ -270,6 +270,16 @@ pub fn run() {
|
||||
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// 数据库版本过新的恢复模式下没有托盘可唤回,关闭即退出,避免应用隐身后台
|
||||
let in_db_recovery = crate::init_status::get_init_error()
|
||||
.map(|p| p.kind.as_deref() == Some("db_version_too_new"))
|
||||
.unwrap_or(false);
|
||||
if in_db_recovery {
|
||||
api.prevent_close();
|
||||
window.app_handle().exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let settings = crate::settings::get_settings();
|
||||
|
||||
if settings.minimize_to_tray_on_close {
|
||||
@@ -403,6 +413,35 @@ pub fn run() {
|
||||
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
|
||||
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
|
||||
// 否则表现可能只是“应用打不开/闪退”。
|
||||
//
|
||||
// 预检:数据库版本过新时,必须先于任何 schema 写操作(create_tables 内含
|
||||
// DROP/ALTER 等 DDL)进入恢复界面,避免旧应用对读不懂的更新版 DB 落写。
|
||||
match crate::database::Database::stored_user_version_exceeds_supported(&db_path) {
|
||||
Ok(Some(version)) => {
|
||||
log::warn!("数据库版本过新(v{version}),引导用户在应用内升级应用");
|
||||
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
|
||||
path: db_path.display().to_string(),
|
||||
error: format!(
|
||||
"数据库版本过新({version}),当前应用仅支持 {},请升级应用后再尝试。",
|
||||
crate::database::SCHEMA_VERSION
|
||||
),
|
||||
kind: Some("db_version_too_new".to_string()),
|
||||
db_version: Some(version),
|
||||
supported_version: Some(crate::database::SCHEMA_VERSION),
|
||||
});
|
||||
// 主窗口默认 visible:false,恢复界面必须强制显示
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
log::warn!("预检数据库版本失败,继续正常初始化流程: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let db = loop {
|
||||
match crate::database::Database::init() {
|
||||
Ok(db) => break Arc::new(db),
|
||||
@@ -1185,6 +1224,7 @@ pub fn run() {
|
||||
commands::set_log_config,
|
||||
commands::restart_app,
|
||||
commands::install_update_and_restart,
|
||||
commands::check_app_update_available,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
commands::copy_text_to_clipboard,
|
||||
|
||||
@@ -16,6 +16,19 @@ fn main() {
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
|
||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
}
|
||||
|
||||
// AppImage 的 GTK 启动钩子 (linuxdeploy-plugin-gtk.sh) 会无条件
|
||||
// `export GDK_BACKEND=x11` 强制走 XWayland,以规避历史上的 Wayland 崩溃
|
||||
// (tauri-apps/tauri#8541)。但在较新的 Wayland + NVIDIA 环境下,强制 XWayland
|
||||
// 反而使 WebKitGTK 的 webview 收不到指针事件(标题栏可点、网页内容点不动),
|
||||
// resize 后黑屏;改回原生 Wayland 即可解决,且该崩溃在 WebKitGTK 2.52 上已不复现。
|
||||
// 由于该钩子会覆盖用户预设的 GDK_BACKEND,这里提供一个钩子不会触碰的逃生开关:
|
||||
// 设置 CC_SWITCH_GDK_BACKEND=wayland 即可强制覆盖,默认行为保持不变(零回归)。
|
||||
if let Ok(backend) = std::env::var("CC_SWITCH_GDK_BACKEND") {
|
||||
if !backend.is_empty() {
|
||||
std::env::set_var("GDK_BACKEND", backend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cc_switch_lib::run();
|
||||
|
||||
@@ -382,6 +382,21 @@ pub struct CodexChatReasoningConfig {
|
||||
pub output_format: Option<String>,
|
||||
}
|
||||
|
||||
/// Local proxy request overrides applied after route/protocol transforms.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct LocalProxyRequestOverrides {
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub headers: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl LocalProxyRequestOverrides {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.headers.is_empty() && self.body.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
@@ -466,6 +481,12 @@ pub struct ProviderMeta {
|
||||
/// Custom User-Agent for local proxy routing.
|
||||
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
|
||||
pub custom_user_agent: Option<String>,
|
||||
/// Local proxy request overrides applied to the transformed upstream request.
|
||||
#[serde(
|
||||
rename = "localProxyRequestOverrides",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub local_proxy_request_overrides: Option<LocalProxyRequestOverrides>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||
@@ -932,10 +953,11 @@ pub struct OpenCodeModelLimit {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||
ProviderManager, ProviderMeta, UniversalProvider,
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, LocalProxyRequestOverrides,
|
||||
OpenCodeProviderConfig, Provider, ProviderManager, ProviderMeta, UniversalProvider,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn provider_meta_serializes_pricing_model_source() {
|
||||
@@ -963,6 +985,33 @@ mod tests {
|
||||
assert!(value.get("pricingModelSource").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_meta_roundtrips_local_proxy_request_overrides() {
|
||||
let meta = ProviderMeta {
|
||||
local_proxy_request_overrides: Some(LocalProxyRequestOverrides {
|
||||
headers: HashMap::from([("X-Test".to_string(), "yes".to_string())]),
|
||||
body: Some(json!({ "temperature": 0.2 })),
|
||||
}),
|
||||
..ProviderMeta::default()
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||
assert_eq!(
|
||||
value["localProxyRequestOverrides"]["headers"]["X-Test"],
|
||||
"yes"
|
||||
);
|
||||
assert_eq!(
|
||||
value["localProxyRequestOverrides"]["body"]["temperature"],
|
||||
0.2
|
||||
);
|
||||
|
||||
let decoded: ProviderMeta =
|
||||
serde_json::from_value(value).expect("deserialize ProviderMeta");
|
||||
let overrides = decoded.local_proxy_request_overrides.unwrap();
|
||||
assert_eq!(overrides.headers.get("X-Test"), Some(&"yes".to_string()));
|
||||
assert_eq!(overrides.body.unwrap()["temperature"], 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_with_id_populates_defaults() {
|
||||
let settings_config = json!({
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! HTTP content-encoding 工具。
|
||||
//!
|
||||
//! reqwest 的自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
||||
//! 请求侧(如 Codex Desktop 在登录态发压缩请求体)与响应侧(上游压缩响应体)
|
||||
//! 共用同一套解压逻辑。
|
||||
|
||||
use axum::http::header::HeaderMap;
|
||||
use std::io::Read;
|
||||
|
||||
/// 把 content-encoding 值拆成有序 coding 列表(去掉 identity 与空值)。
|
||||
///
|
||||
/// HTTP 允许堆叠编码(如 `gzip, zstd`),各 coding 以逗号分隔;亦允许重复
|
||||
/// content-encoding 头,语义等同逗号拼接(见 [`get_content_encoding`])。
|
||||
fn split_codings(content_encoding: &str) -> Vec<&str> {
|
||||
content_encoding
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty() && *c != "identity")
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 单个 coding 是否可被解压。
|
||||
fn is_single_supported(coding: &str) -> bool {
|
||||
matches!(
|
||||
coding,
|
||||
"gzip" | "x-gzip" | "deflate" | "br" | "zstd" | "zst"
|
||||
)
|
||||
}
|
||||
|
||||
/// 解压单个 content-coding。未知编码返回 `Ok(None)`。
|
||||
fn decompress_single(coding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
match coding {
|
||||
"gzip" | "x-gzip" => {
|
||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"deflate" => {
|
||||
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游 / 客户端发 raw deflate 流。
|
||||
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规来源必然解压失败,
|
||||
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
|
||||
let mut decompressed = Vec::new();
|
||||
let mut zlib = flate2::read::ZlibDecoder::new(body);
|
||||
match zlib.read_to_end(&mut decompressed) {
|
||||
Ok(_) => Ok(Some(decompressed)),
|
||||
Err(zlib_err) => {
|
||||
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
|
||||
let mut decompressed = Vec::new();
|
||||
let mut raw = flate2::read::DeflateDecoder::new(body);
|
||||
raw.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
}
|
||||
}
|
||||
"br" => {
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"zstd" | "zst" => {
|
||||
// Codex 登录态对请求体启用 zstd(Compression::Zstd);上游也可能 zstd 压缩响应。
|
||||
let decompressed = zstd::stream::decode_all(std::io::Cursor::new(body))?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 content-encoding 解压 body 字节,支持堆叠编码(如 `gzip, zstd`)。
|
||||
///
|
||||
/// RFC 9110 §8.4:codings 按**应用顺序**列出,故解压须**反向**(最后应用的先解)。
|
||||
/// 返回 `Ok(None)` 表示存在不受支持的编码、原样透传——此时调用方必须保留
|
||||
/// content-encoding 头,否则下游(诊断 / 客户端)会把压缩字节误当明文。
|
||||
pub(crate) fn decompress_body(
|
||||
content_encoding: &str,
|
||||
body: &[u8],
|
||||
) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
let codings = split_codings(content_encoding);
|
||||
if codings.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// 任一 coding 不支持就整体放弃解压、保头透传,避免半解码的脏数据。
|
||||
if !codings.iter().all(|c| is_single_supported(c)) {
|
||||
log::warn!("不支持的 content-encoding: {content_encoding},跳过解压");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 反向解码:列表末尾是最后应用的编码,须最先解。
|
||||
let mut data: Option<Vec<u8>> = None;
|
||||
for coding in codings.iter().rev() {
|
||||
let input = data.as_deref().unwrap_or(body);
|
||||
match decompress_single(coding, input)? {
|
||||
Some(decompressed) => data = Some(decompressed),
|
||||
// 上面 is_single_supported 已校验,理论不会发生;防御性兜底。
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// 该 content-encoding(含堆叠,如 `gzip, zstd`)是否全部可被解压。
|
||||
///
|
||||
/// 请求侧用它做闸门:无法解压的压缩体不能透传给 JSON 解析,需直接拒绝。
|
||||
pub(crate) fn is_supported_content_encoding(content_encoding: &str) -> bool {
|
||||
let codings = split_codings(content_encoding);
|
||||
!codings.is_empty() && codings.iter().all(|c| is_single_supported(c))
|
||||
}
|
||||
|
||||
/// 从 header 提取 content-encoding(合并重复头,忽略 identity 与空值)。
|
||||
///
|
||||
/// HTTP 允许重复 content-encoding 头,语义等同逗号拼接,故用 `get_all` 合并;
|
||||
/// 返回值可能含多个逗号分隔的 coding,交由 [`decompress_body`] 反向解码。
|
||||
pub(crate) fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||
let combined = headers
|
||||
.get_all("content-encoding")
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
.to_lowercase();
|
||||
if split_codings(&combined).is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(combined)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
|
||||
// RFC 9110 规范的 deflate = zlib 包裹格式(合规来源发的就是这个)
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_falls_back_to_raw_stream() {
|
||||
// 部分来源违规发 raw deflate 流,保持兼容
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_zstd_roundtrip() {
|
||||
// Codex 登录态发的就是 zstd 压缩请求体
|
||||
let payload = br#"{"hello":"world","n":42}"#;
|
||||
let compressed = zstd::stream::encode_all(std::io::Cursor::new(&payload[..]), 0).unwrap();
|
||||
let decompressed = decompress_body("zstd", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_stacked_gzip_then_zstd_decodes_in_reverse() {
|
||||
// Content-Encoding: gzip, zstd 表示先 gzip 后 zstd,解压须反向(先 zstd 后 gzip)
|
||||
let payload = br#"{"stacked":true}"#;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut gz, payload).unwrap();
|
||||
let gzipped = gz.finish().unwrap();
|
||||
let stacked = zstd::stream::encode_all(std::io::Cursor::new(&gzipped[..]), 0).unwrap();
|
||||
|
||||
let decompressed = decompress_body("gzip, zstd", &stacked).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_stacked_with_unsupported_returns_none() {
|
||||
// 堆叠里只要有一个不支持,就整体保头透传
|
||||
let result = decompress_body("snappy, zstd", b"\x00\x01\x02\x03").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
|
||||
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
|
||||
// 头被剥掉,下游诊断会把压缩字节误报成明文
|
||||
let result = decompress_body("snappy", b"\x00\x01\x02\x03").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_supported_content_encoding_matches_decompressable() {
|
||||
for enc in [
|
||||
"gzip",
|
||||
"x-gzip",
|
||||
"deflate",
|
||||
"br",
|
||||
"zstd",
|
||||
"zst",
|
||||
"gzip, zstd",
|
||||
] {
|
||||
assert!(is_supported_content_encoding(enc), "{enc} 应受支持");
|
||||
}
|
||||
for enc in ["identity", "snappy", "compress", "", "gzip, snappy"] {
|
||||
assert!(!is_supported_content_encoding(enc), "{enc} 不应受支持");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_content_encoding_combines_repeated_headers() {
|
||||
// 重复的 content-encoding 头等同逗号拼接,须用 get_all 合并
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append("content-encoding", HeaderValue::from_static("gzip"));
|
||||
headers.append("content-encoding", HeaderValue::from_static("zstd"));
|
||||
assert_eq!(
|
||||
get_content_encoding(&headers).as_deref(),
|
||||
Some("gzip, zstd")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_content_encoding_ignores_identity_only() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append("content-encoding", HeaderValue::from_static("identity"));
|
||||
assert_eq!(get_content_encoding(&headers), None);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::hyper_client::ProxyResponse;
|
||||
use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
content_encoding::{decompress_body, get_content_encoding},
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
json_canonical::{canonicalize_value, short_value_hash},
|
||||
@@ -24,7 +25,10 @@ use super::{
|
||||
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, provider::Provider};
|
||||
use crate::{
|
||||
app_config::AppType,
|
||||
provider::{LocalProxyRequestOverrides, Provider},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use http::Extensions;
|
||||
use serde_json::Value;
|
||||
@@ -1386,7 +1390,18 @@ impl RequestForwarder {
|
||||
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = prepare_upstream_request_body(request_body);
|
||||
let mut filtered_body = prepare_upstream_request_body(request_body);
|
||||
if !is_copilot {
|
||||
if let Some(overrides) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.local_proxy_request_overrides.as_ref())
|
||||
{
|
||||
if apply_local_proxy_body_overrides(&mut filtered_body, overrides) {
|
||||
filtered_body = prepare_upstream_request_body(filtered_body);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
|
||||
if let Some(m) = filtered_body
|
||||
.get("model")
|
||||
@@ -1833,6 +1848,15 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
apply_local_proxy_header_overrides(
|
||||
&mut ordered_headers,
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.local_proxy_request_overrides.as_ref()),
|
||||
is_copilot,
|
||||
);
|
||||
|
||||
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
|
||||
|
||||
// 输出请求信息日志
|
||||
@@ -1942,7 +1966,20 @@ impl RequestForwarder {
|
||||
Ok((response, resolved_claude_api_format, outbound_model))
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
||||
// 错误响应同样可能被上游压缩(content-encoding)。reqwest 未启用任何
|
||||
// 自动解压 feature,这里拿到的是原始字节;不解压的话,压缩过的错误体会
|
||||
// 在 from_utf8 处变成非 UTF-8 而被丢弃,隐藏掉上游的限流/鉴权等详情。
|
||||
let encoding = get_content_encoding(response.headers());
|
||||
let raw = response.bytes().await?;
|
||||
let decoded = match encoding {
|
||||
Some(encoding) => match decompress_body(&encoding, &raw) {
|
||||
Ok(Some(decompressed)) => decompressed,
|
||||
// 不支持的编码 / 解压失败:退回原始字节,尽量保留可读信息
|
||||
_ => raw.to_vec(),
|
||||
},
|
||||
None => raw.to_vec(),
|
||||
};
|
||||
let body_text = String::from_utf8(decoded).ok();
|
||||
|
||||
Err(ProxyError::UpstreamError {
|
||||
status: status_code,
|
||||
@@ -2544,6 +2581,154 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn apply_local_proxy_body_overrides(
|
||||
body: &mut Value,
|
||||
overrides: &LocalProxyRequestOverrides,
|
||||
) -> bool {
|
||||
let Some(override_body) = overrides.body.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !override_body.is_object() {
|
||||
log::warn!("[LocalProxyOverrides] Ignoring body override because it is not an object");
|
||||
return false;
|
||||
}
|
||||
|
||||
merge_json_override(body, override_body)
|
||||
}
|
||||
|
||||
fn merge_json_override(target: &mut Value, patch: &Value) -> bool {
|
||||
merge_json_override_inner(target, patch, true)
|
||||
}
|
||||
|
||||
fn merge_json_override_inner(target: &mut Value, patch: &Value, is_top_level: bool) -> bool {
|
||||
match (target, patch) {
|
||||
(Value::Object(target_map), Value::Object(patch_map)) => {
|
||||
let mut changed = false;
|
||||
for (key, patch_value) in patch_map {
|
||||
if is_top_level && key == "stream" {
|
||||
log::warn!(
|
||||
"[LocalProxyOverrides] Ignoring body override for protected field: stream"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) => {
|
||||
changed |= merge_json_override_inner(target_value, patch_value, false);
|
||||
}
|
||||
None => {
|
||||
target_map.insert(key.clone(), patch_value.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
(target_value, patch_value) => {
|
||||
if target_value == patch_value {
|
||||
false
|
||||
} else {
|
||||
*target_value = patch_value.clone();
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_local_proxy_header_overrides(
|
||||
headers: &mut http::HeaderMap,
|
||||
overrides: Option<&LocalProxyRequestOverrides>,
|
||||
is_copilot: bool,
|
||||
) {
|
||||
if is_copilot {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(header_overrides) = overrides.map(|overrides| &overrides.headers) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (raw_name, raw_value) in header_overrides {
|
||||
let header_name = raw_name.trim().to_ascii_lowercase();
|
||||
if header_name.is_empty() {
|
||||
log::warn!("[LocalProxyOverrides] Ignoring header override with empty name");
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(name) = http::HeaderName::from_bytes(header_name.as_bytes()) else {
|
||||
log::warn!("[LocalProxyOverrides] Ignoring invalid header override name: {raw_name}");
|
||||
continue;
|
||||
};
|
||||
|
||||
if is_protected_local_proxy_override_header(&name) {
|
||||
log::debug!(
|
||||
"[LocalProxyOverrides] Ignoring protected header override: {}",
|
||||
name.as_str()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(value) = http::HeaderValue::from_str(raw_value) else {
|
||||
log::warn!(
|
||||
"[LocalProxyOverrides] Ignoring invalid header override value for {}",
|
||||
name.as_str()
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_protected_local_proxy_override_header(name: &http::HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"host"
|
||||
| "content-length"
|
||||
| "transfer-encoding"
|
||||
| "connection"
|
||||
| "proxy-authorization"
|
||||
| "proxy-authenticate"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "upgrade"
|
||||
| "accept-encoding"
|
||||
| "content-type"
|
||||
| "authorization"
|
||||
| "x-api-key"
|
||||
| "x-goog-api-key"
|
||||
| "chatgpt-account-id"
|
||||
| "session_id"
|
||||
| "x-client-request-id"
|
||||
| "x-codex-window-id"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-port"
|
||||
| "x-forwarded-proto"
|
||||
| "forwarded"
|
||||
| "cf-connecting-ip"
|
||||
| "cf-ipcountry"
|
||||
| "cf-ray"
|
||||
| "cf-visitor"
|
||||
| "true-client-ip"
|
||||
| "fastly-client-ip"
|
||||
| "x-azure-clientip"
|
||||
| "x-azure-fdid"
|
||||
| "x-azure-ref"
|
||||
| "akamai-origin-hop"
|
||||
| "x-akamai-config-log-detail"
|
||||
| "x-request-id"
|
||||
| "x-correlation-id"
|
||||
| "x-trace-id"
|
||||
| "x-amzn-trace-id"
|
||||
| "x-b3-traceid"
|
||||
| "x-b3-spanid"
|
||||
| "x-b3-parentspanid"
|
||||
| "x-b3-sampled"
|
||||
| "traceparent"
|
||||
| "tracestate"
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_upstream_request_body(request_body: Value) -> Value {
|
||||
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
|
||||
}
|
||||
@@ -2607,6 +2792,7 @@ fn value_for_log(value: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
use crate::provider::LocalProxyRequestOverrides;
|
||||
use axum::http::header::{HeaderValue, ACCEPT};
|
||||
use axum::http::HeaderMap;
|
||||
use bytes::Bytes;
|
||||
@@ -2806,6 +2992,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_body_overrides_deep_merge_final_body_without_stream() {
|
||||
let mut body = json!({
|
||||
"model": "before",
|
||||
"stream": false,
|
||||
"metadata": {
|
||||
"keep": true,
|
||||
"temperature": 1
|
||||
},
|
||||
"messages": [{ "role": "user", "content": "hello" }]
|
||||
});
|
||||
let overrides = LocalProxyRequestOverrides {
|
||||
headers: HashMap::new(),
|
||||
body: Some(json!({
|
||||
"model": "after",
|
||||
"stream": true,
|
||||
"metadata": {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.9
|
||||
},
|
||||
"messages": []
|
||||
})),
|
||||
};
|
||||
|
||||
assert!(apply_local_proxy_body_overrides(&mut body, &overrides));
|
||||
|
||||
assert_eq!(body["model"], "after");
|
||||
assert_eq!(body["stream"], false);
|
||||
assert_eq!(body["metadata"]["keep"], true);
|
||||
assert_eq!(body["metadata"]["temperature"], 0.2);
|
||||
assert_eq!(body["metadata"]["top_p"], 0.9);
|
||||
assert_eq!(body["messages"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_header_overrides_replace_allowed_headers_only() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::USER_AGENT,
|
||||
http::HeaderValue::from_static("original"),
|
||||
);
|
||||
headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
http::HeaderValue::from_static("Bearer good"),
|
||||
);
|
||||
headers.insert(
|
||||
http::header::CONTENT_TYPE,
|
||||
http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
let overrides = LocalProxyRequestOverrides {
|
||||
headers: HashMap::from([
|
||||
("User-Agent".to_string(), "custom".to_string()),
|
||||
("X-Test".to_string(), "ok".to_string()),
|
||||
("Authorization".to_string(), "Bearer bad".to_string()),
|
||||
("Content-Type".to_string(), "text/plain".to_string()),
|
||||
("X-Bad".to_string(), "bad\nvalue".to_string()),
|
||||
]),
|
||||
body: None,
|
||||
};
|
||||
|
||||
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), false);
|
||||
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(http::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("custom")
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("Bearer good")
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-test").and_then(|value| value.to_str().ok()),
|
||||
Some("ok")
|
||||
);
|
||||
assert!(headers.get("x-bad").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_header_overrides_are_skipped_for_copilot() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::USER_AGENT,
|
||||
http::HeaderValue::from_static("copilot"),
|
||||
);
|
||||
let overrides = LocalProxyRequestOverrides {
|
||||
headers: HashMap::from([("User-Agent".to_string(), "custom".to_string())]),
|
||||
body: None,
|
||||
};
|
||||
|
||||
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), true);
|
||||
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(http::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("copilot")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
|
||||
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
|
||||
|
||||
use super::{
|
||||
content_encoding::{decompress_body, get_content_encoding, is_supported_content_encoding},
|
||||
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||||
forwarder::ActiveConnectionGuard,
|
||||
handler_config::{
|
||||
@@ -568,6 +569,49 @@ fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 客户端(尤其 Desktop 登录态)可能对请求体启用 zstd 压缩,使得后续
|
||||
/// `serde_json::from_slice` 直接解析失败。这里在解析前解压,并剥掉已失真的实体头
|
||||
/// (content-encoding / content-length / transfer-encoding)——转发层会基于解压后的
|
||||
/// 明文 JSON 重新生成正确的头。
|
||||
fn decode_codex_request_body(
|
||||
headers: &mut axum::http::HeaderMap,
|
||||
body_bytes: Bytes,
|
||||
) -> Result<Bytes, ProxyError> {
|
||||
let Some(encoding) = get_content_encoding(headers) else {
|
||||
return Ok(body_bytes);
|
||||
};
|
||||
|
||||
if !is_supported_content_encoding(&encoding) {
|
||||
return Err(ProxyError::InvalidRequest(format!(
|
||||
"Unsupported request content-encoding: {encoding}"
|
||||
)));
|
||||
}
|
||||
|
||||
log::debug!("[Codex] 解压请求体: content-encoding={encoding}");
|
||||
let decompressed = match decompress_body(&encoding, &body_bytes) {
|
||||
Ok(Some(decompressed)) => decompressed,
|
||||
// is_supported_content_encoding 已确保编码受支持,正常不会返回 None;
|
||||
// 防御性兜底:宁可报错,也不能把压缩字节当 JSON 透传下去。
|
||||
Ok(None) => {
|
||||
return Err(ProxyError::InvalidRequest(format!(
|
||||
"Unsupported request content-encoding: {encoding}"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[Codex] 请求体解压失败 ({encoding}): {e}");
|
||||
return Err(ProxyError::InvalidRequest(format!(
|
||||
"Failed to decompress request body ({encoding}): {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
headers.remove(axum::http::header::TRANSFER_ENCODING);
|
||||
|
||||
Ok(Bytes::from(decompressed))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex API 处理器
|
||||
// ============================================================================
|
||||
@@ -580,13 +624,14 @@ pub async fn handle_chat_completions(
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let mut headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
@@ -645,13 +690,14 @@ pub async fn handle_responses(
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let mut headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
@@ -723,13 +769,14 @@ pub async fn handle_responses_compact(
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let mut headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
|
||||
@@ -223,7 +223,8 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
// 响应解压由 response_processor 根据 content-encoding 手动处理。
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate();
|
||||
.no_deflate()
|
||||
.no_zstd();
|
||||
|
||||
// 有代理地址则使用代理,否则跟随系统代理
|
||||
if let Some(url) = proxy_url {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub(crate) mod content_encoding;
|
||||
pub mod copilot_optimizer;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! - 通过 JWT id_token 提取 chatgpt_account_id 作为账号唯一标识
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use reqwest::Client;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -236,7 +236,6 @@ pub struct CodexOAuthManager {
|
||||
/// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms}
|
||||
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
|
||||
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
|
||||
http_client: Client,
|
||||
storage_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -250,7 +249,6 @@ impl CodexOAuthManager {
|
||||
access_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
};
|
||||
|
||||
@@ -272,8 +270,7 @@ impl CodexOAuthManager {
|
||||
pub async fn start_device_flow(&self) -> Result<GitHubDeviceCodeResponse, CodexOAuthError> {
|
||||
log::info!("[CodexOAuth] 启动 Device Code 流程");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(DEVICE_AUTH_USERCODE_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", CODEX_USER_AGENT)
|
||||
@@ -355,8 +352,7 @@ impl CodexOAuthManager {
|
||||
|
||||
log::debug!("[CodexOAuth] 轮询 Device Code");
|
||||
|
||||
let poll_response = self
|
||||
.http_client
|
||||
let poll_response = crate::proxy::http_client::get()
|
||||
.post(DEVICE_AUTH_TOKEN_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", CODEX_USER_AGENT)
|
||||
@@ -443,8 +439,7 @@ impl CodexOAuthManager {
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<OAuthTokenResponse, CodexOAuthError> {
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(OAUTH_TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("User-Agent", CODEX_USER_AGENT)
|
||||
@@ -477,8 +472,7 @@ impl CodexOAuthManager {
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
) -> Result<OAuthTokenResponse, CodexOAuthError> {
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(OAUTH_TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("User-Agent", CODEX_USER_AGENT)
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//! - Provider 通过 meta.authBinding 关联账号
|
||||
//! - 自动迁移 v1 单账号格式到 v3 多账号 + 默认账号格式
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -429,8 +428,6 @@ pub struct CopilotAuthManager {
|
||||
api_endpoints: Arc<RwLock<HashMap<String, String>>>,
|
||||
/// 每个账号的端点拉取锁,避免并发拉取重复打 GitHub API
|
||||
endpoint_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
/// HTTP 客户端
|
||||
http_client: Client,
|
||||
/// 存储路径
|
||||
storage_path: PathBuf,
|
||||
/// 待迁移的旧格式 token
|
||||
@@ -452,7 +449,6 @@ impl CopilotAuthManager {
|
||||
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||
api_endpoints: Arc::new(RwLock::new(HashMap::new())),
|
||||
endpoint_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
pending_migration: Arc::new(RwLock::new(None)),
|
||||
migration_error: Arc::new(RwLock::new(None)),
|
||||
@@ -608,8 +604,7 @@ impl CopilotAuthManager {
|
||||
};
|
||||
log::info!("[CopilotAuth] 启动设备码流程 (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(github_device_code_url(&domain))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
@@ -653,8 +648,7 @@ impl CopilotAuthManager {
|
||||
};
|
||||
log::debug!("[CopilotAuth] 轮询 OAuth Token (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(github_oauth_token_url(&domain))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
@@ -837,8 +831,7 @@ impl CopilotAuthManager {
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(&models_url)
|
||||
.header("Authorization", format!("Bearer {copilot_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -925,8 +918,7 @@ impl CopilotAuthManager {
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(copilot_usage_url(&domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -1040,8 +1032,7 @@ impl CopilotAuthManager {
|
||||
|
||||
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(copilot_usage_url(&domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -1318,8 +1309,7 @@ impl CopilotAuthManager {
|
||||
github_token: &str,
|
||||
domain: &str,
|
||||
) -> Result<GitHubUser, CopilotAuthError> {
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(github_user_url(domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
@@ -1351,8 +1341,7 @@ impl CopilotAuthManager {
|
||||
) -> Result<(), CopilotAuthError> {
|
||||
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(copilot_token_url(domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 统一处理流式和非流式 API 响应
|
||||
|
||||
use super::{
|
||||
content_encoding::{decompress_body, get_content_encoding},
|
||||
forwarder::ActiveConnectionGuard,
|
||||
handler_config::{StreamUsageEventFilter, UsageParserConfig},
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
@@ -19,7 +20,6 @@ use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
io::Read,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
@@ -29,60 +29,9 @@ use std::{
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ============================================================================
|
||||
// 响应解压
|
||||
// 响应头处理
|
||||
// ============================================================================
|
||||
|
||||
/// 根据 content-encoding 解压响应体字节
|
||||
///
|
||||
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
||||
/// 返回 `Ok(None)` 表示编码不受支持、原样透传——此时调用方必须保留
|
||||
/// content-encoding 头,否则下游(诊断/客户端)会把压缩字节误当明文。
|
||||
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
match content_encoding {
|
||||
"gzip" | "x-gzip" => {
|
||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"deflate" => {
|
||||
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游发 raw deflate 流。
|
||||
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规上游必然解压失败,
|
||||
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
|
||||
let mut decompressed = Vec::new();
|
||||
let mut zlib = flate2::read::ZlibDecoder::new(body);
|
||||
match zlib.read_to_end(&mut decompressed) {
|
||||
Ok(_) => Ok(Some(decompressed)),
|
||||
Err(zlib_err) => {
|
||||
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
|
||||
let mut decompressed = Vec::new();
|
||||
let mut raw = flate2::read::DeflateDecoder::new(body);
|
||||
raw.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
}
|
||||
}
|
||||
"br" => {
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
_ => {
|
||||
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从响应头提取 content-encoding(忽略 identity 和 chunked)
|
||||
fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("content-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty() && s != "identity")
|
||||
}
|
||||
|
||||
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
|
||||
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
@@ -878,40 +827,6 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
|
||||
// RFC 9110 规范的 deflate = zlib 包裹格式(合规上游发的就是这个)
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_falls_back_to_raw_stream() {
|
||||
// 部分上游违规发 raw deflate 流,保持兼容
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
|
||||
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
|
||||
// 头被剥掉,下游诊断会把压缩字节误报成明文
|
||||
let result = decompress_body("zstd", b"\x28\xb5\x2f\xfd").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"slug": "native-responses-template",
|
||||
"display_name": "native-responses-template",
|
||||
"description": "native-responses-template",
|
||||
"base_instructions": "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.",
|
||||
"default_reasoning_level": "high",
|
||||
"supported_reasoning_levels": [
|
||||
{
|
||||
"effort": "none",
|
||||
"description": "Disable Thinking"
|
||||
},
|
||||
{
|
||||
"effort": "high",
|
||||
"description": "Enabled Thinking"
|
||||
}
|
||||
],
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"supported_in_api": true,
|
||||
"priority": 0,
|
||||
"supports_reasoning_summaries": true,
|
||||
"default_reasoning_summary": "none",
|
||||
"support_verbosity": false,
|
||||
"truncation_policy": {
|
||||
"mode": "bytes",
|
||||
"limit": 10000
|
||||
},
|
||||
"supports_parallel_tool_calls": false,
|
||||
"supports_image_detail_original": false,
|
||||
"context_window": 262144,
|
||||
"max_context_window": 262144,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_search_tool": false
|
||||
}
|
||||
@@ -159,11 +159,16 @@ impl ConfigService {
|
||||
}
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
|
||||
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
|
||||
);
|
||||
|
||||
crate::codex_config::write_codex_provider_live_with_catalog(
|
||||
&provider.settings_config,
|
||||
provider.category.as_deref(),
|
||||
auth,
|
||||
cfg_text,
|
||||
profile,
|
||||
)?;
|
||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||
|
||||
@@ -918,11 +918,19 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||
let config_str = obj.get("config").and_then(|v| v.as_str());
|
||||
|
||||
// Native (direct) Responses providers must suppress Codex's freeform
|
||||
// apply_patch custom tool via the generated catalog; chat/proxy
|
||||
// providers keep the default tool set. Keyed on provider.meta.apiFormat.
|
||||
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
|
||||
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
|
||||
);
|
||||
|
||||
crate::codex_config::write_codex_provider_live_with_catalog(
|
||||
&provider.settings_config,
|
||||
provider.category.as_deref(),
|
||||
auth,
|
||||
config_str,
|
||||
profile,
|
||||
)?;
|
||||
if provider
|
||||
.meta
|
||||
|
||||
@@ -103,7 +103,7 @@ mod tests {
|
||||
use crate::claude_desktop_config::PROFILE_ID;
|
||||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
|
||||
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta, UsageScript};
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};
|
||||
use crate::proxy::types::ProxyConfig;
|
||||
@@ -228,6 +228,68 @@ mod tests {
|
||||
result
|
||||
}
|
||||
|
||||
fn codex_settings(base_url: &str, api_key: &str) -> Value {
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": api_key
|
||||
},
|
||||
"config": format!(
|
||||
"model_provider = \"custom\"\n\
|
||||
[model_providers.custom]\n\
|
||||
name = \"custom\"\n\
|
||||
base_url = \"{base_url}\"\n\
|
||||
wire_api = \"chat\"\n"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn usage_script_with_credentials(
|
||||
api_key: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> UsageScript {
|
||||
UsageScript {
|
||||
enabled: true,
|
||||
language: "javascript".to_string(),
|
||||
code: "return { remaining: 1, unit: 'USD' };".to_string(),
|
||||
timeout: Some(10),
|
||||
api_key: api_key.map(str::to_string),
|
||||
base_url: base_url.map(str::to_string),
|
||||
access_token: None,
|
||||
user_id: None,
|
||||
template_type: template_type.map(str::to_string),
|
||||
auto_query_interval: None,
|
||||
coding_plan_provider: None,
|
||||
access_key_id: Some("ak-test".to_string()),
|
||||
secret_access_key: Some("sk-test".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_provider_with_usage(
|
||||
id: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
usage_api_key: Option<&str>,
|
||||
usage_base_url: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Provider {
|
||||
let mut provider = Provider::with_id(
|
||||
id.to_string(),
|
||||
format!("Provider {id}"),
|
||||
codex_settings(base_url, api_key),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
usage_script: Some(usage_script_with_credentials(
|
||||
usage_api_key,
|
||||
usage_base_url,
|
||||
template_type,
|
||||
)),
|
||||
..Default::default()
|
||||
});
|
||||
provider
|
||||
}
|
||||
|
||||
fn openclaw_provider(id: &str) -> Provider {
|
||||
Provider {
|
||||
id: id.to_string(),
|
||||
@@ -328,6 +390,255 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn add_clears_usage_credentials_that_match_provider_config() {
|
||||
with_test_home(|state, _| {
|
||||
let provider = codex_provider_with_usage(
|
||||
"codex-a",
|
||||
"https://api.a.example/v1/",
|
||||
"sk-a",
|
||||
Some(" sk-a "),
|
||||
Some(" https://api.a.example/v1/ "),
|
||||
None,
|
||||
);
|
||||
|
||||
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||||
|
||||
let saved = state
|
||||
.db
|
||||
.get_provider_by_id("codex-a", AppType::Codex.as_str())
|
||||
.expect("query saved provider")
|
||||
.expect("saved provider should exist");
|
||||
let script = saved
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should remain");
|
||||
|
||||
assert_eq!(script.api_key, None);
|
||||
assert_eq!(script.base_url, None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn update_preserves_usage_credentials_that_only_match_previous_config() {
|
||||
with_test_home(|state, _| {
|
||||
let provider = codex_provider_with_usage(
|
||||
"codex-usage-old",
|
||||
"https://api.a.example/v1/",
|
||||
"sk-a",
|
||||
Some("sk-a"),
|
||||
Some("https://api.a.example/v1/"),
|
||||
None,
|
||||
);
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Codex.as_str(), &provider)
|
||||
.expect("seed provider with explicit usage credentials");
|
||||
|
||||
let mut updated = provider.clone();
|
||||
updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||||
|
||||
ProviderService::update(state, AppType::Codex, None, updated)
|
||||
.expect("update provider main credentials");
|
||||
|
||||
let saved = state
|
||||
.db
|
||||
.get_provider_by_id("codex-usage-old", AppType::Codex.as_str())
|
||||
.expect("query updated provider")
|
||||
.expect("updated provider should exist");
|
||||
let script = saved
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should remain");
|
||||
|
||||
assert_eq!(script.api_key.as_deref(), Some("sk-a"));
|
||||
assert_eq!(
|
||||
script.base_url.as_deref(),
|
||||
Some("https://api.a.example/v1/")
|
||||
);
|
||||
assert_eq!(
|
||||
saved.resolve_usage_credentials(&AppType::Codex),
|
||||
("https://api.b.example/v1".to_string(), "sk-b".to_string())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() {
|
||||
with_test_home(|state, _| {
|
||||
let copied_provider = codex_provider_with_usage(
|
||||
"codex-copy",
|
||||
"https://api.a.example/v1/",
|
||||
"sk-a",
|
||||
Some("sk-a"),
|
||||
Some("https://api.a.example/v1/"),
|
||||
None,
|
||||
);
|
||||
|
||||
ProviderService::add(state, AppType::Codex, copied_provider, false)
|
||||
.expect("add copied provider");
|
||||
|
||||
let saved_after_add = state
|
||||
.db
|
||||
.get_provider_by_id("codex-copy", AppType::Codex.as_str())
|
||||
.expect("query copied provider")
|
||||
.expect("copied provider should exist");
|
||||
let script_after_add = saved_after_add
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should remain");
|
||||
assert_eq!(script_after_add.api_key, None);
|
||||
assert_eq!(script_after_add.base_url, None);
|
||||
|
||||
let mut edited_provider = saved_after_add.clone();
|
||||
edited_provider.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||||
|
||||
ProviderService::update(state, AppType::Codex, None, edited_provider)
|
||||
.expect("edit copied provider credentials");
|
||||
|
||||
let saved_after_update = state
|
||||
.db
|
||||
.get_provider_by_id("codex-copy", AppType::Codex.as_str())
|
||||
.expect("query edited provider")
|
||||
.expect("edited provider should exist");
|
||||
let script_after_update = saved_after_update
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should remain");
|
||||
|
||||
assert_eq!(script_after_update.api_key, None);
|
||||
assert_eq!(script_after_update.base_url, None);
|
||||
assert_eq!(
|
||||
saved_after_update.resolve_usage_credentials(&AppType::Codex),
|
||||
("https://api.b.example/v1".to_string(), "sk-b".to_string())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn update_clears_usage_credentials_that_match_current_config() {
|
||||
with_test_home(|state, _| {
|
||||
let provider = codex_provider_with_usage(
|
||||
"codex-current",
|
||||
"https://api.a.example/v1",
|
||||
"sk-a",
|
||||
Some("sk-usage"),
|
||||
Some("https://usage.example/api"),
|
||||
None,
|
||||
);
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Codex.as_str(), &provider)
|
||||
.expect("seed provider with distinct usage credentials");
|
||||
|
||||
let mut updated = provider.clone();
|
||||
updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||||
updated.meta = Some(ProviderMeta {
|
||||
usage_script: Some(usage_script_with_credentials(
|
||||
Some(" sk-b "),
|
||||
Some(" https://api.b.example/v1/ "),
|
||||
None,
|
||||
)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
ProviderService::update(state, AppType::Codex, None, updated)
|
||||
.expect("update provider with redundant usage credentials");
|
||||
|
||||
let saved = state
|
||||
.db
|
||||
.get_provider_by_id("codex-current", AppType::Codex.as_str())
|
||||
.expect("query updated provider")
|
||||
.expect("updated provider should exist");
|
||||
let script = saved
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should remain");
|
||||
|
||||
assert_eq!(script.api_key, None);
|
||||
assert_eq!(script.base_url, None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn add_preserves_distinct_usage_credentials() {
|
||||
with_test_home(|state, _| {
|
||||
let provider = codex_provider_with_usage(
|
||||
"codex-distinct",
|
||||
"https://api.main.example/v1",
|
||||
"sk-main",
|
||||
Some("sk-usage"),
|
||||
Some("https://usage.example/api"),
|
||||
None,
|
||||
);
|
||||
|
||||
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||||
|
||||
let saved = state
|
||||
.db
|
||||
.get_provider_by_id("codex-distinct", AppType::Codex.as_str())
|
||||
.expect("query saved provider")
|
||||
.expect("saved provider should exist");
|
||||
let script = saved
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should remain");
|
||||
|
||||
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
|
||||
assert_eq!(
|
||||
script.base_url.as_deref(),
|
||||
Some("https://usage.example/api")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn add_does_not_clear_token_plan_credentials() {
|
||||
with_test_home(|state, _| {
|
||||
let provider = codex_provider_with_usage(
|
||||
"codex-token-plan",
|
||||
"https://api.plan.example/v1",
|
||||
"sk-plan",
|
||||
Some("sk-plan"),
|
||||
Some("https://api.plan.example/v1"),
|
||||
Some("token_plan"),
|
||||
);
|
||||
|
||||
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||||
|
||||
let saved = state
|
||||
.db
|
||||
.get_provider_by_id("codex-token-plan", AppType::Codex.as_str())
|
||||
.expect("query saved provider")
|
||||
.expect("saved provider should exist");
|
||||
let script = saved
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should remain");
|
||||
|
||||
assert_eq!(script.api_key.as_deref(), Some("sk-plan"));
|
||||
assert_eq!(
|
||||
script.base_url.as_deref(),
|
||||
Some("https://api.plan.example/v1")
|
||||
);
|
||||
assert_eq!(script.access_key_id.as_deref(), Some("ak-test"));
|
||||
assert_eq!(script.secret_access_key.as_deref(), Some("sk-test"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_settings_rejects_missing_auth() {
|
||||
let provider = Provider::with_id(
|
||||
@@ -344,6 +655,83 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
|
||||
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)
|
||||
// 与可共享配置;顶层混入非标准的 apiKey/api_key 凭据与正常设置。
|
||||
let settings = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant",
|
||||
"ANTHROPIC_AUTH_TOKEN": "tok-ant",
|
||||
"OPENROUTER_API_KEY": "sk-or",
|
||||
"GOOGLE_API_KEY": "g-key",
|
||||
"OPENAI_API_KEY": "sk-oai",
|
||||
"GEMINI_API_KEY": "g-gem",
|
||||
"AWS_ACCESS_KEY_ID": "AKIA",
|
||||
"AWS_SECRET_ACCESS_KEY": "secret",
|
||||
"AWS_SESSION_TOKEN": "sess",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json",
|
||||
"AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok",
|
||||
"ANTHROPIC_BASE_URL": "https://example.com",
|
||||
"ANTHROPIC_MODEL": "claude-x",
|
||||
// 可共享、非机密配置(复数 _TOKENS 不应被误剥)
|
||||
"ENABLE_TOOL_SEARCH": "true",
|
||||
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192"
|
||||
},
|
||||
"apiKey": "sk-top",
|
||||
"api_key": "sk-top2",
|
||||
"theme": "dark",
|
||||
"includeCoAuthoredBy": false
|
||||
});
|
||||
|
||||
let snippet = ProviderService::extract_claude_common_config(&settings)
|
||||
.expect("extract should succeed");
|
||||
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||
|
||||
// 所有凭据都不得出现在共享片段里
|
||||
let env = value.get("env");
|
||||
for leaked in [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
] {
|
||||
assert!(
|
||||
env.and_then(|e| e.get(leaked)).is_none(),
|
||||
"credential {leaked} must not leak into common config"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
value.get("apiKey").is_none() && value.get("api_key").is_none(),
|
||||
"top-level credentials must be stripped"
|
||||
);
|
||||
|
||||
// 端点/模型(provider-specific 非机密)也应剥掉
|
||||
assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none());
|
||||
assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none());
|
||||
|
||||
// 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)
|
||||
assert_eq!(
|
||||
env.and_then(|e| e.get("ENABLE_TOOL_SEARCH"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
env.and_then(|e| e.get("CLAUDE_CODE_MAX_OUTPUT_TOKENS"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("8192")
|
||||
);
|
||||
assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark"));
|
||||
assert_eq!(value.get("includeCoAuthoredBy"), Some(&json!(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_settings_rejects_negative_cost_multiplier() {
|
||||
let mut provider = Provider::with_id(
|
||||
@@ -1318,6 +1706,72 @@ impl ProviderService {
|
||||
.live_config_managed = Some(managed);
|
||||
}
|
||||
|
||||
fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) {
|
||||
let current_credentials = provider.resolve_usage_credentials(app_type);
|
||||
|
||||
let Some(usage_script) = provider
|
||||
.meta
|
||||
.as_mut()
|
||||
.and_then(|meta| meta.usage_script.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if usage_script.template_type.as_deref() == Some("token_plan") {
|
||||
return;
|
||||
}
|
||||
|
||||
if usage_script.api_key.as_deref().is_some_and(|api_key| {
|
||||
Self::should_clear_usage_api_key_override(api_key, ¤t_credentials)
|
||||
}) {
|
||||
usage_script.api_key = None;
|
||||
}
|
||||
|
||||
if usage_script.base_url.as_deref().is_some_and(|base_url| {
|
||||
Self::should_clear_usage_base_url_override(base_url, ¤t_credentials)
|
||||
}) {
|
||||
usage_script.base_url = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn should_clear_usage_api_key_override(
|
||||
script_api_key: &str,
|
||||
current_credentials: &(String, String),
|
||||
) -> bool {
|
||||
let candidate = script_api_key.trim();
|
||||
if candidate.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let matches_provider_key = |api_key: &str| {
|
||||
let api_key = api_key.trim();
|
||||
!api_key.is_empty() && api_key == candidate
|
||||
};
|
||||
|
||||
matches_provider_key(¤t_credentials.1)
|
||||
}
|
||||
|
||||
fn should_clear_usage_base_url_override(
|
||||
script_base_url: &str,
|
||||
current_credentials: &(String, String),
|
||||
) -> bool {
|
||||
let candidate = Self::normalize_usage_base_url_for_compare(script_base_url);
|
||||
if candidate.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let matches_provider_base_url = |base_url: &str| {
|
||||
let base_url = Self::normalize_usage_base_url_for_compare(base_url);
|
||||
!base_url.is_empty() && base_url == candidate
|
||||
};
|
||||
|
||||
matches_provider_base_url(¤t_credentials.0)
|
||||
}
|
||||
|
||||
fn normalize_usage_base_url_for_compare(base_url: &str) -> String {
|
||||
base_url.trim().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// List all providers for an app type
|
||||
pub fn list(
|
||||
state: &AppState,
|
||||
@@ -1354,6 +1808,7 @@ impl ProviderService {
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
|
||||
if app_type.is_additive_mode() {
|
||||
Self::set_provider_live_config_managed(&mut provider, add_to_live);
|
||||
}
|
||||
@@ -1408,6 +1863,7 @@ impl ProviderService {
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
|
||||
|
||||
if provider_id_changed {
|
||||
if !app_type.is_additive_mode() {
|
||||
@@ -1896,6 +2352,17 @@ impl ProviderService {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
// 切走前先把 live 里的可共享改动(含用户直接在应用内
|
||||
// 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。
|
||||
// 详见 sync_common_config_snippet_from_live 的文档。
|
||||
Self::sync_common_config_snippet_from_live(
|
||||
state,
|
||||
&app_type,
|
||||
¤t_provider,
|
||||
&live_config,
|
||||
&mut result,
|
||||
);
|
||||
|
||||
current_provider.settings_config =
|
||||
strip_common_config_from_live_settings(
|
||||
state.db.as_ref(),
|
||||
@@ -2123,6 +2590,100 @@ impl ProviderService {
|
||||
Self::migrate_legacy_common_config_usage(state, app_type, &snippet)
|
||||
}
|
||||
|
||||
/// 切走某供应商前,把它 live 配置里的可共享部分重新提取并**整体替换**到
|
||||
/// 通用配置片段,使在 live 应用里直接做的改动不会因切换而丢失。
|
||||
///
|
||||
/// 采用"整体重提取 + 替换"而非"只合并新增",是为了同时覆盖三种情况:
|
||||
/// - **新增**:用户直接在应用里装了插件、加了 hook、改了 env/主题/权限等共享
|
||||
/// 偏好,被捕获进通用配置,切到别的供应商也带得过去;
|
||||
/// - **删除**:被删掉的键不在新提取结果里,于是从片段里消失、下次切换不会被
|
||||
/// 重新注入——否则会出现"插件怎么删也删不掉"的反直觉 bug;
|
||||
/// - **密钥安全**:提取器已剥掉 auth / model / endpoint,密钥永不进共享片段。
|
||||
///
|
||||
/// 之所以"整体替换"是安全的:每次写 live 都会把当前片段合并进去,所以切走时
|
||||
/// 读到的 live 一定是"片段 + 本地改动"的超集,重提取只会丢掉用户真正删掉的键,
|
||||
/// 不会误删其它供应商共享的内容。
|
||||
///
|
||||
/// **作用域**:仅 Claude。Codex 的 live 是 TOML 且端点藏在 `[model_providers]`
|
||||
/// 表里(现有提取器不剥),自动同步会泄漏端点并与 modelCatalog / 统一会话桶 /
|
||||
/// auth 还原逻辑冲突;Gemini 暂未纳入。两者如需支持应各自单独验证后再加。
|
||||
///
|
||||
/// 仅对**显式勾选"写入通用配置"**(`meta.common_config_enabled == Some(true)`)的
|
||||
/// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的
|
||||
/// 配置又塞回来。所有失败均为非致命,只记 warning,绝不阻断切换。
|
||||
fn sync_common_config_snippet_from_live(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
live_config: &Value,
|
||||
result: &mut SwitchResult,
|
||||
) {
|
||||
// 作用域限定 Claude(见函数文档)。
|
||||
if !matches!(app_type, AppType::Claude) {
|
||||
return;
|
||||
}
|
||||
|
||||
let opted_in = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
== Some(true);
|
||||
if !opted_in {
|
||||
return;
|
||||
}
|
||||
|
||||
match state.db.is_config_snippet_cleared(app_type.as_str()) {
|
||||
Ok(true) => return, // 用户显式清空过通用配置,尊重其选择,不再自动塞回
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to read common config cleared flag for {}: {err}",
|
||||
app_type.as_str()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let new_snippet = match Self::extract_common_config_snippet_from_settings(
|
||||
app_type.clone(),
|
||||
live_config,
|
||||
) {
|
||||
Ok(snippet) => snippet,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to extract common config from live for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 未变化则跳过,避免无谓写库(不切 live 配置时这是常态路径)。
|
||||
let current = state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())
|
||||
.ok()
|
||||
.flatten();
|
||||
if current.as_deref() == Some(new_snippet.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = state
|
||||
.db
|
||||
.set_config_snippet(app_type.as_str(), Some(new_snippet))
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to persist synced common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
result
|
||||
.warnings
|
||||
.push(format!("common_config_sync_failed:{}", provider.id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
@@ -2169,16 +2730,63 @@ impl ProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的
|
||||
/// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小
|
||||
/// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用
|
||||
/// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。
|
||||
///
|
||||
/// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY`
|
||||
/// (Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持
|
||||
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
||||
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
||||
/// 私钥命名。
|
||||
fn is_sensitive_config_key(name: &str) -> bool {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
|
||||
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
|
||||
// (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
|
||||
const SENSITIVE_SUFFIXES: &[&str] = &[
|
||||
"_API_KEY",
|
||||
"_APIKEY",
|
||||
"_AUTH_TOKEN",
|
||||
"_TOKEN",
|
||||
"_ACCESS_KEY",
|
||||
"_ACCESS_KEY_ID",
|
||||
"_KEY_ID",
|
||||
"_PRIVATE_KEY",
|
||||
];
|
||||
const SENSITIVE_EXACT: &[&str] = &[
|
||||
"APIKEY",
|
||||
"API_KEY",
|
||||
"TOKEN",
|
||||
"SECRET",
|
||||
"PASSWORD",
|
||||
"CREDENTIALS",
|
||||
];
|
||||
// contains:覆盖 AWS_SECRET_ACCESS_KEY / *_CLIENT_SECRET /
|
||||
// GOOGLE_APPLICATION_CREDENTIALS / AWS_BEARER_TOKEN_BEDROCK 等变体。
|
||||
const SENSITIVE_CONTAINS: &[&str] = &[
|
||||
"SECRET",
|
||||
"PASSWORD",
|
||||
"PASSWD",
|
||||
"CREDENTIAL",
|
||||
"PRIVATE_KEY",
|
||||
"BEARER_TOKEN",
|
||||
];
|
||||
|
||||
SENSITIVE_EXACT.contains(&upper.as_str())
|
||||
|| SENSITIVE_SUFFIXES.iter().any(|s| upper.ends_with(s))
|
||||
|| SENSITIVE_CONTAINS.iter().any(|c| upper.contains(c))
|
||||
}
|
||||
|
||||
/// Extract common config for Claude (JSON format)
|
||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Fields to exclude from common config
|
||||
const ENV_EXCLUDES: &[&str] = &[
|
||||
// Auth
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
// Models and Claude Code model-menu display names
|
||||
// 供应商专属的**非机密**字段(模型 + 端点),不应共享。凭据/机密不在此列举,
|
||||
// 改由 `is_sensitive_config_key`(模式匹配)统一剥离,新供应商的 `*_API_KEY`
|
||||
// 等无需再手工补名单即可被覆盖。
|
||||
const ENV_PROVIDER_SPECIFIC_EXCLUDES: &[&str] = &[
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
@@ -2187,7 +2795,6 @@ impl ProviderService {
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
||||
// Endpoint
|
||||
"ANTHROPIC_BASE_URL",
|
||||
];
|
||||
|
||||
@@ -2198,22 +2805,39 @@ impl ProviderService {
|
||||
"smallFastModel",
|
||||
];
|
||||
|
||||
// Remove env fields
|
||||
// Remove env fields: provider-specific (models/endpoint) + 任何凭据键。
|
||||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
for key in ENV_EXCLUDES {
|
||||
let sensitive: Vec<String> = env
|
||||
.keys()
|
||||
.filter(|k| Self::is_sensitive_config_key(k))
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in ENV_PROVIDER_SPECIFIC_EXCLUDES {
|
||||
env.remove(*key);
|
||||
}
|
||||
for key in &sensitive {
|
||||
env.remove(key);
|
||||
}
|
||||
// If env is empty after removal, remove the env object itself
|
||||
if env.is_empty() {
|
||||
config.as_object_mut().map(|obj| obj.remove("env"));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove top-level fields
|
||||
// Remove top-level fields: legacy model fields + 任何凭据键
|
||||
// (例如非标准的顶层 apiKey / api_key / *_TOKEN)。
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
let sensitive: Vec<String> = obj
|
||||
.keys()
|
||||
.filter(|k| Self::is_sensitive_config_key(k))
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in TOP_LEVEL_EXCLUDES {
|
||||
obj.remove(*key);
|
||||
}
|
||||
for key in &sensitive {
|
||||
obj.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if result is empty
|
||||
|
||||
@@ -2213,12 +2213,16 @@ impl ProxyService {
|
||||
.get("auth")
|
||||
.ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?;
|
||||
let config_str = effective_settings.get("config").and_then(|v| v.as_str());
|
||||
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
|
||||
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
|
||||
);
|
||||
|
||||
crate::codex_config::write_codex_provider_live_with_catalog(
|
||||
&effective_settings,
|
||||
provider.category.as_deref(),
|
||||
auth,
|
||||
config_str,
|
||||
profile,
|
||||
)
|
||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
|
||||
}
|
||||
@@ -2512,12 +2516,16 @@ impl ProxyService {
|
||||
.get("auth")
|
||||
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
|
||||
let config_str = config.get("config").and_then(|v| v.as_str());
|
||||
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
|
||||
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
|
||||
);
|
||||
|
||||
crate::codex_config::write_codex_provider_live_with_catalog(
|
||||
config,
|
||||
provider.category.as_deref(),
|
||||
auth,
|
||||
config_str,
|
||||
profile,
|
||||
)
|
||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))
|
||||
}
|
||||
@@ -2537,9 +2545,12 @@ impl ProxyService {
|
||||
.filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))
|
||||
{
|
||||
let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
|
||||
provider.and_then(|p| p.meta.as_ref()?.api_format.as_deref()),
|
||||
);
|
||||
let prepared_config =
|
||||
crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(
|
||||
config, config_str,
|
||||
config, config_str, profile,
|
||||
)
|
||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
|
||||
let live_config =
|
||||
@@ -2573,10 +2584,18 @@ impl ProxyService {
|
||||
// living in the config's `experimental_bearer_token`). Computing it up here
|
||||
// keeps every config-writing branch — write-auth, delete-auth, no-auth —
|
||||
// consistent instead of letting the empty-auth path skip projection.
|
||||
// Verbatim restore has no Provider in hand (we only have the stored
|
||||
// backup config), so the catalog tool profile can't be recovered here.
|
||||
// Default to ProxyChat: a restored native-direct backup keeps its inline
|
||||
// modelCatalog but would not get apply_patch re-stripped until the next
|
||||
// provider switch rewrites it via write_live_snapshot. Acceptable known
|
||||
// limitation (restore-of-deleted-provider-backup only).
|
||||
let prepared_cfg = config_str
|
||||
.map(|cfg| {
|
||||
crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(
|
||||
config, cfg,
|
||||
config,
|
||||
cfg,
|
||||
crate::codex_config::CodexCatalogToolProfile::ProxyChat,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
|
||||
@@ -2197,8 +2197,24 @@ fn strip_model_date_suffix(model_id: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
let (base, suffix) = model_id.rsplit_once('-')?;
|
||||
(!base.is_empty() && suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()))
|
||||
.then(|| base.to_string())
|
||||
if base.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
// 8 位 YYYYMMDD(如 -20250615;OpenAI / Claude / 通义千问等)。
|
||||
if suffix.len() == 8 {
|
||||
return Some(base.to_string());
|
||||
}
|
||||
// 6 位 YYMMDD(如 -260628;火山方舟 doubao-seed-*、部分国产厂商)。
|
||||
// 6 位比 8 位更易误伤非日期尾巴(如 -123456 的版本号),故额外校验
|
||||
// 月 01-12、日 01-31 才剥离;剥不动时退回 None 由上层精确匹配兜底。
|
||||
if suffix.len() == 6 {
|
||||
let month: u32 = suffix[2..4].parse().unwrap_or(0);
|
||||
let day: u32 = suffix[4..6].parse().unwrap_or(0);
|
||||
if (1..=12).contains(&month) && (1..=31).contains(&day) {
|
||||
return Some(base.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn strip_reasoning_effort_suffix(model_id: &str) -> Option<String> {
|
||||
@@ -3859,6 +3875,55 @@ mod tests {
|
||||
assert_eq!(strip_model_date_suffix("abc🚀12345678"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_model_date_suffix_handles_six_digit_yymmdd() {
|
||||
// 火山方舟 6 位 YYMMDD 后缀应被剥离(doubao 全系都用这种格式)。
|
||||
assert_eq!(
|
||||
strip_model_date_suffix("doubao-seed-2-1-pro-260628").as_deref(),
|
||||
Some("doubao-seed-2-1-pro")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_model_date_suffix("doubao-seed-1-6-250615").as_deref(),
|
||||
Some("doubao-seed-1-6")
|
||||
);
|
||||
// 8 位 YYYYMMDD 仍照旧剥离。
|
||||
assert_eq!(
|
||||
strip_model_date_suffix("claude-3-5-sonnet-20241022").as_deref(),
|
||||
Some("claude-3-5-sonnet")
|
||||
);
|
||||
// 月/日非法的 6 位尾巴(版本号等)不剥离,避免误伤。
|
||||
assert_eq!(strip_model_date_suffix("foo-bar-123456"), None); // 月=34
|
||||
assert_eq!(strip_model_date_suffix("widget-209900"), None); // 月=99
|
||||
assert_eq!(strip_model_date_suffix("gizmo-251200"), None); // 日=00
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pricing_resolves_volcengine_dated_model_to_bare_seed_row() -> Result<(), AppError> {
|
||||
// 回归:火山真实用量带 6 位日期后缀(doubao-seed-2-1-pro-260628),
|
||||
// 必须能归一化命中定价表里的裸名 seed 行(doubao-seed-2-1-pro),否则成本显示 $0。
|
||||
let db = Database::memory()?;
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES ('doubao-seed-2-1-pro', 'Doubao Seed 2.1 Pro', '0.84', '4.2', '0.17', '0')",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let row = find_model_pricing_row(&conn, "doubao-seed-2-1-pro-260628")?;
|
||||
assert!(
|
||||
row.is_some(),
|
||||
"带日期的火山模型应通过 6 位日期剥离命中裸名定价行"
|
||||
);
|
||||
let (input, output, ..) = row.unwrap();
|
||||
assert_eq!(input, "0.84");
|
||||
assert_eq!(output, "4.2");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_pricing_does_not_match_short_base_model_to_variant() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.16.3",
|
||||
"version": "3.16.4",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -1696,6 +1696,367 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 切走勾选了通用配置的 Claude 供应商时,应把它 live 里新增的可共享键
|
||||
/// (用户直接在应用内装插件/改偏好)捕获进通用配置片段,并带到下一个供应商。
|
||||
#[test]
|
||||
fn switch_claude_syncs_new_shared_keys_from_live_into_common_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||
}
|
||||
// A 的 live = A 私有密钥(含非 Anthropic 的 OpenRouter 凭据)+ 已共享的 theme
|
||||
// + 用户刚在应用内新增的 enableAllProjectMcpServers
|
||||
let live = json!({
|
||||
"env": { "ANTHROPIC_API_KEY": "a-key", "OPENROUTER_API_KEY": "sk-or-leak" },
|
||||
"theme": "dark",
|
||||
"enableAllProjectMcpServers": true
|
||||
});
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||||
)
|
||||
.expect("seed claude live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "a".to_string();
|
||||
let mut provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_a.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("a".to_string(), provider_a);
|
||||
let mut provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_b.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("b".to_string(), provider_b);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{"theme":"dark"}"#.to_string()),
|
||||
)
|
||||
.expect("seed common config snippet");
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||||
|
||||
// 片段应捕获到新增键,并保留已有共享键,且绝不含密钥
|
||||
let snippet = state
|
||||
.db
|
||||
.get_config_snippet(AppType::Claude.as_str())
|
||||
.expect("read snippet")
|
||||
.expect("snippet present");
|
||||
let snippet_value: serde_json::Value =
|
||||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||
assert_eq!(
|
||||
snippet_value.get("enableAllProjectMcpServers"),
|
||||
Some(&json!(true)),
|
||||
"newly added shared key should be captured into common config"
|
||||
);
|
||||
assert_eq!(
|
||||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||||
Some("dark"),
|
||||
"previously shared key should be preserved"
|
||||
);
|
||||
assert!(
|
||||
snippet_value
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.is_none(),
|
||||
"secrets must never leak into the shared snippet"
|
||||
);
|
||||
assert!(
|
||||
snippet_value
|
||||
.get("env")
|
||||
.and_then(|env| env.get("OPENROUTER_API_KEY"))
|
||||
.is_none(),
|
||||
"non-Anthropic Claude credentials must never leak into the shared snippet"
|
||||
);
|
||||
|
||||
// 新增键应通过通用配置带到 B 的 live
|
||||
let live_after: serde_json::Value =
|
||||
read_json_file(&settings_path).expect("read live after switch");
|
||||
assert_eq!(
|
||||
live_after.get("enableAllProjectMcpServers"),
|
||||
Some(&json!(true)),
|
||||
"shared key should propagate to the next provider's live config"
|
||||
);
|
||||
assert!(
|
||||
live_after
|
||||
.get("env")
|
||||
.and_then(|env| env.get("OPENROUTER_API_KEY"))
|
||||
.is_none(),
|
||||
"leaked credential must not be injected into the next provider's live"
|
||||
);
|
||||
assert_eq!(
|
||||
live_after
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("b-key"),
|
||||
"live should reflect new provider's own auth"
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户在应用内删掉一个已共享的键后,切换应把删除同步进通用配置,
|
||||
/// 且不会在切到下一个供应商时被重新注入(否则会"删不掉")。
|
||||
#[test]
|
||||
fn switch_claude_syncs_deletions_from_live_into_common_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||
}
|
||||
// live 里 theme 还在,但用户已删掉 enableAllProjectMcpServers
|
||||
let live = json!({
|
||||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||||
"theme": "dark"
|
||||
});
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||||
)
|
||||
.expect("seed claude live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "a".to_string();
|
||||
let mut provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_a.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("a".to_string(), provider_a);
|
||||
let mut provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_b.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("b".to_string(), provider_b);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
// 片段里仍残留 enableAllProjectMcpServers(上次共享的)
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{"theme":"dark","enableAllProjectMcpServers":true}"#.to_string()),
|
||||
)
|
||||
.expect("seed common config snippet");
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||||
|
||||
let snippet = state
|
||||
.db
|
||||
.get_config_snippet(AppType::Claude.as_str())
|
||||
.expect("read snippet")
|
||||
.expect("snippet present");
|
||||
let snippet_value: serde_json::Value =
|
||||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||
assert!(
|
||||
snippet_value.get("enableAllProjectMcpServers").is_none(),
|
||||
"deleted key should be removed from common config"
|
||||
);
|
||||
assert_eq!(
|
||||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||||
Some("dark"),
|
||||
"untouched shared key should remain"
|
||||
);
|
||||
|
||||
// 切到 B 后 live 不应再出现被删除的键
|
||||
let live_after: serde_json::Value =
|
||||
read_json_file(&settings_path).expect("read live after switch");
|
||||
assert!(
|
||||
live_after.get("enableAllProjectMcpServers").is_none(),
|
||||
"deleted shared key must not be re-injected into the next provider"
|
||||
);
|
||||
}
|
||||
|
||||
/// 未勾选"写入通用配置"的供应商,其 live 改动不应自动污染通用配置片段。
|
||||
#[test]
|
||||
fn switch_claude_does_not_sync_common_config_for_opted_out_provider() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||
}
|
||||
let live = json!({
|
||||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||||
"providerSpecific": "x"
|
||||
});
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||||
)
|
||||
.expect("seed claude live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "a".to_string();
|
||||
// A 未勾选通用配置(meta = None)
|
||||
manager.providers.insert(
|
||||
"a".to_string(),
|
||||
Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
),
|
||||
);
|
||||
manager.providers.insert(
|
||||
"b".to_string(),
|
||||
Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{"theme":"dark"}"#.to_string()),
|
||||
)
|
||||
.expect("seed common config snippet");
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||||
|
||||
let snippet = state
|
||||
.db
|
||||
.get_config_snippet(AppType::Claude.as_str())
|
||||
.expect("read snippet")
|
||||
.expect("snippet present");
|
||||
let snippet_value: serde_json::Value =
|
||||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||
assert!(
|
||||
snippet_value.get("providerSpecific").is_none(),
|
||||
"opted-out provider's live changes must not pollute the shared snippet"
|
||||
);
|
||||
assert_eq!(
|
||||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||||
Some("dark"),
|
||||
"snippet should stay unchanged for opted-out providers"
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户显式清空过通用配置(_cleared)后,切换不应把片段重新塞回来。
|
||||
#[test]
|
||||
fn switch_claude_respects_explicitly_cleared_common_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||
}
|
||||
let live = json!({
|
||||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||||
"theme": "dark"
|
||||
});
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||||
)
|
||||
.expect("seed claude live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "a".to_string();
|
||||
let mut provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_a.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("a".to_string(), provider_a);
|
||||
let mut provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_b.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("b".to_string(), provider_b);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
|
||||
.expect("mark snippet cleared");
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||||
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.get_config_snippet(AppType::Claude.as_str())
|
||||
.expect("read snippet")
|
||||
.is_none(),
|
||||
"explicitly cleared snippet must not be resurrected by switch-away sync"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_switch_missing_provider_returns_error() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
|
||||
import { useTauriEvent } from "@/hooks/useTauriEvent";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { useScanUnmanagedSkills } from "@/hooks/useSkills";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
import { deepClone } from "@/utils/deepClone";
|
||||
@@ -250,6 +251,10 @@ function App() {
|
||||
const mcpPanelRef = useRef<any>(null);
|
||||
const skillsPageRef = useRef<any>(null);
|
||||
const unifiedSkillsPanelRef = useRef<any>(null);
|
||||
// 订阅未管理 Skill 的共享缓存(实际扫描由 UnifiedSkillsPanel 进入页面时触发)。
|
||||
// 这里 enabled 默认 false,仅用于「导入」按钮的绿点提示,不主动发起扫描。
|
||||
const { data: unmanagedSkills } = useScanUnmanagedSkills();
|
||||
const hasUnmanagedSkills = (unmanagedSkills?.length ?? 0) > 0;
|
||||
const addActionButtonClass =
|
||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||
|
||||
@@ -1315,10 +1320,21 @@ function App() {
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openImport()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
className="relative hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={
|
||||
hasUnmanagedSkills
|
||||
? t("skills.unmanagedAvailable")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("skills.import")}
|
||||
{hasUnmanagedSkills && (
|
||||
<span
|
||||
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-green-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { exit } from "@tauri-apps/plugin-process";
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const RELEASES_URL = "https://github.com/farion1231/cc-switch/releases";
|
||||
|
||||
interface DatabaseUpgradeProps {
|
||||
payload: {
|
||||
path?: string;
|
||||
error?: string;
|
||||
kind?: string;
|
||||
db_version?: number;
|
||||
supported_version?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// checking: 启动时检查是否有可用更新
|
||||
// upgradable: 有可用更新,升级应用即可解决
|
||||
// incompatible: 已是最新版本但数据库仍过新(可能来自第三方客户端),升级无法解决
|
||||
// updating: 正在下载/安装更新
|
||||
// error: 升级过程出错
|
||||
type Phase = "checking" | "upgradable" | "incompatible" | "updating" | "error";
|
||||
|
||||
interface DownloadProgress {
|
||||
downloaded: number;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库版本过新(应用过旧)时的应用内恢复界面。
|
||||
*
|
||||
* 启动时先检查是否有可用更新:
|
||||
* - 有 → 提供「升级应用」一键下载+安装+重启,并展示下载进度条。
|
||||
* - 无 → 说明当前已是最新版本但数据库仍不兼容(通常由第三方客户端或更高版本创建),
|
||||
* 升级无法解决,及时提醒用户备份后改用兼容客户端或等待官方支持。
|
||||
*/
|
||||
export function DatabaseUpgrade({ payload }: DatabaseUpgradeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<Phase>("checking");
|
||||
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<DownloadProgress | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const dbVersion = payload.db_version;
|
||||
const supportedVersion = payload.supported_version;
|
||||
|
||||
// 启动时检查可用更新,决定 upgradable / incompatible
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const version = await invoke<string | null>(
|
||||
"check_app_update_available",
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (version) {
|
||||
setAvailableVersion(version);
|
||||
setPhase("upgradable");
|
||||
} else {
|
||||
setPhase("incompatible");
|
||||
}
|
||||
} catch {
|
||||
// 检查失败(如离线):仍允许尝试升级,避免完全卡死
|
||||
if (!cancelled) setPhase("upgradable");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unlistenRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startUpgrade = useCallback(async () => {
|
||||
setPhase("updating");
|
||||
setProgress(null);
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
unlistenRef.current?.();
|
||||
unlistenRef.current = await listen<DownloadProgress>(
|
||||
"update-download-progress",
|
||||
(e) => setProgress(e.payload),
|
||||
);
|
||||
// 成功时后端会下载+安装+重启,不会返回;返回 false 表示无可用更新。
|
||||
const updating = await invoke<boolean>("install_update_and_restart");
|
||||
unlistenRef.current?.();
|
||||
unlistenRef.current = null;
|
||||
if (!updating) {
|
||||
// 竞态:检查时有更新、安装时已无 → 按不兼容处理
|
||||
setPhase("incompatible");
|
||||
}
|
||||
// updating === true:应用即将重启,保持 updating 态直到进程退出。
|
||||
} catch (e) {
|
||||
unlistenRef.current?.();
|
||||
unlistenRef.current = null;
|
||||
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||
setPhase("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const percent =
|
||||
progress && progress.total && progress.total > 0
|
||||
? Math.min(100, Math.round((progress.downloaded / progress.total) * 100))
|
||||
: null;
|
||||
const fmtMB = (n: number) => (n / 1024 / 1024).toFixed(1);
|
||||
|
||||
const isIncompatible = phase === "incompatible";
|
||||
const accent = isIncompatible
|
||||
? {
|
||||
chip: "bg-red-100 text-red-600 dark:bg-red-950/50 dark:text-red-400",
|
||||
Icon: AlertTriangle,
|
||||
}
|
||||
: {
|
||||
chip: "bg-amber-100 text-amber-600 dark:bg-amber-950/50 dark:text-amber-400",
|
||||
Icon: Database,
|
||||
};
|
||||
const AccentIcon = accent.Icon;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6 text-foreground">
|
||||
<div className="w-full max-w-lg space-y-5 rounded-2xl border border-border/60 bg-card/80 p-7 shadow-xl">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-xl ${accent.chip}`}
|
||||
>
|
||||
<AccentIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("dbUpgrade.title", "数据库版本过新")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"dbUpgrade.description",
|
||||
"当前数据库由更新版本的 CC Switch 创建,需要升级应用后才能继续使用。升级不会删除你的数据。",
|
||||
)}
|
||||
</p>
|
||||
{dbVersion != null && supportedVersion != null && (
|
||||
<p className="pt-0.5 text-xs text-muted-foreground tabular-nums">
|
||||
{t("dbUpgrade.versionInfo", {
|
||||
db: dbVersion,
|
||||
supported: supportedVersion,
|
||||
defaultValue: "数据库版本 v{{db}} · 应用支持 v{{supported}}",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误详情 / 数据库路径 */}
|
||||
<div className="space-y-1 rounded-lg border border-border/50 bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
{payload.error && (
|
||||
<p className="break-words font-mono">{payload.error}</p>
|
||||
)}
|
||||
{payload.path && (
|
||||
<p className="break-all">
|
||||
{t("dbUpgrade.dbPath", "数据库文件")}:{payload.path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{phase === "checking" && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("dbUpgrade.checking", "正在检查可用更新…")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{phase === "upgradable" && availableVersion && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dbUpgrade.updateAvailable", {
|
||||
version: availableVersion,
|
||||
defaultValue: "发现新版本 v{{version}},升级后即可继续使用。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{phase === "incompatible" && (
|
||||
<div className="space-y-2 rounded-lg border border-red-300/60 bg-red-50 p-3 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-950/40 dark:text-red-300">
|
||||
<p className="font-medium">
|
||||
{t("dbUpgrade.incompatibleTitle", "升级也无法解决")}
|
||||
</p>
|
||||
<p className="leading-relaxed">
|
||||
{t("dbUpgrade.incompatibleDescription", {
|
||||
db: dbVersion,
|
||||
supported: supportedVersion,
|
||||
defaultValue:
|
||||
"你已是最新版本,但数据库版本(v{{db}})仍高于本应用支持的版本(v{{supported}})。该数据库可能由第三方客户端或更高版本创建,升级当前官方应用也无法兼容。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "updating" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{percent === null
|
||||
? t("dbUpgrade.preparing", "正在准备更新…")
|
||||
: t("dbUpgrade.downloading", "正在下载更新…")}
|
||||
</span>
|
||||
{percent !== null && (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{percent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full bg-amber-500 transition-all duration-200 ${
|
||||
percent === null ? "w-1/3 animate-pulse" : ""
|
||||
}`}
|
||||
style={percent === null ? undefined : { width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress && (
|
||||
<p className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
{fmtMB(progress.downloaded)} MB
|
||||
{progress.total ? ` / ${fmtMB(progress.total)} MB` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "error" && errorMsg && (
|
||||
<p className="rounded-lg border border-red-300/60 bg-red-50 p-3 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-950/40 dark:text-red-300">
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(phase === "upgradable" || phase === "error") && (
|
||||
<Button
|
||||
onClick={startUpgrade}
|
||||
className="gap-2 bg-amber-500 text-white hover:bg-amber-600"
|
||||
>
|
||||
{phase === "error" ? (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{phase === "error"
|
||||
? t("dbUpgrade.retry", "重试升级")
|
||||
: t("dbUpgrade.upgradeNow", "升级应用")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(phase === "incompatible" || phase === "error") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() =>
|
||||
void invoke("open_external", { url: RELEASES_URL })
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t("dbUpgrade.openReleases", "打开发布页")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => void invoke("open_app_config_folder")}
|
||||
disabled={phase === "updating"}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{t("dbUpgrade.openConfigDir", "打开配置目录")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => void exit(0)}
|
||||
disabled={phase === "updating"}
|
||||
>
|
||||
{t("dbUpgrade.quit", "退出")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatabaseUpgrade;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||
import { Play, Wand2, Eye, EyeOff, Save, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -31,6 +31,14 @@ import {
|
||||
} from "@/config/codingPlanProviders";
|
||||
import { formatUsageDataSummary } from "@/utils/usageDisplay";
|
||||
|
||||
/**
|
||||
* 火山引擎账号级 AccessKey 的密钥管理页(IAM)。
|
||||
* 用量查询走控制面 OpenAPI,需要 AK/SK 签名,与推理 API Key 是两套凭据,
|
||||
* 直接给用户一个可点击的直达地址,省得在控制台里翻菜单。
|
||||
*/
|
||||
const VOLCENGINE_KEY_CONSOLE_URL =
|
||||
"https://console.volcengine.com/iam/keymanage";
|
||||
|
||||
interface UsageScriptModalProps {
|
||||
provider: Provider;
|
||||
appId: AppId;
|
||||
@@ -1269,6 +1277,19 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{t("usageScript.volcengineAkSkHint")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t("usageScript.volcengineKeyConsoleLink")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
settingsApi.openExternal(VOLCENGINE_KEY_CONSOLE_URL)
|
||||
}
|
||||
className="inline-flex items-center gap-1 text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors break-all align-baseline underline-offset-2 hover:underline"
|
||||
>
|
||||
{VOLCENGINE_KEY_CONSOLE_URL}
|
||||
<ExternalLink size={12} className="shrink-0" />
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
@@ -39,9 +39,11 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { CodexOAuthSection } from "./CodexOAuthSection";
|
||||
import { CopilotAuthSection } from "./CopilotAuthSection";
|
||||
import { ApiKeySection } from "./shared/ApiKeySection";
|
||||
import { EndpointField } from "./shared/EndpointField";
|
||||
import { ModelDropdown } from "./shared/ModelDropdown";
|
||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||
import { useApiKeyLink } from "./hooks/useApiKeyLink";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type {
|
||||
ClaudeApiFormat,
|
||||
@@ -386,6 +388,21 @@ export function ClaudeDesktopProviderForm({
|
||||
activeProviderType === "github_copilot" ||
|
||||
activeProviderType === "codex_oauth";
|
||||
|
||||
// API Key 获取/邀请链接(与 Claude Code 表单同款,见 ClaudeFormFields)
|
||||
const apiKeyLinkCategory = activePreset?.category ?? initialData?.category;
|
||||
const {
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl: apiKeyLinkWebsiteUrl,
|
||||
isPartner: apiKeyLinkIsPartner,
|
||||
partnerPromotionKey: apiKeyLinkPromotionKey,
|
||||
} = useApiKeyLink({
|
||||
appId: "claude-desktop",
|
||||
category: apiKeyLinkCategory,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
const applyDesktopPreset = (preset: ClaudeDesktopProviderPreset) => {
|
||||
form.setValue("name", preset.nameKey ? t(preset.nameKey) : preset.name);
|
||||
form.setValue("websiteUrl", preset.websiteUrl);
|
||||
@@ -781,15 +798,15 @@ export function ClaudeDesktopProviderForm({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label>{"API Key"}</Label>
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
category={apiKeyLinkCategory}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={apiKeyLinkWebsiteUrl}
|
||||
isPartner={apiKeyLinkIsPartner}
|
||||
partnerPromotionKey={apiKeyLinkPromotionKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EndpointField
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||
import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
@@ -148,6 +149,10 @@ interface ClaudeFormFieldsProps {
|
||||
// Local proxy User-Agent override
|
||||
customUserAgent: string;
|
||||
onCustomUserAgentChange: (value: string) => void;
|
||||
localProxyHeadersOverride: string;
|
||||
onLocalProxyHeadersOverrideChange: (value: string) => void;
|
||||
localProxyBodyOverride: string;
|
||||
onLocalProxyBodyOverrideChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -205,8 +210,15 @@ export function ClaudeFormFields({
|
||||
onFullUrlChange,
|
||||
customUserAgent,
|
||||
onCustomUserAgentChange,
|
||||
localProxyHeadersOverride,
|
||||
onLocalProxyHeadersOverrideChange,
|
||||
localProxyBodyOverride,
|
||||
onLocalProxyBodyOverrideChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasRequestOverrides = Boolean(
|
||||
localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(),
|
||||
);
|
||||
const hasAnyAdvancedValue = !!(
|
||||
claudeModel ||
|
||||
defaultHaikuModel ||
|
||||
@@ -215,7 +227,8 @@ export function ClaudeFormFields({
|
||||
defaultFableModel ||
|
||||
apiFormat !== "anthropic" ||
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
|
||||
customUserAgent
|
||||
customUserAgent ||
|
||||
hasRequestOverrides
|
||||
);
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
|
||||
@@ -979,6 +992,15 @@ export function ClaudeFormFields({
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
|
||||
<div className="border-t border-border-default pt-3">
|
||||
<LocalProxyRequestOverridesField
|
||||
headersJson={localProxyHeadersOverride}
|
||||
bodyJson={localProxyBodyOverride}
|
||||
onHeadersJsonChange={onLocalProxyHeadersOverrideChange}
|
||||
onBodyJsonChange={onLocalProxyBodyOverrideChange}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -27,6 +34,7 @@ import {
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||
import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
CodexApiFormat,
|
||||
@@ -85,6 +93,10 @@ interface CodexFormFieldsProps {
|
||||
// Local proxy User-Agent override
|
||||
customUserAgent: string;
|
||||
onCustomUserAgentChange: (value: string) => void;
|
||||
localProxyHeadersOverride: string;
|
||||
onLocalProxyHeadersOverrideChange: (value: string) => void;
|
||||
localProxyBodyOverride: string;
|
||||
onLocalProxyBodyOverrideChange: (value: string) => void;
|
||||
}
|
||||
|
||||
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
|
||||
@@ -95,13 +107,24 @@ function createCatalogRow(seed?: Partial<CodexCatalogModel>): CodexCatalogRow {
|
||||
model: seed?.model ?? "",
|
||||
displayName: seed?.displayName ?? "",
|
||||
contextWindow: seed?.contextWindow ?? "",
|
||||
// Carry native-profile overrides verbatim (not user-editable in the row UI,
|
||||
// but must survive load->save so the official catalog fidelity is kept).
|
||||
...(seed?.supportsParallelToolCalls !== undefined
|
||||
? { supportsParallelToolCalls: seed.supportsParallelToolCalls }
|
||||
: {}),
|
||||
...(seed?.inputModalities ? { inputModalities: seed.inputModalities } : {}),
|
||||
...(seed?.baseInstructions
|
||||
? { baseInstructions: seed.baseInstructions }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Compares rows (with rowId) to incoming models (without) by data fields only,
|
||||
// so both sync effects can use the same equality definition.
|
||||
// so both sync effects can use the same equality definition. Hidden native-profile
|
||||
// fields are included so switching between providers with identical visible fields
|
||||
// but different base_instructions / tools / modalities still rebuilds the rows.
|
||||
function catalogRowsMatchModels(
|
||||
rows: Array<Pick<CodexCatalogRow, "model" | "displayName" | "contextWindow">>,
|
||||
rows: CodexCatalogModel[],
|
||||
models: CodexCatalogModel[],
|
||||
): boolean {
|
||||
if (rows.length !== models.length) return false;
|
||||
@@ -110,7 +133,13 @@ function catalogRowsMatchModels(
|
||||
return (
|
||||
row.model === (incoming.model ?? "") &&
|
||||
(row.displayName ?? "") === (incoming.displayName ?? "") &&
|
||||
String(row.contextWindow ?? "") === String(incoming.contextWindow ?? "")
|
||||
String(row.contextWindow ?? "") ===
|
||||
String(incoming.contextWindow ?? "") &&
|
||||
(row.supportsParallelToolCalls ?? null) ===
|
||||
(incoming.supportsParallelToolCalls ?? null) &&
|
||||
(row.baseInstructions ?? "") === (incoming.baseInstructions ?? "") &&
|
||||
JSON.stringify(row.inputModalities ?? []) ===
|
||||
JSON.stringify(incoming.inputModalities ?? [])
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -148,12 +177,18 @@ export function CodexFormFields({
|
||||
speedTestEndpoints,
|
||||
customUserAgent,
|
||||
onCustomUserAgentChange,
|
||||
localProxyHeadersOverride,
|
||||
onLocalProxyHeadersOverrideChange,
|
||||
localProxyBodyOverride,
|
||||
onLocalProxyBodyOverrideChange,
|
||||
}: CodexFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
const needsLocalRouting = apiFormat === "openai_chat";
|
||||
// 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻
|
||||
//(填了才生成 catalog)。两者都已与「路由接管」概念解耦。
|
||||
const isChatFormat = apiFormat === "openai_chat";
|
||||
const canEditCatalog = Boolean(onCatalogModelsChange);
|
||||
const canEditReasoning = Boolean(onCodexChatReasoningChange);
|
||||
const supportsThinking =
|
||||
@@ -161,8 +196,18 @@ export function CodexFormFields({
|
||||
codexChatReasoning.supportsEffort === true;
|
||||
const supportsEffort = codexChatReasoning.supportsEffort === true;
|
||||
|
||||
// needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见
|
||||
const hasAnyAdvancedValue = !!customUserAgent || needsLocalRouting;
|
||||
// 高级区在有任何可见配置时自动展开(仅折叠→展开,不会自动折叠):自定义 UA /
|
||||
// 请求覆盖 / 已填模型映射 / 原生 Responses(需维护 catalog)/ 已配置思考能力。
|
||||
const hasRequestOverrides = Boolean(
|
||||
localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(),
|
||||
);
|
||||
const hasAnyAdvancedValue =
|
||||
!!customUserAgent ||
|
||||
hasRequestOverrides ||
|
||||
catalogModels.length > 0 ||
|
||||
apiFormat === "openai_responses" ||
|
||||
supportsThinking ||
|
||||
supportsEffort;
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
|
||||
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
@@ -203,13 +248,6 @@ export function CodexFormFields({
|
||||
onCatalogModelsChange(next);
|
||||
}, [catalogRows, onCatalogModelsChange]);
|
||||
|
||||
const handleLocalRoutingChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
onApiFormatChange(checked ? "openai_chat" : "openai_responses");
|
||||
},
|
||||
[onApiFormatChange],
|
||||
);
|
||||
|
||||
const handleReasoningThinkingChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!onCodexChatReasoningChange) return;
|
||||
@@ -374,7 +412,7 @@ export function CodexFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 高级选项 —— 本地路由映射/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
|
||||
{/* 高级选项 —— 上游格式/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
|
||||
{category !== "official" && (
|
||||
<Collapsible
|
||||
open={advancedExpanded}
|
||||
@@ -402,43 +440,57 @@ export function CodexFormFields({
|
||||
<p className="mt-1 ml-1 text-xs text-muted-foreground">
|
||||
{t("codexConfig.advancedSectionHint", {
|
||||
defaultValue:
|
||||
"包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。",
|
||||
"包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{/* 本地路由映射开关 —— 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换 */}
|
||||
{/* 上游格式 —— Chat 需开启路由接管(走代理转换),Responses 原生直连。
|
||||
沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换。 */}
|
||||
{shouldShowSpeedTest && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.localRoutingToggle", {
|
||||
defaultValue: "需要本地路由映射",
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<FormLabel htmlFor="codex-upstream-format">
|
||||
{t("codexConfig.upstreamFormatLabel", {
|
||||
defaultValue: "上游格式",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{needsLocalRouting
|
||||
? t("codexConfig.localRoutingOnHint", {
|
||||
defaultValue:
|
||||
"Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。",
|
||||
})
|
||||
: t("codexConfig.localRoutingOffHint", {
|
||||
defaultValue:
|
||||
"如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。",
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
onApiFormatChange(value as CodexApiFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="codex-upstream-format"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("codexConfig.upstreamFormatChat", {
|
||||
defaultValue: "Chat Completions(需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("codexConfig.upstreamFormatResponses", {
|
||||
defaultValue: "Responses(原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.upstreamFormatHint", {
|
||||
defaultValue:
|
||||
"供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(需开启路由接管才能转换为 Chat Completions)。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={needsLocalRouting}
|
||||
onCheckedChange={handleLocalRoutingChange}
|
||||
aria-label={t("codexConfig.localRoutingToggle", {
|
||||
defaultValue: "需要本地路由映射",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsLocalRouting && canEditReasoning && (
|
||||
{isChatFormat && canEditReasoning && (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3",
|
||||
@@ -507,23 +559,17 @@ export function CodexFormFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
(shouldShowSpeedTest ||
|
||||
(needsLocalRouting && canEditReasoning)) &&
|
||||
"border-t border-border-default pt-3",
|
||||
)}
|
||||
>
|
||||
<CustomUserAgentField
|
||||
id="codex-custom-user-agent"
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */}
|
||||
{needsLocalRouting && canEditCatalog && (
|
||||
<div className="space-y-4 border-t border-border-default pt-3">
|
||||
{/* 模型映射 / 模型目录 —— 与「路由接管」解耦,常驻显示(可编辑即渲染)。
|
||||
填了才生成 catalog:Chat 模式生成兼容路由、原生 Responses 生成
|
||||
model-catalogs.json;留空则不生成。排在自定义 UA 之前。 */}
|
||||
{canEditCatalog && (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
(shouldShowSpeedTest || (isChatFormat && canEditReasoning)) &&
|
||||
"border-t border-border-default pt-3",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FormLabel>
|
||||
@@ -662,6 +708,30 @@ export function CodexFormFields({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3",
|
||||
(shouldShowSpeedTest ||
|
||||
(isChatFormat && canEditReasoning) ||
|
||||
canEditCatalog) &&
|
||||
"border-t border-border-default pt-3",
|
||||
)}
|
||||
>
|
||||
<CustomUserAgentField
|
||||
id="codex-custom-user-agent"
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
<div className="border-t border-border-default pt-3">
|
||||
<LocalProxyRequestOverridesField
|
||||
headersJson={localProxyHeadersOverride}
|
||||
bodyJson={localProxyBodyOverride}
|
||||
onHeadersJsonChange={onLocalProxyHeadersOverrideChange}
|
||||
onBodyJsonChange={onLocalProxyBodyOverrideChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function CommonConfigEditor({
|
||||
/>
|
||||
<span>
|
||||
{t("claudeConfig.writeCommonConfig", {
|
||||
defaultValue: "写入通用配置",
|
||||
defaultValue: "应用通用配置",
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -69,7 +69,7 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
{t("geminiConfig.writeCommonConfig", {
|
||||
defaultValue: "写入通用配置",
|
||||
defaultValue: "应用通用配置",
|
||||
})}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +330,9 @@ export function HermesFormFields({
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
// Hermes 没有 OAuth-only 的免 key 官方供应商:即便是 official 预设
|
||||
// (如 Nous Research)也需用户自填 key,故不让 official 禁用输入框。
|
||||
category={category === "official" ? undefined : category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
parseBodyOverrideJson,
|
||||
parseHeaderOverrideJson,
|
||||
} from "@/lib/requestOverrides";
|
||||
|
||||
interface LocalProxyRequestOverridesFieldProps {
|
||||
headersJson: string;
|
||||
bodyJson: string;
|
||||
onHeadersJsonChange: (value: string) => void;
|
||||
onBodyJsonChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LocalProxyRequestOverridesField({
|
||||
headersJson,
|
||||
bodyJson,
|
||||
onHeadersJsonChange,
|
||||
onBodyJsonChange,
|
||||
}: LocalProxyRequestOverridesFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const headerError = parseHeaderOverrideJson(headersJson).error;
|
||||
const bodyError = parseBodyOverrideJson(bodyJson).error;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("providerForm.localProxyRequestOverrides", {
|
||||
defaultValue: "本地代理请求覆盖",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.localProxyRequestOverridesHint", {
|
||||
defaultValue:
|
||||
"仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-xs text-muted-foreground">
|
||||
{t("providerForm.localProxyHeaderOverrides", {
|
||||
defaultValue: "Header 覆盖",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
value={headersJson}
|
||||
onChange={(event) => onHeadersJsonChange(event.target.value)}
|
||||
placeholder={'{\n "X-Provider": "cc-switch"\n}'}
|
||||
className="min-h-[132px] resize-y font-mono text-xs"
|
||||
aria-invalid={Boolean(headerError)}
|
||||
/>
|
||||
{headerError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("providerForm.localProxyHeaderOverridesInvalidDetail", {
|
||||
error: headerError,
|
||||
defaultValue: "Header 覆盖格式错误:{{error}}",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-xs text-muted-foreground">
|
||||
{t("providerForm.localProxyBodyOverrides", {
|
||||
defaultValue: "Body 覆盖",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
value={bodyJson}
|
||||
onChange={(event) => onBodyJsonChange(event.target.value)}
|
||||
placeholder={'{\n "temperature": 0.2\n}'}
|
||||
className="min-h-[132px] resize-y font-mono text-xs"
|
||||
aria-invalid={Boolean(bodyError)}
|
||||
/>
|
||||
{bodyError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("providerForm.localProxyBodyOverridesInvalidDetail", {
|
||||
error: bodyError,
|
||||
defaultValue: "Body 覆盖格式错误:{{error}}",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -257,7 +257,9 @@ export function OpenClawFormFields({
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
// OpenClaw 的 API key 始终由用户自填,没有 OAuth-only 的免 key 官方供应商,
|
||||
// 故不让 official 禁用输入框(与 Hermes 对齐)。
|
||||
category={category === "official" ? undefined : category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
|
||||
@@ -8,6 +8,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import {
|
||||
buildLocalProxyRequestOverrides,
|
||||
formatRequestOverrideObject,
|
||||
} from "@/lib/requestOverrides";
|
||||
import {
|
||||
providersApi,
|
||||
settingsApi,
|
||||
@@ -181,10 +185,24 @@ export const normalizeCodexCatalogModelsForSave = (
|
||||
? Number.parseInt(rawContextWindow, 10)
|
||||
: undefined;
|
||||
|
||||
const inputModalities = item.inputModalities?.filter(
|
||||
(m) => typeof m === "string" && m.trim(),
|
||||
);
|
||||
|
||||
const baseInstructions = item.baseInstructions?.trim();
|
||||
|
||||
normalized.push({
|
||||
model,
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
|
||||
// Native Responses profile overrides (ignored by the chat/proxy profile).
|
||||
...(typeof item.supportsParallelToolCalls === "boolean"
|
||||
? { supportsParallelToolCalls: item.supportsParallelToolCalls }
|
||||
: {}),
|
||||
...(inputModalities && inputModalities.length > 0
|
||||
? { inputModalities }
|
||||
: {}),
|
||||
...(baseInstructions ? { baseInstructions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,6 +244,10 @@ const normalizeCodexChatReasoningForSave = (
|
||||
};
|
||||
};
|
||||
|
||||
type LocalProxyRequestOverridesBuildResult = ReturnType<
|
||||
typeof buildLocalProxyRequestOverrides
|
||||
>;
|
||||
|
||||
export interface ProviderFormProps {
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
@@ -382,6 +404,16 @@ function ProviderFormFull({
|
||||
setCodexFastMode(initialData?.meta?.codexFastMode ?? false);
|
||||
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
|
||||
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
|
||||
setLocalProxyHeadersOverride(
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.headers,
|
||||
),
|
||||
);
|
||||
setLocalProxyBodyOverride(
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.body,
|
||||
),
|
||||
);
|
||||
}, [appId, initialData, supportsFullUrl]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -438,6 +470,10 @@ function ProviderFormFull({
|
||||
const [softIssues, setSoftIssues] = useState<string[] | null>(null);
|
||||
const [pendingFormValues, setPendingFormValues] =
|
||||
useState<ProviderFormData | null>(null);
|
||||
const [
|
||||
pendingLocalProxyRequestOverridesResult,
|
||||
setPendingLocalProxyRequestOverridesResult,
|
||||
] = useState<LocalProxyRequestOverridesBuildResult | null>(null);
|
||||
// 确认框走的提交路径绕过了 react-hook-form 的 isSubmitting,单独追踪
|
||||
const [isConfirmSubmitting, setIsConfirmSubmitting] = useState(false);
|
||||
|
||||
@@ -541,6 +577,18 @@ function ProviderFormFull({
|
||||
const [customUserAgent, setCustomUserAgent] = useState<string>(
|
||||
() => initialData?.meta?.customUserAgent ?? "",
|
||||
);
|
||||
const [localProxyHeadersOverride, setLocalProxyHeadersOverride] =
|
||||
useState<string>(() =>
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.headers,
|
||||
),
|
||||
);
|
||||
const [localProxyBodyOverride, setLocalProxyBodyOverride] = useState<string>(
|
||||
() =>
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.body,
|
||||
),
|
||||
);
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
@@ -558,24 +606,21 @@ function ProviderFormFull({
|
||||
resetCodexConfig,
|
||||
} = useCodexConfigState({ initialData });
|
||||
|
||||
const initialCodexApiFormat: CodexApiFormat =
|
||||
initialData?.meta?.apiFormat === "openai_chat"
|
||||
? "openai_chat"
|
||||
: initialData?.meta?.apiFormat === "openai_responses"
|
||||
? "openai_responses"
|
||||
: (codexApiFormatFromWireApi(
|
||||
extractCodexWireApi(
|
||||
typeof initialData?.settingsConfig?.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "",
|
||||
),
|
||||
) ?? "openai_responses");
|
||||
|
||||
const [localCodexApiFormat, setLocalCodexApiFormat] =
|
||||
useState<CodexApiFormat>(() => {
|
||||
if (initialData?.meta?.apiFormat === "openai_chat") {
|
||||
return "openai_chat";
|
||||
}
|
||||
if (initialData?.meta?.apiFormat === "openai_responses") {
|
||||
return "openai_responses";
|
||||
}
|
||||
return (
|
||||
codexApiFormatFromWireApi(
|
||||
extractCodexWireApi(
|
||||
typeof initialData?.settingsConfig?.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "",
|
||||
),
|
||||
) ?? "openai_responses"
|
||||
);
|
||||
});
|
||||
useState<CodexApiFormat>(initialCodexApiFormat);
|
||||
|
||||
const { configError: codexConfigError, debouncedValidate } =
|
||||
useCodexTomlValidation();
|
||||
@@ -989,7 +1034,26 @@ function ProviderFormFull({
|
||||
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
const shouldApplyLocalProxyRequestOverrides =
|
||||
(appId === "claude" || appId === "codex") && category !== "official";
|
||||
|
||||
const handleSubmit = async (values: ProviderFormData) => {
|
||||
const overridesResult = shouldApplyLocalProxyRequestOverrides
|
||||
? buildLocalProxyRequestOverrides(
|
||||
localProxyHeadersOverride,
|
||||
localProxyBodyOverride,
|
||||
)
|
||||
: {};
|
||||
if (overridesResult.error) {
|
||||
toast.error(
|
||||
t("providerForm.localProxyRequestOverridesInvalid", {
|
||||
defaultValue: `本地代理请求覆盖格式错误:${overridesResult.error}`,
|
||||
error: overridesResult.error,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 软性问题(业务约束,用户可选择仍要保存)
|
||||
const issues: string[] = [];
|
||||
|
||||
@@ -1224,13 +1288,27 @@ function ProviderFormFull({
|
||||
// 弹确认框让用户决定是否仍要保存
|
||||
setSoftIssues(issues);
|
||||
setPendingFormValues(values);
|
||||
setPendingLocalProxyRequestOverridesResult(overridesResult);
|
||||
return;
|
||||
}
|
||||
|
||||
await performSubmit(values);
|
||||
await performSubmit(values, overridesResult);
|
||||
};
|
||||
|
||||
const performSubmit = async (values: ProviderFormData) => {
|
||||
const performSubmit = async (
|
||||
values: ProviderFormData,
|
||||
overridesResult: LocalProxyRequestOverridesBuildResult,
|
||||
) => {
|
||||
if (overridesResult.error) {
|
||||
toast.error(
|
||||
t("providerForm.localProxyRequestOverridesInvalid", {
|
||||
defaultValue: `本地代理请求覆盖格式错误:${overridesResult.error}`,
|
||||
error: overridesResult.error,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let settingsConfig: string;
|
||||
|
||||
if (appId === "codex") {
|
||||
@@ -1246,8 +1324,11 @@ function ProviderFormFull({
|
||||
category !== "official" && (codexConfig ?? "").trim()
|
||||
? setCodexWireApi(codexConfig ?? "", "responses")
|
||||
: (codexConfig ?? "");
|
||||
// 模型映射与「路由接管」解耦:对所有非官方供应商,填了就持久化
|
||||
//(Chat 生成兼容路由、原生 Responses 生成 model-catalogs.json),
|
||||
// 留空归一化为 [] 即不写。后端只看 modelCatalog.models 是否非空。
|
||||
const normalizedCatalogModels =
|
||||
category !== "official" && localCodexApiFormat === "openai_chat"
|
||||
category !== "official"
|
||||
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
|
||||
: [];
|
||||
// Sync first catalog row's model into config.toml so Codex uses it as default
|
||||
@@ -1458,6 +1539,9 @@ function ProviderFormFull({
|
||||
(appId === "claude" || appId === "codex") && category !== "official"
|
||||
? customUserAgent.trim() || undefined
|
||||
: undefined,
|
||||
localProxyRequestOverrides: shouldApplyLocalProxyRequestOverrides
|
||||
? overridesResult.overrides
|
||||
: undefined,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
? pricingConfig.costMultiplier
|
||||
@@ -2085,6 +2169,10 @@ function ProviderFormFull({
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
customUserAgent={customUserAgent}
|
||||
onCustomUserAgentChange={setCustomUserAgent}
|
||||
localProxyHeadersOverride={localProxyHeadersOverride}
|
||||
onLocalProxyHeadersOverrideChange={setLocalProxyHeadersOverride}
|
||||
localProxyBodyOverride={localProxyBodyOverride}
|
||||
onLocalProxyBodyOverrideChange={setLocalProxyBodyOverride}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2124,6 +2212,10 @@ function ProviderFormFull({
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
customUserAgent={customUserAgent}
|
||||
onCustomUserAgentChange={setCustomUserAgent}
|
||||
localProxyHeadersOverride={localProxyHeadersOverride}
|
||||
onLocalProxyHeadersOverrideChange={setLocalProxyHeadersOverride}
|
||||
localProxyBodyOverride={localProxyBodyOverride}
|
||||
onLocalProxyBodyOverrideChange={setLocalProxyBodyOverride}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2448,15 +2540,19 @@ function ProviderFormFull({
|
||||
onConfirm={async () => {
|
||||
if (isConfirmSubmitting) return;
|
||||
const values = pendingFormValues;
|
||||
if (!values) {
|
||||
const overridesResult = pendingLocalProxyRequestOverridesResult;
|
||||
if (!values || !overridesResult) {
|
||||
setSoftIssues(null);
|
||||
setPendingFormValues(null);
|
||||
setPendingLocalProxyRequestOverridesResult(null);
|
||||
return;
|
||||
}
|
||||
setIsConfirmSubmitting(true);
|
||||
try {
|
||||
await performSubmit(values);
|
||||
await performSubmit(values, overridesResult);
|
||||
setSoftIssues(null);
|
||||
setPendingFormValues(null);
|
||||
setPendingLocalProxyRequestOverridesResult(null);
|
||||
} catch (error) {
|
||||
console.error("[ProviderForm] soft-confirm submit failed:", error);
|
||||
// 保留确认框和 pending values,让用户可以重试或取消
|
||||
@@ -2468,6 +2564,7 @@ function ProviderFormFull({
|
||||
if (isConfirmSubmitting) return;
|
||||
setSoftIssues(null);
|
||||
setPendingFormValues(null);
|
||||
setPendingLocalProxyRequestOverridesResult(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||
import type { OpenCodeProviderPreset } from "@/config/opencodeProviderPresets";
|
||||
import type { ClaudeDesktopProviderPreset } from "@/config/claudeDesktopProviderPresets";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -12,7 +13,8 @@ type PresetEntry = {
|
||||
| ProviderPreset
|
||||
| CodexProviderPreset
|
||||
| GeminiProviderPreset
|
||||
| OpenCodeProviderPreset;
|
||||
| OpenCodeProviderPreset
|
||||
| ClaudeDesktopProviderPreset;
|
||||
};
|
||||
|
||||
interface UseApiKeyLinkProps {
|
||||
@@ -80,9 +82,12 @@ export function useApiKeyLink({
|
||||
return {
|
||||
shouldShowApiKeyLink:
|
||||
appId === "claude" ||
|
||||
appId === "claude-desktop" ||
|
||||
appId === "codex" ||
|
||||
appId === "gemini" ||
|
||||
appId === "opencode"
|
||||
appId === "opencode" ||
|
||||
appId === "openclaw" ||
|
||||
appId === "hermes"
|
||||
? shouldShowApiKeyLink
|
||||
: false,
|
||||
websiteUrl: getWebsiteUrl,
|
||||
|
||||
@@ -66,23 +66,51 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
: [];
|
||||
setCodexCatalogModels(
|
||||
rawCatalogModels
|
||||
.map((item: any) => ({
|
||||
model: typeof item?.model === "string" ? item.model : "",
|
||||
displayName:
|
||||
typeof item?.displayName === "string"
|
||||
? item.displayName
|
||||
: typeof item?.display_name === "string"
|
||||
? item.display_name
|
||||
: "",
|
||||
contextWindow:
|
||||
typeof item?.contextWindow === "string" ||
|
||||
typeof item?.contextWindow === "number"
|
||||
? item.contextWindow
|
||||
: typeof item?.context_window === "string" ||
|
||||
typeof item?.context_window === "number"
|
||||
? item.context_window
|
||||
: "",
|
||||
}))
|
||||
.map((item: any) => {
|
||||
// 隐藏字段(原生 Responses profile 用)不在行 UI 暴露,但必须 load→save
|
||||
// 原样保留,否则编辑保存 MiMo/MiniMax 等会丢官方 base_instructions、
|
||||
// 并行工具、图像模态。DB SSOT 为 camelCase、live 反解兜底可能为 snake_case,
|
||||
// 双格式兼容(与 displayName/contextWindow 一致)。
|
||||
const supportsParallelToolCalls =
|
||||
typeof item?.supportsParallelToolCalls === "boolean"
|
||||
? item.supportsParallelToolCalls
|
||||
: typeof item?.supports_parallel_tool_calls === "boolean"
|
||||
? item.supports_parallel_tool_calls
|
||||
: undefined;
|
||||
const inputModalities = Array.isArray(item?.inputModalities)
|
||||
? item.inputModalities
|
||||
: Array.isArray(item?.input_modalities)
|
||||
? item.input_modalities
|
||||
: undefined;
|
||||
const baseInstructions =
|
||||
typeof item?.baseInstructions === "string"
|
||||
? item.baseInstructions
|
||||
: typeof item?.base_instructions === "string"
|
||||
? item.base_instructions
|
||||
: undefined;
|
||||
return {
|
||||
model: typeof item?.model === "string" ? item.model : "",
|
||||
displayName:
|
||||
typeof item?.displayName === "string"
|
||||
? item.displayName
|
||||
: typeof item?.display_name === "string"
|
||||
? item.display_name
|
||||
: "",
|
||||
contextWindow:
|
||||
typeof item?.contextWindow === "string" ||
|
||||
typeof item?.contextWindow === "number"
|
||||
? item.contextWindow
|
||||
: typeof item?.context_window === "string" ||
|
||||
typeof item?.context_window === "number"
|
||||
? item.context_window
|
||||
: "",
|
||||
...(supportsParallelToolCalls !== undefined
|
||||
? { supportsParallelToolCalls }
|
||||
: {}),
|
||||
...(inputModalities ? { inputModalities } : {}),
|
||||
...(baseInstructions ? { baseInstructions } : {}),
|
||||
};
|
||||
})
|
||||
.filter((item: CodexCatalogModel) => item.model.trim()),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
import { opencodeProviderPresets } from "@/config/opencodeProviderPresets";
|
||||
import { openclawProviderPresets } from "@/config/openclawProviderPresets";
|
||||
import { hermesProviderPresets } from "@/config/hermesProviderPresets";
|
||||
|
||||
interface UseProviderCategoryProps {
|
||||
appId: AppId;
|
||||
@@ -44,7 +46,7 @@ export function useProviderCategory({
|
||||
|
||||
// 从预设 ID 提取索引
|
||||
const match = selectedPresetId.match(
|
||||
/^(claude|codex|gemini|opencode)-(\d+)$/,
|
||||
/^(claude|codex|gemini|opencode|openclaw|hermes)-(\d+)$/,
|
||||
);
|
||||
if (!match) return;
|
||||
|
||||
@@ -75,6 +77,16 @@ export function useProviderCategory({
|
||||
if (preset) {
|
||||
setCategory(preset.category || undefined);
|
||||
}
|
||||
} else if (type === "openclaw" && appId === "openclaw") {
|
||||
const preset = openclawProviderPresets[index];
|
||||
if (preset) {
|
||||
setCategory(preset.category || undefined);
|
||||
}
|
||||
} else if (type === "hermes" && appId === "hermes") {
|
||||
const preset = hermesProviderPresets[index];
|
||||
if (preset) {
|
||||
setCategory(preset.category || undefined);
|
||||
}
|
||||
}
|
||||
}, [appId, selectedPresetId, isEditMode, initialCategory]);
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export function ApiKeySection({
|
||||
websiteUrl,
|
||||
placeholder,
|
||||
disabled,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
}: ApiKeySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -73,8 +72,8 @@ export function ApiKeySection({
|
||||
})}
|
||||
</a>
|
||||
|
||||
{/* 合作伙伴促销信息 */}
|
||||
{isPartner && partnerPromotionKey && (
|
||||
{/* 促销信息(与 isPartner 解耦:仅凭 partnerPromotionKey 即可展示,星标仍由 isPartner 控制) */}
|
||||
{partnerPromotionKey && (
|
||||
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-2.5 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300">
|
||||
💡{" "}
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
FileText,
|
||||
X,
|
||||
CheckSquare,
|
||||
ListTree,
|
||||
List,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useDeleteSessionMutation,
|
||||
@@ -27,6 +32,7 @@ import type { SessionMeta } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -35,6 +41,11 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -56,10 +67,19 @@ import {
|
||||
getBaseName,
|
||||
getProviderIconName,
|
||||
getProviderLabel,
|
||||
getSessionDirectoryGroupKey,
|
||||
getSessionKey,
|
||||
groupSessionsByProviderAndDirectory,
|
||||
type SessionDirectoryGroup,
|
||||
type SessionProviderGroup,
|
||||
shouldHideCodexMessageFromToc,
|
||||
} from "./utils";
|
||||
|
||||
const SESSION_LIST_VIEW_MODE_STORAGE_KEY =
|
||||
"cc-switch.sessionManager.listViewMode";
|
||||
const SESSION_GROUP_EXPANSION_STORAGE_KEY =
|
||||
"cc-switch.sessionManager.groupExpansionState";
|
||||
|
||||
type ProviderFilter =
|
||||
| "all"
|
||||
| "codex"
|
||||
@@ -69,6 +89,102 @@ type ProviderFilter =
|
||||
| "gemini"
|
||||
| "hermes";
|
||||
|
||||
type SessionListViewMode = "flat" | "grouped";
|
||||
|
||||
type GroupSelectionState = {
|
||||
checked: boolean | "indeterminate";
|
||||
isSelected: boolean;
|
||||
selectedCount: number;
|
||||
selectableCount: number;
|
||||
};
|
||||
|
||||
type SessionGroupExpansionState = {
|
||||
expandedProviderIds: Set<string>;
|
||||
expandedDirectoryKeys: Set<string>;
|
||||
};
|
||||
|
||||
const readInitialSessionListViewMode = (): SessionListViewMode => {
|
||||
if (typeof window === "undefined") return "flat";
|
||||
const stored = window.localStorage.getItem(
|
||||
SESSION_LIST_VIEW_MODE_STORAGE_KEY,
|
||||
);
|
||||
return stored === "grouped" || stored === "flat" ? stored : "flat";
|
||||
};
|
||||
|
||||
const readInitialSessionGroupExpansionState =
|
||||
(): SessionGroupExpansionState => {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
expandedProviderIds: new Set(),
|
||||
expandedDirectoryKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(
|
||||
SESSION_GROUP_EXPANSION_STORAGE_KEY,
|
||||
);
|
||||
const parsed = stored ? JSON.parse(stored) : null;
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return {
|
||||
expandedProviderIds: new Set(),
|
||||
expandedDirectoryKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
const expandedProviderIds = Array.isArray(parsed.expandedProviderIds)
|
||||
? parsed.expandedProviderIds.filter(
|
||||
(providerId: unknown): providerId is string =>
|
||||
typeof providerId === "string",
|
||||
)
|
||||
: [];
|
||||
const expandedDirectoryKeys = Array.isArray(parsed.expandedDirectoryKeys)
|
||||
? parsed.expandedDirectoryKeys.filter(
|
||||
(directoryKey: unknown): directoryKey is string =>
|
||||
typeof directoryKey === "string",
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
expandedProviderIds: new Set(expandedProviderIds),
|
||||
expandedDirectoryKeys: new Set(expandedDirectoryKeys),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
expandedProviderIds: new Set(),
|
||||
expandedDirectoryKeys: new Set(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const serializeSessionGroupExpansionState = (
|
||||
expandedProviderGroups: Set<string>,
|
||||
expandedDirectoryGroups: Set<string>,
|
||||
) =>
|
||||
JSON.stringify({
|
||||
expandedProviderIds: Array.from(expandedProviderGroups).sort(),
|
||||
expandedDirectoryKeys: Array.from(expandedDirectoryGroups).sort(),
|
||||
});
|
||||
|
||||
const filterSetToAllowedValues = (
|
||||
current: Set<string>,
|
||||
allowedValues: Set<string>,
|
||||
) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
|
||||
current.forEach((value) => {
|
||||
if (allowedValues.has(value)) {
|
||||
next.add(value);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? next : current;
|
||||
};
|
||||
|
||||
export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -96,6 +212,18 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
appId as ProviderFilter,
|
||||
);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [listViewMode, setListViewMode] = useState<SessionListViewMode>(
|
||||
readInitialSessionListViewMode,
|
||||
);
|
||||
const [initialGroupExpansionState] = useState(
|
||||
readInitialSessionGroupExpansionState,
|
||||
);
|
||||
const [expandedProviderGroups, setExpandedProviderGroups] = useState<
|
||||
Set<string>
|
||||
>(() => initialGroupExpansionState.expandedProviderIds);
|
||||
const [expandedDirectoryGroups, setExpandedDirectoryGroups] = useState<
|
||||
Set<string>
|
||||
>(() => initialGroupExpansionState.expandedDirectoryKeys);
|
||||
|
||||
// 使用 FlexSearch 全文搜索
|
||||
const { search: searchSessions } = useSessionSearch({
|
||||
@@ -107,6 +235,57 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
return searchSessions(search);
|
||||
}, [searchSessions, search]);
|
||||
|
||||
const groupedSessions = useMemo(
|
||||
() =>
|
||||
groupSessionsByProviderAndDirectory(
|
||||
filteredSessions,
|
||||
t("sessionManager.unknownDirectory", {
|
||||
defaultValue: "未知目录",
|
||||
}),
|
||||
),
|
||||
[filteredSessions, t],
|
||||
);
|
||||
|
||||
const validGroupExpansionKeys = useMemo(
|
||||
() => ({
|
||||
providerIds: new Set(sessions.map((session) => session.providerId)),
|
||||
directoryKeys: new Set(
|
||||
sessions.map((session) =>
|
||||
getSessionDirectoryGroupKey(session.providerId, session.projectDir),
|
||||
),
|
||||
),
|
||||
}),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
SESSION_LIST_VIEW_MODE_STORAGE_KEY,
|
||||
listViewMode,
|
||||
);
|
||||
}, [listViewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
SESSION_GROUP_EXPANSION_STORAGE_KEY,
|
||||
serializeSessionGroupExpansionState(
|
||||
expandedProviderGroups,
|
||||
expandedDirectoryGroups,
|
||||
),
|
||||
);
|
||||
}, [expandedDirectoryGroups, expandedProviderGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
setExpandedProviderGroups((current) =>
|
||||
filterSetToAllowedValues(current, validGroupExpansionKeys.providerIds),
|
||||
);
|
||||
setExpandedDirectoryGroups((current) =>
|
||||
filterSetToAllowedValues(current, validGroupExpansionKeys.directoryKeys),
|
||||
);
|
||||
}, [isLoading, validGroupExpansionKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSessions.length === 0) {
|
||||
setSelectedKey(null);
|
||||
@@ -131,6 +310,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
);
|
||||
}, [filteredSessions, selectedKey]);
|
||||
|
||||
const listViewModeLabel =
|
||||
listViewMode === "grouped"
|
||||
? t("sessionManager.viewModeGrouped", {
|
||||
defaultValue: "分类",
|
||||
})
|
||||
: t("sessionManager.viewModeFlat", {
|
||||
defaultValue: "列表",
|
||||
});
|
||||
|
||||
const { data: messages = [], isLoading: isLoadingMessages } =
|
||||
useSessionMessagesQuery(
|
||||
selectedSession?.providerId,
|
||||
@@ -402,6 +590,28 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
selectedSessionKeys.has(getSessionKey(session)),
|
||||
);
|
||||
|
||||
const getGroupSelectionState = (
|
||||
groupSessions: SessionMeta[],
|
||||
): GroupSelectionState => {
|
||||
const selectableSessions = groupSessions.filter((session) =>
|
||||
Boolean(session.sourcePath),
|
||||
);
|
||||
const selectedCount = selectableSessions.filter((session) =>
|
||||
selectedSessionKeys.has(getSessionKey(session)),
|
||||
).length;
|
||||
const isSelected =
|
||||
selectableSessions.length > 0 &&
|
||||
selectedCount === selectableSessions.length;
|
||||
|
||||
return {
|
||||
checked:
|
||||
selectedCount === 0 ? false : isSelected ? true : "indeterminate",
|
||||
isSelected,
|
||||
selectedCount,
|
||||
selectableCount: selectableSessions.length,
|
||||
};
|
||||
};
|
||||
|
||||
const toggleSessionChecked = (session: SessionMeta, checked: boolean) => {
|
||||
if (!session.sourcePath) return;
|
||||
const key = getSessionKey(session);
|
||||
@@ -416,6 +626,140 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSessionGroupChecked = (
|
||||
groupSessions: SessionMeta[],
|
||||
checked: boolean,
|
||||
) => {
|
||||
const selectableSessions = groupSessions.filter((session) =>
|
||||
Boolean(session.sourcePath),
|
||||
);
|
||||
if (selectableSessions.length === 0) return;
|
||||
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
selectableSessions.forEach((session) => {
|
||||
const sessionKey = getSessionKey(session);
|
||||
if (checked) {
|
||||
next.add(sessionKey);
|
||||
} else {
|
||||
next.delete(sessionKey);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProviderGroup = (providerId: string) => {
|
||||
setExpandedProviderGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(providerId)) {
|
||||
next.delete(providerId);
|
||||
} else {
|
||||
next.add(providerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDirectoryGroup = (directoryKey: string) => {
|
||||
setExpandedDirectoryGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(directoryKey)) {
|
||||
next.delete(directoryKey);
|
||||
} else {
|
||||
next.add(directoryKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCollapseAllGroups = () => {
|
||||
setExpandedProviderGroups(new Set());
|
||||
setExpandedDirectoryGroups(new Set());
|
||||
};
|
||||
|
||||
const renderSessionItem = (session: SessionMeta) => {
|
||||
const sessionKey = getSessionKey(session);
|
||||
const isSelected = selectedKey !== null && sessionKey === selectedKey;
|
||||
|
||||
return (
|
||||
<SessionItem
|
||||
key={sessionKey}
|
||||
session={session}
|
||||
isSelected={isSelected}
|
||||
selectionMode={selectionMode}
|
||||
searchQuery={search}
|
||||
isChecked={selectedSessionKeys.has(sessionKey)}
|
||||
isCheckDisabled={!session.sourcePath}
|
||||
onSelect={setSelectedKey}
|
||||
onToggleChecked={(checked) => toggleSessionChecked(session, checked)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGroupSelectionBadge = (
|
||||
selectionState: GroupSelectionState,
|
||||
totalCount: number,
|
||||
variant: "secondary" | "outline",
|
||||
) => (
|
||||
<Badge variant={variant} className="shrink-0 text-xs">
|
||||
{selectionMode
|
||||
? `${selectionState.selectedCount}/${selectionState.selectableCount}`
|
||||
: totalCount}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const renderProviderGroupCheckbox = (
|
||||
providerGroup: SessionProviderGroup,
|
||||
providerLabel: string,
|
||||
selectionState: GroupSelectionState,
|
||||
) => {
|
||||
if (!selectionMode) return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selectionState.checked}
|
||||
disabled={selectionState.selectableCount === 0}
|
||||
aria-label={t("sessionManager.selectProviderGroupForBatch", {
|
||||
defaultValue: "选择 {{provider}} 供应商分组内会话",
|
||||
provider: providerLabel,
|
||||
})}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={() =>
|
||||
toggleSessionGroupChecked(
|
||||
providerGroup.sessions,
|
||||
!selectionState.isSelected,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDirectoryGroupCheckbox = (
|
||||
directoryGroup: SessionDirectoryGroup,
|
||||
selectionState: GroupSelectionState,
|
||||
) => {
|
||||
if (!selectionMode) return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selectionState.checked}
|
||||
disabled={selectionState.selectableCount === 0}
|
||||
aria-label={t("sessionManager.selectDirectoryGroupForBatch", {
|
||||
defaultValue: "选择 {{directory}} 目录分组内会话",
|
||||
directory: directoryGroup.label,
|
||||
})}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={() =>
|
||||
toggleSessionGroupChecked(
|
||||
directoryGroup.sessions,
|
||||
!selectionState.isSelected,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleSelectAll = () => {
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -570,6 +914,85 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Select
|
||||
value={listViewMode}
|
||||
onValueChange={(value) =>
|
||||
setListViewMode(value as SessionListViewMode)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger
|
||||
className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted"
|
||||
aria-label={t(
|
||||
"sessionManager.viewModeTooltip",
|
||||
{
|
||||
defaultValue: "查看方式",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("sessionManager.viewModeTooltip", {
|
||||
defaultValue: "查看方式",
|
||||
})}
|
||||
</span>
|
||||
{listViewMode === "grouped" ? (
|
||||
<ListTree className="size-3.5" />
|
||||
) : (
|
||||
<List className="size-3.5" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{listViewModeLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent className="w-40">
|
||||
<SelectItem value="flat">
|
||||
<div className="flex items-center gap-2">
|
||||
<List className="size-3.5" />
|
||||
<span>
|
||||
{t("sessionManager.viewModeFlat", {
|
||||
defaultValue: "列表",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="grouped">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTree className="size-3.5" />
|
||||
<span>
|
||||
{t("sessionManager.viewModeGrouped", {
|
||||
defaultValue: "分类",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{listViewMode === "grouped" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={t(
|
||||
"sessionManager.collapseAllGroups",
|
||||
{
|
||||
defaultValue: "全部收起",
|
||||
},
|
||||
)}
|
||||
onClick={handleCollapseAllGroups}
|
||||
>
|
||||
<ChevronsDownUp className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.collapseAllGroups", {
|
||||
defaultValue: "全部收起",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -600,7 +1023,20 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<SelectTrigger
|
||||
className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted"
|
||||
aria-label={t(
|
||||
"sessionManager.providerFilterTooltip",
|
||||
{
|
||||
defaultValue: "供应商筛选",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("sessionManager.providerFilterTooltip", {
|
||||
defaultValue: "供应商筛选",
|
||||
})}
|
||||
</span>
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
@@ -784,32 +1220,168 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
{t("sessionManager.noSessions")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredSessions.map((session) => {
|
||||
const isSelected =
|
||||
selectedKey !== null &&
|
||||
getSessionKey(session) === selectedKey;
|
||||
) : listViewMode === "grouped" ? (
|
||||
<div className="space-y-2">
|
||||
{groupedSessions.map((providerGroup) => {
|
||||
const providerOpen = expandedProviderGroups.has(
|
||||
providerGroup.providerId,
|
||||
);
|
||||
const providerLabel = getProviderLabel(
|
||||
providerGroup.providerId,
|
||||
t,
|
||||
);
|
||||
const providerSelectionState = getGroupSelectionState(
|
||||
providerGroup.sessions,
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionItem
|
||||
key={getSessionKey(session)}
|
||||
session={session}
|
||||
isSelected={isSelected}
|
||||
selectionMode={selectionMode}
|
||||
searchQuery={search}
|
||||
isChecked={selectedSessionKeys.has(
|
||||
getSessionKey(session),
|
||||
)}
|
||||
isCheckDisabled={!session.sourcePath}
|
||||
onSelect={setSelectedKey}
|
||||
onToggleChecked={(checked) =>
|
||||
toggleSessionChecked(session, checked)
|
||||
<Collapsible
|
||||
key={providerGroup.providerId}
|
||||
open={providerOpen}
|
||||
onOpenChange={() =>
|
||||
toggleProviderGroup(providerGroup.providerId)
|
||||
}
|
||||
/>
|
||||
>
|
||||
<div className="flex w-full items-center gap-2 rounded-md border bg-muted/40 px-2.5 py-2 transition-colors hover:bg-muted">
|
||||
{renderProviderGroupCheckbox(
|
||||
providerGroup,
|
||||
providerLabel,
|
||||
providerSelectionState,
|
||||
)}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
aria-label={t(
|
||||
"sessionManager.toggleProviderGroup",
|
||||
{
|
||||
defaultValue:
|
||||
"展开或折叠 {{provider}} 供应商分组",
|
||||
provider: providerLabel,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{providerOpen ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(
|
||||
providerGroup.providerId,
|
||||
)}
|
||||
name={providerGroup.providerId}
|
||||
size={16}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{providerLabel}
|
||||
</span>
|
||||
{renderGroupSelectionBadge(
|
||||
providerSelectionState,
|
||||
providerGroup.sessions.length,
|
||||
"secondary",
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className="mt-1 space-y-1 pl-2">
|
||||
{providerGroup.directories.map(
|
||||
(directoryGroup) => {
|
||||
const directoryOpen =
|
||||
expandedDirectoryGroups.has(
|
||||
directoryGroup.key,
|
||||
);
|
||||
const directorySelectionState =
|
||||
getGroupSelectionState(
|
||||
directoryGroup.sessions,
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={directoryGroup.key}
|
||||
open={directoryOpen}
|
||||
onOpenChange={() =>
|
||||
toggleDirectoryGroup(
|
||||
directoryGroup.key,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
{renderDirectoryGroupCheckbox(
|
||||
directoryGroup,
|
||||
directorySelectionState,
|
||||
)}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
aria-label={t(
|
||||
"sessionManager.toggleDirectoryGroup",
|
||||
{
|
||||
defaultValue:
|
||||
"展开或折叠 {{directory}} 目录分组",
|
||||
directory:
|
||||
directoryGroup.label,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{directoryOpen ? (
|
||||
<ChevronDown className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<FolderOpen className="size-3.5 shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{directoryGroup.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="max-w-xs"
|
||||
>
|
||||
<p className="font-mono text-xs break-all">
|
||||
{directoryGroup.projectDir ??
|
||||
t(
|
||||
"sessionManager.unknownDirectory",
|
||||
{
|
||||
defaultValue:
|
||||
"未知目录",
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{renderGroupSelectionBadge(
|
||||
directorySelectionState,
|
||||
directoryGroup.sessions.length,
|
||||
"outline",
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className="mt-1 space-y-1 pl-3">
|
||||
{directoryGroup.sessions.map(
|
||||
(session) =>
|
||||
renderSessionItem(session),
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredSessions.map((session) =>
|
||||
renderSessionItem(session),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -4,6 +4,20 @@ import { SessionMeta } from "@/types";
|
||||
|
||||
const CODEX_IDE_CONTEXT_PREFIX = "# Context from my IDE setup:";
|
||||
const CODEX_REQUEST_MARKER = "my request for codex";
|
||||
export const UNKNOWN_PROJECT_DIR_KEY = "__unknown_project_dir__";
|
||||
|
||||
export interface SessionDirectoryGroup {
|
||||
key: string;
|
||||
projectDir: string | null;
|
||||
label: string;
|
||||
sessions: SessionMeta[];
|
||||
}
|
||||
|
||||
export interface SessionProviderGroup {
|
||||
providerId: string;
|
||||
sessions: SessionMeta[];
|
||||
directories: SessionDirectoryGroup[];
|
||||
}
|
||||
|
||||
const getCodexRequestHeadingPayload = (lineText: string) => {
|
||||
if (!lineText.startsWith("#")) return null;
|
||||
@@ -55,6 +69,14 @@ const extractCodexPromptFromIdeContext = (content: string) => {
|
||||
export const getSessionKey = (session: SessionMeta) =>
|
||||
`${session.providerId}:${session.sessionId}:${session.sourcePath ?? ""}`;
|
||||
|
||||
export const getSessionDirectoryGroupKey = (
|
||||
providerId: string,
|
||||
projectDir?: string | null,
|
||||
) => {
|
||||
const trimmed = projectDir?.trim();
|
||||
return `${providerId}:${trimmed || UNKNOWN_PROJECT_DIR_KEY}`;
|
||||
};
|
||||
|
||||
export const getBaseName = (value?: string | null) => {
|
||||
if (!value) return "";
|
||||
const trimmed = value.trim();
|
||||
@@ -131,6 +153,59 @@ export const formatSessionTitle = (session: SessionMeta) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const groupSessionsByProviderAndDirectory = (
|
||||
sessions: SessionMeta[],
|
||||
unknownDirectoryLabel: string,
|
||||
): SessionProviderGroup[] => {
|
||||
const providerGroups: SessionProviderGroup[] = [];
|
||||
const providerGroupMap = new Map<string, SessionProviderGroup>();
|
||||
const directoryGroupMaps = new Map<
|
||||
string,
|
||||
Map<string, SessionDirectoryGroup>
|
||||
>();
|
||||
|
||||
sessions.forEach((session) => {
|
||||
let providerGroup = providerGroupMap.get(session.providerId);
|
||||
if (!providerGroup) {
|
||||
providerGroup = {
|
||||
providerId: session.providerId,
|
||||
sessions: [],
|
||||
directories: [],
|
||||
};
|
||||
providerGroupMap.set(session.providerId, providerGroup);
|
||||
providerGroups.push(providerGroup);
|
||||
directoryGroupMaps.set(session.providerId, new Map());
|
||||
}
|
||||
|
||||
providerGroup.sessions.push(session);
|
||||
|
||||
const trimmedProjectDir = session.projectDir?.trim() || null;
|
||||
const directoryKey = getSessionDirectoryGroupKey(
|
||||
session.providerId,
|
||||
trimmedProjectDir,
|
||||
);
|
||||
const directoryGroups = directoryGroupMaps.get(session.providerId)!;
|
||||
|
||||
let directoryGroup = directoryGroups.get(directoryKey);
|
||||
if (!directoryGroup) {
|
||||
directoryGroup = {
|
||||
key: directoryKey,
|
||||
projectDir: trimmedProjectDir,
|
||||
label: trimmedProjectDir
|
||||
? getBaseName(trimmedProjectDir) || trimmedProjectDir
|
||||
: unknownDirectoryLabel,
|
||||
sessions: [],
|
||||
};
|
||||
directoryGroups.set(directoryKey, directoryGroup);
|
||||
providerGroup.directories.push(directoryGroup);
|
||||
}
|
||||
|
||||
directoryGroup.sessions.push(session);
|
||||
});
|
||||
|
||||
return providerGroups;
|
||||
};
|
||||
|
||||
export const shouldHideCodexMessageFromToc = (content: string) => {
|
||||
const trimmed = content.trim();
|
||||
return (
|
||||
|
||||
@@ -35,7 +35,6 @@ import { useUpdate } from "@/contexts/UpdateContext";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { motion } from "framer-motion";
|
||||
import appIcon from "@/assets/icons/app-icon.png";
|
||||
import fable5VerifiedBanner from "@/assets/fable5-verified.png";
|
||||
import { APP_ICON_MAP } from "@/config/appConfig";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
@@ -854,12 +853,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
src={fable5VerifiedBanner}
|
||||
alt="Fable 5 Verified"
|
||||
className="h-16 w-auto shrink-0 select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -90,8 +90,9 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const toggleAppMutation = useToggleSkillApp();
|
||||
const uninstallMutation = useUninstallSkill();
|
||||
const restoreBackupMutation = useRestoreSkillBackup();
|
||||
// enabled: true —— 进入 Skill 页面时自动静默扫描一次(绿点提示来源)
|
||||
const { data: unmanagedSkills, refetch: scanUnmanaged } =
|
||||
useScanUnmanagedSkills();
|
||||
useScanUnmanagedSkills({ enabled: true });
|
||||
const importMutation = useImportSkillsFromApps();
|
||||
const installFromZipMutation = useInstallSkillsFromZip();
|
||||
const {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { Check, Minus } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -12,6 +12,7 @@ const Checkbox = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -19,7 +20,11 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("grid place-content-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{props.checked === "indeterminate" ? (
|
||||
<Minus className="h-4 w-4" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
|
||||
@@ -37,7 +37,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"relative z-[100] max-h-[min(24rem,var(--radix-select-content-available-height))] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
|
||||
@@ -331,7 +331,7 @@ export function UsageDateRangePicker({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[340px] max-w-[calc(100vw-2rem)] p-3 sm:w-[620px]"
|
||||
className="usage-range-popover w-[620px] max-w-[calc(100vw-2rem)] p-3"
|
||||
align="end"
|
||||
>
|
||||
{/* Preset shortcuts */}
|
||||
@@ -353,9 +353,9 @@ export function UsageDateRangePicker({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="usage-range-layout flex flex-col gap-3">
|
||||
{/* Left: date fields */}
|
||||
<div className="space-y-2 sm:w-[250px] sm:flex-none">
|
||||
<div className="usage-range-fields space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usage.customRangeHint", "支持日期与时间,最长 30 天")}
|
||||
</p>
|
||||
@@ -403,7 +403,7 @@ export function UsageDateRangePicker({
|
||||
</div>
|
||||
|
||||
{/* Right: calendar */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-2.5 sm:min-w-0 sm:flex-1">
|
||||
<div className="usage-range-calendar rounded-lg border border-border/50 bg-muted/30 p-2.5">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Button
|
||||
|
||||
@@ -184,10 +184,65 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
partnerPromotionKey: "patewayai",
|
||||
icon: "pateway",
|
||||
},
|
||||
{
|
||||
name: "Qiniu",
|
||||
nameKey: "providerForm.presets.qiniu",
|
||||
websiteUrl: "https://s.qiniu.com/nMvAvy",
|
||||
apiKeyUrl: "https://s.qiniu.com/nMvAvy",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.qnaigc.com",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://api.qnaigc.com", "https://api.modelink.ai"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "qiniu",
|
||||
icon: "qiniu",
|
||||
},
|
||||
{
|
||||
name: "FennoAI",
|
||||
websiteUrl: "https://api.fenno.ai",
|
||||
apiKeyUrl:
|
||||
"https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.fenno.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "fenno",
|
||||
icon: "fenno",
|
||||
},
|
||||
{
|
||||
name: "ZetaAPI",
|
||||
websiteUrl: "https://zetaapi.ai",
|
||||
apiKeyUrl: "https://zetaapi.ai/go/ccs",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.zetaapi.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zetaapi",
|
||||
icon: "zetaapi",
|
||||
},
|
||||
{
|
||||
name: "TeamoRouter",
|
||||
websiteUrl: "https://teamorouter.com",
|
||||
apiKeyUrl:
|
||||
"https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.teamorouter.com",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "teamorouter",
|
||||
icon: "teamorouter",
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/ark",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
category: "cn_official",
|
||||
@@ -206,8 +261,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.byteplus.com/en/product/modelark",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
category: "cn_official",
|
||||
@@ -226,8 +280,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
category: "cn_official",
|
||||
@@ -235,9 +288,9 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: brandedRoutes(
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"doubao-seed-2-1-pro-260628",
|
||||
"doubao-seed-2-1-pro-260628",
|
||||
"doubao-seed-2-1-pro-260628",
|
||||
),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "doubaoseed",
|
||||
@@ -257,6 +310,19 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
apiKeyUrl: "https://subrouter.ai/register?aff=l3ri",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://subrouter.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(true),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "subrouter",
|
||||
icon: "subrouter",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
@@ -335,7 +401,9 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "OpenCode Go",
|
||||
websiteUrl: "https://opencode.ai",
|
||||
websiteUrl: "https://opencode.ai/go",
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=2YTRG2NGTX",
|
||||
partnerPromotionKey: "opencode_go",
|
||||
category: "third_party",
|
||||
baseUrl: "https://opencode.ai/zen/go",
|
||||
mode: "proxy",
|
||||
@@ -416,7 +484,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
websiteUrl: "https://platform.kimi.com?aff=cc-switch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://api.moonshot.cn/anthropic",
|
||||
mode: "proxy",
|
||||
@@ -432,7 +500,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
websiteUrl: "https://www.kimi.com/code/?aff=cc-switch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
mode: "proxy",
|
||||
@@ -582,7 +650,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
{
|
||||
name: "SiliconFlow",
|
||||
websiteUrl: "https://siliconflow.cn",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.siliconflow.cn",
|
||||
mode: "proxy",
|
||||
@@ -600,7 +668,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
{
|
||||
name: "SiliconFlow en",
|
||||
websiteUrl: "https://siliconflow.com",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.siliconflow.com",
|
||||
mode: "proxy",
|
||||
|
||||
@@ -128,8 +128,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/ark",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -150,8 +149,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.byteplus.com/en/product/modelark",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -173,8 +171,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -182,10 +179,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/compatible",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
ANTHROPIC_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_MODEL: "doubao-seed-2-1-pro-260628",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-2-1-pro-260628",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-2-1-pro-260628",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-2-1-pro-260628",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -209,6 +206,21 @@ export const providerPresets: ProviderPreset[] = [
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
apiKeyUrl: "https://subrouter.ai/register?aff=l3ri",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://subrouter.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "subrouter",
|
||||
icon: "subrouter",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
@@ -224,6 +236,70 @@ export const providerPresets: ProviderPreset[] = [
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "Qiniu",
|
||||
nameKey: "providerForm.presets.qiniu",
|
||||
websiteUrl: "https://s.qiniu.com/nMvAvy",
|
||||
apiKeyUrl: "https://s.qiniu.com/nMvAvy",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.qnaigc.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: ["https://api.qnaigc.com", "https://api.modelink.ai"],
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "qiniu",
|
||||
icon: "qiniu",
|
||||
},
|
||||
{
|
||||
name: "FennoAI",
|
||||
websiteUrl: "https://api.fenno.ai",
|
||||
apiKeyUrl:
|
||||
"https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.fenno.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "fenno",
|
||||
icon: "fenno",
|
||||
},
|
||||
{
|
||||
name: "ZetaAPI",
|
||||
websiteUrl: "https://zetaapi.ai",
|
||||
apiKeyUrl: "https://zetaapi.ai/go/ccs",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.zetaapi.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zetaapi",
|
||||
icon: "zetaapi",
|
||||
},
|
||||
{
|
||||
name: "TeamoRouter",
|
||||
websiteUrl: "https://teamorouter.com",
|
||||
apiKeyUrl:
|
||||
"https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.teamorouter.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "teamorouter",
|
||||
icon: "teamorouter",
|
||||
},
|
||||
{
|
||||
name: "Gemini Native",
|
||||
websiteUrl: "https://ai.google.dev/gemini-api",
|
||||
@@ -266,7 +342,9 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "OpenCode Go",
|
||||
websiteUrl: "https://opencode.ai",
|
||||
websiteUrl: "https://opencode.ai/go",
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=2YTRG2NGTX",
|
||||
partnerPromotionKey: "opencode_go",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://opencode.ai/zen/go",
|
||||
@@ -369,7 +447,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
websiteUrl: "https://platform.kimi.com?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
|
||||
@@ -387,7 +465,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
websiteUrl: "https://www.kimi.com/code/?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.kimi.com/coding/",
|
||||
@@ -612,7 +690,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "SiliconFlow",
|
||||
websiteUrl: "https://siliconflow.cn",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.siliconflow.cn",
|
||||
@@ -632,7 +710,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "SiliconFlow en",
|
||||
websiteUrl: "https://siliconflow.com",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.siliconflow.com",
|
||||
|
||||
@@ -73,7 +73,20 @@ requires_openai_auth = true`;
|
||||
|
||||
function modelCatalog(
|
||||
models: Array<
|
||||
string | { model: string; displayName?: string; contextWindow?: number }
|
||||
| string
|
||||
| {
|
||||
model: string;
|
||||
displayName?: string;
|
||||
contextWindow?: number;
|
||||
// Native Responses (direct) overrides for the generated
|
||||
// model-catalogs.json; omit to inherit the native template defaults
|
||||
// (supports_parallel_tool_calls=false, input_modalities=["text"]).
|
||||
supportsParallelToolCalls?: boolean;
|
||||
inputModalities?: string[];
|
||||
// Vendor's OFFICIAL base_instructions; omit to inherit the neutral
|
||||
// template default. Required by Codex, so the backend always emits one.
|
||||
baseInstructions?: string;
|
||||
}
|
||||
>,
|
||||
): CodexCatalogModel[] {
|
||||
return models.map((entry) =>
|
||||
@@ -83,6 +96,9 @@ function modelCatalog(
|
||||
model: entry.model,
|
||||
displayName: entry.displayName,
|
||||
contextWindow: entry.contextWindow,
|
||||
supportsParallelToolCalls: entry.supportsParallelToolCalls,
|
||||
inputModalities: entry.inputModalities,
|
||||
baseInstructions: entry.baseInstructions,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -136,8 +152,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/ark",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
@@ -163,8 +178,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.byteplus.com/en/product/modelark",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
@@ -192,23 +206,25 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"doubaoseed",
|
||||
"https://ark.cn-beijing.volces.com/api/v3",
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"doubao-seed-2-1-pro-260628",
|
||||
),
|
||||
endpointCandidates: ["https://ark.cn-beijing.volces.com/api/v3"],
|
||||
apiFormat: "openai_chat",
|
||||
// 火山方舟主数据面 /api/v3 原生支持 Responses API(/api/v3/responses),无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch),
|
||||
// 让 Codex 直连显示模型并避免 custom 工具被网关拒绝
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "doubao-seed-2-0-code-preview-latest",
|
||||
displayName: "Doubao Seed Code Preview",
|
||||
contextWindow: 256000,
|
||||
model: "doubao-seed-2-1-pro-260628",
|
||||
displayName: "Doubao Seed 2.1 Pro",
|
||||
contextWindow: 262144,
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
@@ -233,6 +249,22 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
apiKeyUrl: "https://subrouter.ai/register?aff=l3ri",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"subrouter",
|
||||
"https://subrouter.ai/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://subrouter.ai/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "subrouter",
|
||||
icon: "subrouter",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
@@ -249,6 +281,76 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "Qiniu",
|
||||
nameKey: "providerForm.presets.qiniu",
|
||||
websiteUrl: "https://s.qiniu.com/nMvAvy",
|
||||
apiKeyUrl: "https://s.qiniu.com/nMvAvy",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"qiniu",
|
||||
"https://api.qnaigc.com/bypass/openai/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://api.qnaigc.com/bypass/openai/v1",
|
||||
"https://api.modelink.ai/bypass/openai/v1",
|
||||
],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "qiniu",
|
||||
icon: "qiniu",
|
||||
},
|
||||
{
|
||||
name: "FennoAI",
|
||||
websiteUrl: "https://api.fenno.ai",
|
||||
apiKeyUrl:
|
||||
"https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"fenno",
|
||||
"https://api.fenno.ai",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://api.fenno.ai"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "fenno",
|
||||
icon: "fenno",
|
||||
},
|
||||
{
|
||||
name: "ZetaAPI",
|
||||
websiteUrl: "https://zetaapi.ai",
|
||||
apiKeyUrl: "https://zetaapi.ai/go/ccs",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"zetaapi",
|
||||
"https://api.zetaapi.ai/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://api.zetaapi.ai/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zetaapi",
|
||||
icon: "zetaapi",
|
||||
},
|
||||
{
|
||||
name: "TeamoRouter",
|
||||
websiteUrl: "https://teamorouter.com",
|
||||
apiKeyUrl:
|
||||
"https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"teamorouter",
|
||||
"https://api.teamorouter.com/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://api.teamorouter.com/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "teamorouter",
|
||||
icon: "teamorouter",
|
||||
},
|
||||
{
|
||||
name: "Azure OpenAI",
|
||||
websiteUrl:
|
||||
@@ -321,12 +423,12 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"zhipu_glm",
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
"glm-5.1",
|
||||
"glm-5.2",
|
||||
),
|
||||
endpointCandidates: ["https://open.bigmodel.cn/api/coding/paas/v4"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "glm-5.1", displayName: "GLM-5.1", contextWindow: 200000 },
|
||||
{ model: "glm-5.2", displayName: "GLM-5.2", contextWindow: 200000 },
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
@@ -347,12 +449,12 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"zhipu_glm_en",
|
||||
"https://api.z.ai/api/coding/paas/v4",
|
||||
"glm-5.1",
|
||||
"glm-5.2",
|
||||
),
|
||||
endpointCandidates: ["https://api.z.ai/api/coding/paas/v4"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "glm-5.1", displayName: "GLM-5.1", contextWindow: 200000 },
|
||||
{ model: "glm-5.2", displayName: "GLM-5.2", contextWindow: 200000 },
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
@@ -400,22 +502,16 @@ requires_openai_auth = true`,
|
||||
"qwen3-coder-plus",
|
||||
),
|
||||
endpointCandidates: ["https://dashscope.aliyuncs.com/compatible-mode/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
// 阿里百炼 DashScope 原生支持 OpenAI Responses API(/compatible-mode/v1/responses,同一 base_url),无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch)
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "qwen3-coder-plus",
|
||||
displayName: "Qwen3 Coder Plus",
|
||||
contextWindow: 1000000,
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
{ model: "qwen3-max", displayName: "Qwen3 Max", contextWindow: 262144 },
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "enable_thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "bailian",
|
||||
iconColor: "#624AFF",
|
||||
@@ -423,8 +519,8 @@ requires_openai_auth = true`,
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
websiteUrl: "https://platform.kimi.com?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.kimi.com/console/api-keys?aff=cc-switch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"kimi",
|
||||
@@ -454,8 +550,8 @@ requires_openai_auth = true`,
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/",
|
||||
apiKeyUrl: "https://www.kimi.com/code/",
|
||||
websiteUrl: "https://www.kimi.com/code/?aff=cc-switch",
|
||||
apiKeyUrl: "https://www.kimi.com/code/?aff=cc-switch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"kimi_coding",
|
||||
@@ -490,11 +586,16 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"stepfun",
|
||||
"https://api.stepfun.com/step_plan/v1",
|
||||
"step-3.5-flash-2603",
|
||||
"step-3.7-flash",
|
||||
),
|
||||
endpointCandidates: ["https://api.stepfun.com/step_plan/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "step-3.7-flash",
|
||||
displayName: "Step 3.7 Flash",
|
||||
contextWindow: 262144,
|
||||
},
|
||||
{
|
||||
model: "step-3.5-flash-2603",
|
||||
displayName: "Step 3.5 Flash 2603",
|
||||
@@ -518,11 +619,16 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"stepfun_en",
|
||||
"https://api.stepfun.ai/step_plan/v1",
|
||||
"step-3.5-flash-2603",
|
||||
"step-3.7-flash",
|
||||
),
|
||||
endpointCandidates: ["https://api.stepfun.ai/step_plan/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "step-3.7-flash",
|
||||
displayName: "Step 3.7 Flash",
|
||||
contextWindow: 262144,
|
||||
},
|
||||
{
|
||||
model: "step-3.5-flash-2603",
|
||||
displayName: "Step 3.5 Flash 2603",
|
||||
@@ -576,15 +682,18 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"longcat",
|
||||
"https://api.longcat.chat/openai/v1",
|
||||
"LongCat-Flash-Chat",
|
||||
"LongCat-2.0-Preview",
|
||||
),
|
||||
endpointCandidates: ["https://api.longcat.chat/openai/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
// 美团 LongCat 官方 Codex 文档用 wire_api=responses 对自家 base_url,原生 Responses,无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch)。
|
||||
// 注:LongCat 的 /responses 工具类型契约文档化程度最低,建议真机冒烟一次
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "LongCat-Flash-Chat",
|
||||
displayName: "LongCat Flash Chat",
|
||||
contextWindow: 262144,
|
||||
model: "LongCat-2.0-Preview",
|
||||
displayName: "LongCat 2.0 Preview",
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
@@ -599,24 +708,24 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"minimax",
|
||||
"https://api.minimaxi.com/v1",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M3",
|
||||
),
|
||||
endpointCandidates: ["https://api.minimaxi.com/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
// MiniMax 官方 API 参考已列 /v1/responses 为正式端点(CN/intl 双区,POST /v1/responses),原生 Responses,无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 官方 Codex catalog(platform.minimaxi.com/docs/token-plan/codex-cli):
|
||||
// shell_command 编辑、并行工具、文本+图像,不声明 freeform apply_patch
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "MiniMax-M2.7",
|
||||
displayName: "MiniMax M2.7",
|
||||
contextWindow: 200000,
|
||||
model: "MiniMax-M3",
|
||||
displayName: "MiniMax-M3",
|
||||
contextWindow: 1000000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
"You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.",
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "reasoning_split",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_details",
|
||||
},
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: {
|
||||
@@ -634,24 +743,24 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"minimax_en",
|
||||
"https://api.minimax.io/v1",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M3",
|
||||
),
|
||||
endpointCandidates: ["https://api.minimax.io/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
// MiniMax 官方 API 参考已列 /v1/responses 为正式端点(CN/intl 双区,POST /v1/responses),原生 Responses,无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 官方 Codex catalog(platform.minimax.io/docs/token-plan/codex):
|
||||
// shell_command 编辑、并行工具、文本+图像,不声明 freeform apply_patch
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "MiniMax-M2.7",
|
||||
displayName: "MiniMax M2.7",
|
||||
contextWindow: 200000,
|
||||
model: "MiniMax-M3",
|
||||
displayName: "MiniMax-M3",
|
||||
contextWindow: 1000000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
"You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.",
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "reasoning_split",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_details",
|
||||
},
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: {
|
||||
@@ -669,15 +778,15 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"bailing",
|
||||
"https://api.tbox.cn/api/llm/v1",
|
||||
"Ling-2.5-1T",
|
||||
"Ling-2.6-1T",
|
||||
),
|
||||
endpointCandidates: ["https://api.tbox.cn/api/llm/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "Ling-2.5-1T",
|
||||
displayName: "Ling-2.5-1T",
|
||||
contextWindow: 131072,
|
||||
model: "Ling-2.6-1T",
|
||||
displayName: "Ling-2.6-1T",
|
||||
contextWindow: 262144,
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
@@ -693,21 +802,28 @@ requires_openai_auth = true`,
|
||||
"mimo-v2.5-pro",
|
||||
),
|
||||
endpointCandidates: ["https://api.xiaomimimo.com/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
// 小米 MiMo 官方 Codex 文档已声明原生支持 Responses API(wire_api=responses 对自家 base_url),无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 官方 Codex catalog(mimo.mi.com/.../codex-configuration):
|
||||
// shell_command 编辑、不声明 freeform apply_patch
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "mimo-v2.5-pro",
|
||||
displayName: "MiMo V2.5 Pro",
|
||||
contextWindow: 1048576,
|
||||
inputModalities: ["text"],
|
||||
baseInstructions:
|
||||
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
|
||||
},
|
||||
{
|
||||
model: "mimo-v2.5",
|
||||
displayName: "MiMo V2.5",
|
||||
contextWindow: 1048576,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "xiaomimimo",
|
||||
iconColor: "#000000",
|
||||
@@ -723,21 +839,28 @@ requires_openai_auth = true`,
|
||||
"mimo-v2.5-pro",
|
||||
),
|
||||
endpointCandidates: ["https://token-plan-cn.xiaomimimo.com/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
// 小米 MiMo 官方 Codex 文档已声明原生支持 Responses API(wire_api=responses 对自家 base_url),无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 官方 Codex catalog(mimo.mi.com/.../codex-configuration):
|
||||
// shell_command 编辑、不声明 freeform apply_patch
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "mimo-v2.5-pro",
|
||||
displayName: "MiMo V2.5 Pro",
|
||||
contextWindow: 1048576,
|
||||
inputModalities: ["text"],
|
||||
baseInstructions:
|
||||
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
|
||||
},
|
||||
{
|
||||
model: "mimo-v2.5",
|
||||
displayName: "MiMo V2.5",
|
||||
contextWindow: 1048576,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "xiaomimimo",
|
||||
iconColor: "#000000",
|
||||
@@ -745,7 +868,7 @@ requires_openai_auth = true`,
|
||||
{
|
||||
name: "SiliconFlow",
|
||||
websiteUrl: "https://siliconflow.cn",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"siliconflow",
|
||||
@@ -770,7 +893,7 @@ requires_openai_auth = true`,
|
||||
{
|
||||
name: "SiliconFlow en",
|
||||
websiteUrl: "https://siliconflow.com",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"siliconflow_en",
|
||||
@@ -852,6 +975,39 @@ requires_openai_auth = true`,
|
||||
icon: "nvidia",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "OpenCode Go",
|
||||
websiteUrl: "https://opencode.ai/go",
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=2YTRG2NGTX",
|
||||
partnerPromotionKey: "opencode_go",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"opencode_go",
|
||||
"https://opencode.ai/zen/go/v1",
|
||||
"glm-5.2",
|
||||
),
|
||||
endpointCandidates: ["https://opencode.ai/zen/go/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "glm-5.2", displayName: "GLM 5.2", contextWindow: 204800 },
|
||||
{ model: "glm-5.1", displayName: "GLM 5.1", contextWindow: 204800 },
|
||||
{
|
||||
model: "kimi-k2.7-code",
|
||||
displayName: "Kimi K2.7 Code",
|
||||
contextWindow: 262144,
|
||||
},
|
||||
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
|
||||
{ model: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash" },
|
||||
{
|
||||
model: "mimo-v2.5-pro",
|
||||
displayName: "MiMo V2.5 Pro",
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
]),
|
||||
category: "third_party",
|
||||
icon: "opencode",
|
||||
iconColor: "#211E1E",
|
||||
},
|
||||
{
|
||||
name: "AiHubMix",
|
||||
websiteUrl: "https://aihubmix.com",
|
||||
|
||||
@@ -88,6 +88,48 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
apiKeyUrl: "https://subrouter.ai/register?aff=l3ri",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://subrouter.ai/v1beta",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://subrouter.ai/v1beta",
|
||||
model: "gemini-3.5-flash",
|
||||
description: "SubRouter",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "subrouter",
|
||||
endpointCandidates: ["https://subrouter.ai/v1beta"],
|
||||
icon: "subrouter",
|
||||
},
|
||||
{
|
||||
name: "Qiniu",
|
||||
nameKey: "providerForm.presets.qiniu",
|
||||
websiteUrl: "https://s.qiniu.com/nMvAvy",
|
||||
apiKeyUrl: "https://s.qiniu.com/nMvAvy",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.qnaigc.com/bypass/vertex",
|
||||
GEMINI_MODEL: "gemini-3.1-pro-preview",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.qnaigc.com/bypass/vertex",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
description: "Qiniu",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "qiniu",
|
||||
endpointCandidates: [
|
||||
"https://api.qnaigc.com/bypass/vertex",
|
||||
"https://api.modelink.ai/bypass/vertex",
|
||||
],
|
||||
icon: "qiniu",
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://www.packyapi.com",
|
||||
|
||||
@@ -149,10 +149,88 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
model: { default: "openai/gpt-5.5", provider: "shengsuanyun" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Qiniu",
|
||||
nameKey: "providerForm.presets.qiniu",
|
||||
websiteUrl: "https://s.qiniu.com/nMvAvy",
|
||||
apiKeyUrl: "https://s.qiniu.com/nMvAvy",
|
||||
settingsConfig: {
|
||||
name: "qiniu",
|
||||
base_url: "https://api.qnaigc.com/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "qiniu",
|
||||
icon: "qiniu",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "qiniu" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FennoAI",
|
||||
websiteUrl: "https://api.fenno.ai",
|
||||
apiKeyUrl:
|
||||
"https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL",
|
||||
settingsConfig: {
|
||||
name: "fenno",
|
||||
base_url: "https://api.fenno.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "fenno",
|
||||
icon: "fenno",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "fenno" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ZetaAPI",
|
||||
websiteUrl: "https://zetaapi.ai",
|
||||
apiKeyUrl: "https://zetaapi.ai/go/ccs",
|
||||
settingsConfig: {
|
||||
name: "zetaapi",
|
||||
base_url: "https://api.zetaapi.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zetaapi",
|
||||
icon: "zetaapi",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "zetaapi" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TeamoRouter",
|
||||
websiteUrl: "https://teamorouter.com",
|
||||
apiKeyUrl:
|
||||
"https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory",
|
||||
settingsConfig: {
|
||||
name: "teamorouter",
|
||||
base_url: "https://api.teamorouter.com/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "teamorouter",
|
||||
icon: "teamorouter",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "teamorouter" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/ark",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -181,8 +259,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.byteplus.com/en/product/modelark",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -211,8 +288,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -222,8 +298,8 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{
|
||||
id: "doubao-seed-2-0-code-preview-latest",
|
||||
name: "Doubao Seed 2.0 Code Preview",
|
||||
id: "doubao-seed-2-1-pro-260628",
|
||||
name: "Doubao Seed 2.1 Pro",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -234,7 +310,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
iconColor: "#3370FF",
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
default: "doubao-seed-2-0-code-preview-latest",
|
||||
default: "doubao-seed-2-1-pro-260628",
|
||||
provider: "doubao_seed",
|
||||
},
|
||||
},
|
||||
@@ -264,6 +340,31 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
model: { default: "gpt-5.5", provider: "ccsub" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
apiKeyUrl: "https://subrouter.ai/register?aff=l3ri",
|
||||
settingsConfig: {
|
||||
name: "subrouter",
|
||||
base_url: "https://subrouter.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
context_length: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "subrouter",
|
||||
icon: "subrouter",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "subrouter" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
@@ -517,7 +618,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
websiteUrl: "https://platform.kimi.com?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
name: "kimi",
|
||||
base_url: "https://api.moonshot.cn/v1",
|
||||
@@ -535,7 +636,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
websiteUrl: "https://www.kimi.com/code/?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
name: "kimi_coding",
|
||||
base_url: "https://api.kimi.com/coding/",
|
||||
@@ -728,7 +829,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
{
|
||||
name: "SiliconFlow",
|
||||
websiteUrl: "https://siliconflow.cn",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
settingsConfig: {
|
||||
name: "siliconflow",
|
||||
base_url: "https://api.siliconflow.cn/v1",
|
||||
@@ -756,7 +857,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
{
|
||||
name: "SiliconFlow en",
|
||||
websiteUrl: "https://siliconflow.com",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
settingsConfig: {
|
||||
name: "siliconflow_en",
|
||||
base_url: "https://api.siliconflow.com/v1",
|
||||
|
||||
@@ -147,8 +147,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/ark",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -184,8 +183,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.byteplus.com/en/product/modelark",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -221,8 +219,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -231,10 +228,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "doubao-seed-2-0-code-preview-latest",
|
||||
name: "DouBao Seed Code Preview",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.002, output: 0.006 },
|
||||
id: "doubao-seed-2-1-pro-260628",
|
||||
name: "DouBao Seed 2.1 Pro",
|
||||
contextWindow: 262144,
|
||||
cost: { input: 0.84, output: 4.2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -251,9 +248,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "doubaoseed/doubao-seed-2-0-code-preview-latest" },
|
||||
model: { primary: "doubaoseed/doubao-seed-2-1-pro-260628" },
|
||||
modelCatalog: {
|
||||
"doubaoseed/doubao-seed-2-0-code-preview-latest": { alias: "DouBao" },
|
||||
"doubaoseed/doubao-seed-2-1-pro-260628": { alias: "DouBao" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -294,6 +291,189 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
apiKeyUrl: "https://subrouter.ai/register?aff=l3ri",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://subrouter.ai/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "subrouter",
|
||||
icon: "subrouter",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "subrouter/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"subrouter/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Qiniu",
|
||||
nameKey: "providerForm.presets.qiniu",
|
||||
websiteUrl: "https://s.qiniu.com/nMvAvy",
|
||||
apiKeyUrl: "https://s.qiniu.com/nMvAvy",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.qnaigc.com/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "qiniu",
|
||||
icon: "qiniu",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "qiniu/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"qiniu/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FennoAI",
|
||||
websiteUrl: "https://api.fenno.ai",
|
||||
apiKeyUrl:
|
||||
"https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.fenno.ai/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "fenno",
|
||||
icon: "fenno",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "fenno/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"fenno/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ZetaAPI",
|
||||
websiteUrl: "https://zetaapi.ai",
|
||||
apiKeyUrl: "https://zetaapi.ai/go/ccs",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.zetaapi.ai/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zetaapi",
|
||||
icon: "zetaapi",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "zetaapi/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"zetaapi/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TeamoRouter",
|
||||
websiteUrl: "https://teamorouter.com",
|
||||
apiKeyUrl:
|
||||
"https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.teamorouter.com/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "teamorouter",
|
||||
icon: "teamorouter",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "teamorouter/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"teamorouter/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
@@ -493,8 +673,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
websiteUrl: "https://platform.kimi.com?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.kimi.com/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
apiKey: "",
|
||||
@@ -532,8 +712,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
websiteUrl: "https://www.kimi.com/code/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.kimi.com/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.kimi.com/v1",
|
||||
apiKey: "",
|
||||
@@ -1307,7 +1487,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
name: "SiliconFlow",
|
||||
websiteUrl: "https://siliconflow.cn",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.siliconflow.cn/v1",
|
||||
apiKey: "",
|
||||
@@ -1343,7 +1523,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
name: "SiliconFlow en",
|
||||
websiteUrl: "https://siliconflow.com",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/drGuwc9k",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/i/YflgU2Ve",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.siliconflow.com/v1",
|
||||
apiKey: "",
|
||||
|
||||
@@ -309,10 +309,124 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Qiniu",
|
||||
nameKey: "providerForm.presets.qiniu",
|
||||
websiteUrl: "https://s.qiniu.com/nMvAvy",
|
||||
apiKeyUrl: "https://s.qiniu.com/nMvAvy",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Qiniu",
|
||||
options: {
|
||||
baseURL: "https://api.qnaigc.com/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "qiniu",
|
||||
icon: "qiniu",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FennoAI",
|
||||
websiteUrl: "https://api.fenno.ai",
|
||||
apiKeyUrl:
|
||||
"https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "FennoAI",
|
||||
options: {
|
||||
baseURL: "https://api.fenno.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "fenno",
|
||||
icon: "fenno",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ZetaAPI",
|
||||
websiteUrl: "https://zetaapi.ai",
|
||||
apiKeyUrl: "https://zetaapi.ai/go/ccs",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "ZetaAPI",
|
||||
options: {
|
||||
baseURL: "https://api.zetaapi.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zetaapi",
|
||||
icon: "zetaapi",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TeamoRouter",
|
||||
websiteUrl: "https://teamorouter.com",
|
||||
apiKeyUrl:
|
||||
"https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "TeamoRouter",
|
||||
options: {
|
||||
baseURL: "https://api.teamorouter.com/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "teamorouter",
|
||||
icon: "teamorouter",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/ark",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -344,8 +458,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.byteplus.com/en/product/modelark",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -377,8 +490,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
@@ -390,8 +502,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"doubao-seed-2-0-code-preview-latest": {
|
||||
name: "Doubao Seed Code Preview",
|
||||
"doubao-seed-2-1-pro-260628": {
|
||||
name: "Doubao Seed 2.1 Pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -436,6 +548,34 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
apiKeyUrl: "https://subrouter.ai/register?aff=l3ri",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "SubRouter",
|
||||
options: {
|
||||
baseURL: "https://subrouter.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "subrouter",
|
||||
icon: "subrouter",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
@@ -591,8 +731,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
websiteUrl: "https://platform.kimi.com?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.kimi.com/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Kimi",
|
||||
@@ -625,8 +765,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
websiteUrl: "https://www.kimi.com/code/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.kimi.com/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "Kimi For Coding",
|
||||
@@ -1018,6 +1158,38 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "OpenCode Go",
|
||||
websiteUrl: "https://opencode.ai/go",
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=2YTRG2NGTX",
|
||||
partnerPromotionKey: "opencode_go",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "OpenCode Go",
|
||||
options: {
|
||||
baseURL: "https://opencode.ai/zen/go/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"glm-5.2": { name: "GLM 5.2" },
|
||||
"kimi-k2.7-code": { name: "Kimi K2.7 Code" },
|
||||
"deepseek-v4-pro": { name: "DeepSeek V4 Pro" },
|
||||
"deepseek-v4-flash": { name: "DeepSeek V4 Flash" },
|
||||
"mimo-v2.5-pro": { name: "MiMo V2.5 Pro" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
icon: "opencode",
|
||||
iconColor: "#211E1E",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AiHubMix",
|
||||
websiteUrl: "https://aihubmix.com",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useDeleteProviderMutation,
|
||||
useSwitchProviderMutation,
|
||||
} from "@/lib/query";
|
||||
import { usageKeys } from "@/lib/query/usage";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
import {
|
||||
@@ -309,7 +310,7 @@ export function useProviderActions(
|
||||
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
|
||||
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["usage", provider.id, activeApp],
|
||||
queryKey: usageKeys.script(provider.id, activeApp),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["subscription", "quota", activeApp],
|
||||
|
||||
@@ -187,12 +187,19 @@ export function useToggleSkillApp() {
|
||||
|
||||
/**
|
||||
* 扫描未管理的 Skills
|
||||
*
|
||||
* - 传 { enabled: true }(Skill 面板挂载时)会在进入页面时自动静默扫描一次,
|
||||
* 30s 内复用结果,避免来回切页时重复磁盘 IO。
|
||||
* - 默认 enabled: false:仅订阅共享缓存(如顶栏「导入」按钮的绿点提示),
|
||||
* 不主动触发扫描。两者共用同一 queryKey,面板扫描完成后绿点会自动亮起。
|
||||
*/
|
||||
export function useScanUnmanagedSkills() {
|
||||
export function useScanUnmanagedSkills(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["skills", "unmanaged"],
|
||||
queryFn: () => skillsApi.scanUnmanaged(),
|
||||
enabled: false, // 手动触发
|
||||
enabled: options?.enabled ?? false,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"commonConfig": {
|
||||
"guideTitle": "What is a Common Config Snippet?",
|
||||
"guidePurpose": "It lets you share non-sensitive settings (plugins, environment variables, etc.) across different providers. These settings won't be lost when you switch providers.",
|
||||
"guideUsage": "How to use: ① Click \"Extract from Editor\" to save the common parts ② Check \"Write Common Config\" when creating a new provider",
|
||||
"guideUsage": "How to use: ① Click \"Extract from Editor\" to save the common parts ② Check \"Apply Common Config\" when creating a new provider",
|
||||
"guideReExtract": "If you've newly installed plugins or hooks, re-extract the common config so they sync to other providers.",
|
||||
"guideReassurance": "Don't worry — your original configuration is safely stored in the default provider and won't be lost.",
|
||||
"emptyTitle": "No common config snippet yet",
|
||||
@@ -68,10 +68,10 @@
|
||||
},
|
||||
"claudeConfig": {
|
||||
"configLabel": "Claude Code settings.json (JSON) *",
|
||||
"writeCommonConfig": "Write Common Config",
|
||||
"writeCommonConfig": "Apply Common Config",
|
||||
"editCommonConfig": "Edit Common Config",
|
||||
"editCommonConfigTitle": "Edit Common Config Snippet",
|
||||
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
|
||||
"commonConfigHint": "This snippet will be merged into settings.json when 'Apply Common Config' is checked",
|
||||
"fullSettingsHint": "Full Claude Code settings.json content",
|
||||
"extractFromCurrent": "Extract from Editor",
|
||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||
@@ -155,7 +155,7 @@
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "e.g., Company dedicated account",
|
||||
"configJson": "Config JSON",
|
||||
"writeCommonConfig": "Write common config",
|
||||
"writeCommonConfig": "Apply Common Config",
|
||||
"editCommonConfigButton": "Edit common config",
|
||||
"configJsonHint": "Please fill in complete Claude Code configuration",
|
||||
"editCommonConfigTitle": "Edit common config snippet",
|
||||
@@ -311,7 +311,7 @@
|
||||
},
|
||||
"commonConfig": {
|
||||
"title": "About Common Config",
|
||||
"message": "The \"Common Config Snippet\" lets you share plugins, environment variables, and other settings across different providers — so they won't be lost when you switch.\n\nHow to use:\n① Edit a provider → click \"Edit Common Config\" → \"Extract from Editor\"\n② When creating a new provider, check \"Write Common Config\" (enabled by default)\n\nIf you've newly installed plugins or hooks, re-extract the common config to keep them in sync.",
|
||||
"message": "The \"Common Config Snippet\" lets you share plugins, environment variables, and other settings across different providers — so they won't be lost when you switch.\n\nHow to use:\n① Edit a provider → click \"Edit Common Config\" → \"Extract from Editor\"\n② When creating a new provider, check \"Apply Common Config\" (enabled by default)\n\nIf you've newly installed plugins or hooks, re-extract the common config to keep them in sync.",
|
||||
"confirm": "Got it"
|
||||
}
|
||||
},
|
||||
@@ -888,7 +888,17 @@
|
||||
"searchPlaceholder": "Search by content, directory, or ID",
|
||||
"searchSessions": "Search sessions",
|
||||
"providerFilterAll": "All",
|
||||
"providerFilterTooltip": "Provider filter",
|
||||
"sessionList": "Sessions",
|
||||
"viewModeTooltip": "View mode",
|
||||
"viewModeFlat": "List",
|
||||
"viewModeGrouped": "Grouped",
|
||||
"unknownDirectory": "Unknown directory",
|
||||
"toggleProviderGroup": "Expand or collapse {{provider}} provider group",
|
||||
"toggleDirectoryGroup": "Expand or collapse {{directory}} directory group",
|
||||
"collapseAllGroups": "Collapse all",
|
||||
"selectProviderGroupForBatch": "Select sessions in {{provider}} provider group",
|
||||
"selectDirectoryGroupForBatch": "Select sessions in {{directory}} directory group",
|
||||
"manageBatchTooltip": "Enter batch management",
|
||||
"exitBatchModeTooltip": "Exit batch management",
|
||||
"batchModeHint": "Select sessions to delete",
|
||||
@@ -1006,6 +1016,7 @@
|
||||
"runapi": "RunAPI offers special benefits for CC Switch users. Register via this link and contact customer support to claim ¥14 free credit.",
|
||||
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
||||
"opencode_go": "Subscribe to OpenCode Go via this link — only $5 for your first month, plus an extra $5 in credit!",
|
||||
"dmxapi": "Claude Code exclusive model 66% OFF now!",
|
||||
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
|
||||
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!",
|
||||
@@ -1022,12 +1033,18 @@
|
||||
"volcengine_agentplan": "Volcengine Ark Coding Plan offers exclusive benefits for CC Switch users: subscribe via this link and new customers get 75% off for the first two months, plus an extra 5% off with the exclusive invite code 6J6FV5N2 — as low as ¥9.4/month!",
|
||||
"byteplus": "Register via this link to get 500,000 tokens of free inference quota per model.",
|
||||
"ccsub": "CCSub is a stable, affordable AI API relay — a drop-in replacement for Claude.ai & OpenAI subscriptions, with one key for all models at ~30% of direct API cost.",
|
||||
"subrouter": "SubRouter is an AI relay aggregator that lets one key access multiple models and providers.",
|
||||
"qiniu": "Qiniu Cloud is a multi-protocol AI relay: one key for native Claude, GPT, and Gemini models, with the Anthropic, OpenAI, and Gemini APIs all supported.",
|
||||
"fenno": "Fenno.ai is a stable, efficient API relay giving you access to a wide range of mainstream models. Exclusive CC Switch offer: subscribe via this link to the ¥9.9 Coding Plan worth $150 in credits!",
|
||||
"zetaapi": "ZetaAPI delivers real, undegraded models at as low as 35% of official pricing. Exclusive CC Switch offer: register via this link and use promo code CC-SWITCH for 10% off your first top-up!",
|
||||
"teamorouter": "TeamoRouter is an enterprise-grade Agentic LLM gateway with pricing up to 90% off. It offers exclusive benefits for CC Switch users: register via this link as a new user for 10% off your first top-up!",
|
||||
"unity2": "Unity2.ai offers exclusive benefits for CC Switch users: register via this link to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "Compshare",
|
||||
"ucloudCoding": "Compshare Coding Plan",
|
||||
"shengsuanyun": "Shengsuanyun",
|
||||
"qiniu": "Qiniu",
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"together": "Together AI"
|
||||
@@ -1067,6 +1084,13 @@
|
||||
"customUserAgentHint": "Only takes effect when local routing/proxy takeover is enabled; replaces the User-Agent in requests forwarded to the provider API.",
|
||||
"customUserAgentInvalid": "User-Agent must not contain control characters (e.g. line breaks); otherwise it will be ignored.",
|
||||
"customUserAgentPresets": "Presets",
|
||||
"localProxyRequestOverrides": "Local proxy request overrides",
|
||||
"localProxyRequestOverridesHint": "Only takes effect after local routing/proxy takeover; applied to the transformed upstream request.",
|
||||
"localProxyRequestOverridesInvalid": "Local proxy request overrides format error: {{error}}",
|
||||
"localProxyHeaderOverrides": "Header overrides",
|
||||
"localProxyHeaderOverridesInvalidDetail": "Header overrides format error: {{error}}",
|
||||
"localProxyBodyOverrides": "Body overrides",
|
||||
"localProxyBodyOverridesInvalidDetail": "Body overrides format error: {{error}}",
|
||||
"fullUrlLabel": "Full URL",
|
||||
"fullUrlEnabled": "Full URL Mode",
|
||||
"fullUrlDisabled": "Mark as Full URL",
|
||||
@@ -1247,10 +1271,10 @@
|
||||
"configTomlHint": "Codex config.toml configuration content",
|
||||
"configTomlStorageHint": "Proxy takeover is showing the stored config, not the live config.toml.",
|
||||
"proxyTakeoverStorageNotice": "💡 This app is under proxy takeover. You are editing the stored provider config; the live config is already temporarily managed by the proxy.",
|
||||
"writeCommonConfig": "Write Common Config",
|
||||
"writeCommonConfig": "Apply Common Config",
|
||||
"editCommonConfig": "Edit Common Config",
|
||||
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
|
||||
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked",
|
||||
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Apply Common Config' is checked",
|
||||
"enableGoalMode": "Enable Goal mode",
|
||||
"enableRemoteCompaction": "Enable remote compaction",
|
||||
"remoteCompactionHint": "When enabled, the active model_providers entry name is written as OpenAI so Codex can try remote compaction.",
|
||||
@@ -1261,9 +1285,10 @@
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"modelNameHint": "Specify the model to use, will be auto-updated in config.toml",
|
||||
"modelName": "Model Name",
|
||||
"localRoutingToggle": "Needs Local Routing",
|
||||
"localRoutingOffHint": "If your provider is not native OpenAI Responses API, or the model name is not Codex's default GPT series, please enable this switch.",
|
||||
"localRoutingOnHint": "Codex currently only natively supports OpenAI Responses API and GPT series models. If your provider uses the Chat Completions protocol or non-GPT models (such as DeepSeek or Kimi), enable this switch and keep local routing running while in use.",
|
||||
"upstreamFormatLabel": "Upstream Format",
|
||||
"upstreamFormatHint": "Pick Responses when your provider is natively a Responses API (direct, no format conversion); pick Chat when it uses the Chat Completions protocol (requires routing takeover to convert to Chat Completions).",
|
||||
"upstreamFormatChat": "Chat Completions (routing required)",
|
||||
"upstreamFormatResponses": "Responses (native)",
|
||||
"upstreamModelName": "Upstream Model Name",
|
||||
"upstreamModelNameHint": "For Chat format, enter the real upstream model here; routing converts Codex Responses requests to Chat Completions while keeping this model.",
|
||||
"modelNamePlaceholder": "e.g., gpt-5-codex",
|
||||
@@ -1291,17 +1316,17 @@
|
||||
"reasoningEffortHint": "Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.",
|
||||
"reasoningGroupTitle": "Reasoning Capability",
|
||||
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Override manually only when auto-detection is wrong.",
|
||||
"advancedSectionHint": "Includes local routing, model mapping, reasoning overrides and custom User-Agent. Enable local routing here when your provider uses the Chat Completions protocol or non-GPT models."
|
||||
"advancedSectionHint": "Includes upstream format, model mapping, reasoning overrides and custom User-Agent. Providers using the Chat Completions protocol require routing takeover to be enabled."
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "Environment Variables (.env)",
|
||||
"envFileHint": "Configure Gemini environment variables in .env format",
|
||||
"configJson": "Configuration File (config.json)",
|
||||
"configJsonHint": "Configure Gemini extended parameters in JSON format (optional)",
|
||||
"writeCommonConfig": "Write Common Config",
|
||||
"writeCommonConfig": "Apply Common Config",
|
||||
"editCommonConfig": "Edit Common Config",
|
||||
"editCommonConfigTitle": "Edit Gemini Common Config Snippet",
|
||||
"commonConfigHint": "This snippet writes to Gemini .env (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)",
|
||||
"commonConfigHint": "This snippet writes to Gemini .env when 'Apply Common Config' is checked (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)",
|
||||
"extractFromCurrent": "Extract from Editor",
|
||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
||||
"extractFailed": "Extract failed: {{error}}",
|
||||
@@ -1569,6 +1594,7 @@
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "Volcengine usage query needs an account-level AccessKey ID / Secret (different from the inference API key). Create them in the Volcengine console via the top-right account menu → 'API Access Keys'.",
|
||||
"volcengineKeyConsoleLink": "Create keys at:",
|
||||
"defaultPlan": "Default Plan",
|
||||
"queryFailedMessage": "Query failed",
|
||||
"queryScript": "Query script (JavaScript)",
|
||||
@@ -1617,6 +1643,23 @@
|
||||
"tip2": "• Extractor function runs in sandbox environment, supports ES2020+ syntax",
|
||||
"tip3": "• Entire config must be wrapped in () to form object literal expression"
|
||||
},
|
||||
"dbUpgrade": {
|
||||
"title": "Database version is too new",
|
||||
"description": "This database was created by a newer version of CC Switch. Upgrade the app to continue. Upgrading will not delete your data.",
|
||||
"versionInfo": "Database v{{db}} · App supports v{{supported}}",
|
||||
"dbPath": "Database file",
|
||||
"checking": "Checking for available updates…",
|
||||
"updateAvailable": "Version v{{version}} is available; upgrading will let you continue.",
|
||||
"incompatibleTitle": "Upgrading won't fix this",
|
||||
"incompatibleDescription": "You are already on the latest version, but the database (v{{db}}) is still newer than this app supports (v{{supported}}). It was likely created by a third-party client or a newer build, so upgrading the official app cannot make it compatible.",
|
||||
"preparing": "Preparing update…",
|
||||
"downloading": "Downloading update…",
|
||||
"upgradeNow": "Upgrade app",
|
||||
"retry": "Retry upgrade",
|
||||
"openReleases": "Open releases page",
|
||||
"openConfigDir": "Open config folder",
|
||||
"quit": "Quit"
|
||||
},
|
||||
"errors": {
|
||||
"usage_query_failed": "Usage query failed",
|
||||
"configLoadFailedTitle": "Configuration Load Failed",
|
||||
@@ -2194,6 +2237,7 @@
|
||||
"importSuccess": "Successfully imported {{count}} skills",
|
||||
"importSelected": "Import Selected ({{count}})",
|
||||
"noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.",
|
||||
"unmanagedAvailable": "Skills available to import",
|
||||
"foundIn": "Found in",
|
||||
"local": "Local",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps and create a local backup first.",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"commonConfig": {
|
||||
"guideTitle": "共通設定スニペットとは?",
|
||||
"guidePurpose": "異なるプロバイダー間でプラグインや環境変数などの非機密設定を共有するための機能です。プロバイダーを切り替えてもこれらの設定は失われません。",
|
||||
"guideUsage": "使い方:① 「編集内容から抽出」をクリックして共通部分を保存 ② 新規プロバイダー作成時に「共通設定を書き込む」をチェック",
|
||||
"guideUsage": "使い方:① 「編集内容から抽出」をクリックして共通部分を保存 ② 新規プロバイダー作成時に「共通設定を適用」をチェック",
|
||||
"guideReExtract": "新しいプラグインや Hook をインストールした場合は、共通設定を再抽出して他のプロバイダーに同期してください。",
|
||||
"guideReassurance": "ご安心ください:元の設定はデフォルトプロバイダーに安全に保存されており、失われることはありません。",
|
||||
"emptyTitle": "共通設定スニペットがまだありません",
|
||||
@@ -68,10 +68,10 @@
|
||||
},
|
||||
"claudeConfig": {
|
||||
"configLabel": "Claude Code settings.json (JSON) *",
|
||||
"writeCommonConfig": "共通設定を書き込む",
|
||||
"writeCommonConfig": "共通設定を適用",
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
||||
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
|
||||
"commonConfigHint": "「共通設定を適用」がオンのとき settings.json にマージされます",
|
||||
"fullSettingsHint": "Claude Code の settings.json 全文",
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
@@ -155,7 +155,7 @@
|
||||
"notes": "メモ",
|
||||
"notesPlaceholder": "例: 会社用アカウント",
|
||||
"configJson": "Config JSON",
|
||||
"writeCommonConfig": "共通設定を書き込む",
|
||||
"writeCommonConfig": "共通設定を適用",
|
||||
"editCommonConfigButton": "共通設定を編集",
|
||||
"configJsonHint": "Claude Code の設定をすべて入力してください",
|
||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
||||
@@ -311,7 +311,7 @@
|
||||
},
|
||||
"commonConfig": {
|
||||
"title": "共通設定について",
|
||||
"message": "「共通設定スニペット」を使うと、プラグインや環境変数などの設定を異なるプロバイダー間で共有でき、切り替え時に失われることがありません。\n\n使い方:\n① プロバイダーを編集 →「共通設定を編集」→「編集内容から抽出」\n② 新規プロバイダー作成時に「共通設定を書き込む」をチェック(デフォルトでオン)\n\n新しいプラグインや Hook をインストールした場合は、共通設定を再抽出してください。",
|
||||
"message": "「共通設定スニペット」を使うと、プラグインや環境変数などの設定を異なるプロバイダー間で共有でき、切り替え時に失われることがありません。\n\n使い方:\n① プロバイダーを編集 →「共通設定を編集」→「編集内容から抽出」\n② 新規プロバイダー作成時に「共通設定を適用」をチェック(デフォルトでオン)\n\n新しいプラグインや Hook をインストールした場合は、共通設定を再抽出してください。",
|
||||
"confirm": "わかりました"
|
||||
}
|
||||
},
|
||||
@@ -888,7 +888,17 @@
|
||||
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
"providerFilterTooltip": "プロバイダーフィルター",
|
||||
"sessionList": "セッション一覧",
|
||||
"viewModeTooltip": "表示方式",
|
||||
"viewModeFlat": "一覧",
|
||||
"viewModeGrouped": "分類",
|
||||
"unknownDirectory": "不明なディレクトリ",
|
||||
"toggleProviderGroup": "{{provider}} プロバイダーグループを開閉",
|
||||
"toggleDirectoryGroup": "{{directory}} ディレクトリグループを開閉",
|
||||
"collapseAllGroups": "すべて折りたたむ",
|
||||
"selectProviderGroupForBatch": "{{provider}} プロバイダーグループ内のセッションを選択",
|
||||
"selectDirectoryGroupForBatch": "{{directory}} ディレクトリグループ内のセッションを選択",
|
||||
"manageBatchTooltip": "一括管理に入る",
|
||||
"exitBatchModeTooltip": "一括管理を終了",
|
||||
"batchModeHint": "削除するセッションを選択",
|
||||
@@ -1006,6 +1016,7 @@
|
||||
"runapi": "RunAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録しカスタマーサポートにご連絡いただくと、¥14 の無料クレジットを受け取ることができます。",
|
||||
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
||||
"opencode_go": "このリンクから OpenCode Go を購読すると、初月はわずか $5、さらに $5 分のクレジットがもらえます!",
|
||||
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
|
||||
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
|
||||
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!",
|
||||
@@ -1022,12 +1033,18 @@
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟 Coding Plan を購読すると、新規のお客様は最初の 2 か月が 75% オフ、さらに専用招待コード 6J6FV5N2 で 5% オフが加算され、月額 9.4 元から!",
|
||||
"byteplus": "このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。",
|
||||
"ccsub": "CCSub は安定・低価格の AI API リレーサービスです。Claude Code の公式サブスクリプションの代替として、1つのキーで全モデルを公式比約1/3のコストで利用できます。",
|
||||
"subrouter": "SubRouter は、1つのキーで複数のモデルとプロバイダーにアクセスできる AI 中継アグリゲーターです。",
|
||||
"qiniu": "Qiniu Cloud(七牛云)はマルチプロトコル対応の AI 中継プラットフォームです。1つのキーでネイティブの Claude・GPT・Gemini モデルを利用でき、Anthropic/OpenAI/Gemini の各 API に対応します。",
|
||||
"fenno": "Fenno.ai は安定・高効率な API 中継サービスで、幅広い主要モデルを利用できます。CC Switch 利用者限定特典:このリンクから 9.9 元(150 ドル相当)の Coding Plan を購入できます!",
|
||||
"zetaapi": "ZetaAPI はモデルを水増し・劣化させず、公式価格の 35% から利用できます。CC Switch 利用者限定特典:このリンクから登録し、初回チャージ時にプロモコード CC-SWITCH を使うと初回チャージ 10% オフ!",
|
||||
"teamorouter": "TeamoRouter はエンタープライズグレードの Agentic LLM ゲートウェイで、料金は最大 90% オフ。CC Switch ユーザーに特別特典を提供しています:このリンクから新規登録すると初回チャージが 10% オフ!",
|
||||
"unity2": "Unity2.ai は CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると $2 分のクレジット、公式グループ参加でさらに $10、最大 $12 の無料クレジットがもらえます!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "Compshare",
|
||||
"ucloudCoding": "Compshare Coding Plan",
|
||||
"shengsuanyun": "Shengsuanyun",
|
||||
"qiniu": "Qiniu",
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"together": "Together AI"
|
||||
@@ -1067,6 +1084,13 @@
|
||||
"customUserAgentHint": "ローカルルーティング/プロキシ引き継ぎが有効な場合にのみ適用され、プロバイダー API へ転送するリクエストの User-Agent を置き換えます。",
|
||||
"customUserAgentInvalid": "User-Agent に制御文字(改行など)を含めることはできません。含まれている場合は無視されます。",
|
||||
"customUserAgentPresets": "プリセット",
|
||||
"localProxyRequestOverrides": "ローカルプロキシのリクエスト上書き",
|
||||
"localProxyRequestOverridesHint": "ローカルルーティング/プロキシ引き継ぎ後にのみ有効で、変換後の上流リクエストへ適用されます。",
|
||||
"localProxyRequestOverridesInvalid": "ローカルプロキシのリクエスト上書き形式エラー: {{error}}",
|
||||
"localProxyHeaderOverrides": "Header 上書き",
|
||||
"localProxyHeaderOverridesInvalidDetail": "Header 上書きの形式エラー: {{error}}",
|
||||
"localProxyBodyOverrides": "Body 上書き",
|
||||
"localProxyBodyOverridesInvalidDetail": "Body 上書きの形式エラー: {{error}}",
|
||||
"fullUrlLabel": "フル URL",
|
||||
"fullUrlEnabled": "フル URL モード",
|
||||
"fullUrlDisabled": "フル URL として設定",
|
||||
@@ -1247,10 +1271,10 @@
|
||||
"configTomlHint": "Codex の config.toml 設定内容",
|
||||
"configTomlStorageHint": "プロキシ引き継ぎ中は保存済みの config を表示しており、実際の config.toml ではありません。",
|
||||
"proxyTakeoverStorageNotice": "💡 このアプリはプロキシ引き継ぎ中です。ここで編集しているのは保存済みのプロバイダー設定で、実際の live 設定はすでにプロキシが一時的に引き継いでいます。",
|
||||
"writeCommonConfig": "共通設定を書き込む",
|
||||
"writeCommonConfig": "共通設定を適用",
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
|
||||
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
|
||||
"commonConfigHint": "「共通設定を適用」がオンの場合、config.toml の末尾に追記されます",
|
||||
"enableGoalMode": "Goal mode を有効化",
|
||||
"enableRemoteCompaction": "リモート圧縮を有効化",
|
||||
"remoteCompactionHint": "有効にすると、現在の model_providers エントリの name を OpenAI に設定し、Codex がリモート圧縮を試行できるようにします。",
|
||||
@@ -1261,9 +1285,10 @@
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます",
|
||||
"modelName": "モデル名",
|
||||
"localRoutingToggle": "ローカルルーティングが必要",
|
||||
"localRoutingOffHint": "プロバイダーがネイティブの OpenAI Responses API でない場合、またはモデル名が Codex デフォルトの GPT 系列でない場合は、このスイッチを有効にしてください。",
|
||||
"localRoutingOnHint": "Codex は現在、OpenAI Responses API と GPT 系列モデルのみをネイティブにサポートしています。プロバイダーが Chat Completions プロトコルや非 GPT モデル(DeepSeek、Kimi など)を使用する場合は、このスイッチを有効にし、使用中はローカルルーティングを起動したままにしてください。",
|
||||
"upstreamFormatLabel": "上流フォーマット",
|
||||
"upstreamFormatHint": "プロバイダーがネイティブ Responses API なら Responses を選択(直結、フォーマット変換なし)。Chat Completions プロトコルなら Chat を選択(Chat Completions へ変換するにはルーティング引き継ぎを有効化する必要があります)。",
|
||||
"upstreamFormatChat": "Chat Completions(ルーティング必須)",
|
||||
"upstreamFormatResponses": "Responses(ネイティブ)",
|
||||
"upstreamModelName": "上流モデル名",
|
||||
"upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。ルーティングで Codex の Responses リクエストを Chat Completions に変換し、このモデルを維持します。",
|
||||
"modelNamePlaceholder": "例: gpt-5-codex",
|
||||
@@ -1291,17 +1316,17 @@
|
||||
"reasoningEffortHint": "上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。",
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ手動で上書きしてください。",
|
||||
"advancedSectionHint": "ローカルルーティング、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。供給元が Chat Completions プロトコルまたは GPT 以外のモデルを使用する場合は、ここでローカルルーティングを有効にしてください。"
|
||||
"advancedSectionHint": "上流フォーマット、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。Chat Completions プロトコルを使用するプロバイダーはルーティング引き継ぎの有効化が必要です。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境変数 (.env)",
|
||||
"envFileHint": ".env 形式で Gemini の環境変数を設定",
|
||||
"configJson": "設定ファイル (config.json)",
|
||||
"configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)",
|
||||
"writeCommonConfig": "共通設定を書き込む",
|
||||
"writeCommonConfig": "共通設定を適用",
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "Gemini 共通設定スニペットを編集",
|
||||
"commonConfigHint": "このスニペットは Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)",
|
||||
"commonConfigHint": "「共通設定を適用」がオンのとき Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)",
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||
@@ -1569,6 +1594,7 @@
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "Volcengine の使用量照会にはアカウントレベルの AccessKey ID / Secret が必要です(推論 API キーとは別物)。Volcengine コンソール右上のアカウントメニュー →「API アクセスキー」で作成してください。",
|
||||
"volcengineKeyConsoleLink": "キー作成ページ:",
|
||||
"defaultPlan": "デフォルトプラン",
|
||||
"queryFailedMessage": "照会に失敗しました",
|
||||
"queryScript": "照会スクリプト (JavaScript)",
|
||||
@@ -1617,6 +1643,23 @@
|
||||
"tip2": "• 抽出関数はサンドボックスで実行され、ES2020+ の構文を使えます",
|
||||
"tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください"
|
||||
},
|
||||
"dbUpgrade": {
|
||||
"title": "データベースのバージョンが新しすぎます",
|
||||
"description": "このデータベースは新しいバージョンの CC Switch で作成されています。続行するにはアプリを更新してください。更新してもデータは削除されません。",
|
||||
"versionInfo": "データベース v{{db}} · アプリ対応 v{{supported}}",
|
||||
"dbPath": "データベースファイル",
|
||||
"checking": "利用可能な更新を確認中…",
|
||||
"updateAvailable": "新しいバージョン v{{version}} が利用可能です。更新すると続行できます。",
|
||||
"incompatibleTitle": "更新しても解決できません",
|
||||
"incompatibleDescription": "すでに最新バージョンですが、データベース(v{{db}})はこのアプリの対応バージョン(v{{supported}})より新しいままです。サードパーティ製クライアントまたはより新しいビルドで作成された可能性があり、公式アプリを更新しても互換性は得られません。",
|
||||
"preparing": "更新を準備中…",
|
||||
"downloading": "更新をダウンロード中…",
|
||||
"upgradeNow": "アプリを更新",
|
||||
"retry": "更新を再試行",
|
||||
"openReleases": "リリースページを開く",
|
||||
"openConfigDir": "設定フォルダを開く",
|
||||
"quit": "終了"
|
||||
},
|
||||
"errors": {
|
||||
"usage_query_failed": "利用状況の取得に失敗しました",
|
||||
"configLoadFailedTitle": "設定の読み込みに失敗しました",
|
||||
@@ -2194,6 +2237,7 @@
|
||||
"importSuccess": "{{count}} 件のスキルをインポートしました",
|
||||
"importSelected": "選択をインポート ({{count}})",
|
||||
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
|
||||
"unmanagedAvailable": "インポート可能なスキルがあります",
|
||||
"foundIn": "発見場所",
|
||||
"local": "ローカル",
|
||||
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"commonConfig": {
|
||||
"guideTitle": "什麼是通用設定片段?",
|
||||
"guidePurpose": "用來在不同供應商之間共用外掛程式、環境變數等非敏感設定。切換供應商時不會遺失這些設定。",
|
||||
"guideUsage": "用法:① 點擊「從編輯內容擷取」儲存通用部分 ② 新增供應商時勾選「寫入通用設定」",
|
||||
"guideUsage": "用法:① 點擊「從編輯內容擷取」儲存通用部分 ② 新增供應商時勾選「套用通用設定」",
|
||||
"guideReExtract": "如果您新安裝了外掛程式或 Hook,請重新擷取一次通用設定,以便同步至其他供應商。",
|
||||
"guideReassurance": "請放心:您的原始設定已儲存在預設供應商中,不會遺失。",
|
||||
"emptyTitle": "尚未建立通用設定片段",
|
||||
@@ -68,10 +68,10 @@
|
||||
},
|
||||
"claudeConfig": {
|
||||
"configLabel": "Claude Code 設定 (JSON) *",
|
||||
"writeCommonConfig": "寫入通用設定",
|
||||
"writeCommonConfig": "套用通用設定",
|
||||
"editCommonConfig": "編輯通用設定",
|
||||
"editCommonConfigTitle": "編輯通用設定片段",
|
||||
"commonConfigHint": "該片段會在勾選「寫入通用設定」時合併到 settings.json 中",
|
||||
"commonConfigHint": "該片段會在勾選「套用通用設定」時合併到 settings.json 中",
|
||||
"fullSettingsHint": "完整的 Claude Code settings.json 設定內容",
|
||||
"extractFromCurrent": "從編輯內容擷取",
|
||||
"extractNoCommonConfig": "目前編輯內容沒有可擷取的通用設定",
|
||||
@@ -155,7 +155,7 @@
|
||||
"notes": "備註",
|
||||
"notesPlaceholder": "例如:公司專用帳號",
|
||||
"configJson": "設定 JSON",
|
||||
"writeCommonConfig": "寫入通用設定",
|
||||
"writeCommonConfig": "套用通用設定",
|
||||
"editCommonConfigButton": "編輯通用設定",
|
||||
"configJsonHint": "請填寫完整的 Claude Code 設定",
|
||||
"editCommonConfigTitle": "編輯通用設定片段",
|
||||
@@ -311,7 +311,7 @@
|
||||
},
|
||||
"commonConfig": {
|
||||
"title": "關於通用設定",
|
||||
"message": "「通用設定片段」可以在不同供應商之間共用外掛程式、環境變數等設定,避免切換供應商時遺失這些設定。\n\n使用方法:\n① 編輯供應商時點擊「編輯通用設定」→「從編輯內容擷取」\n② 新增供應商時勾選「寫入通用設定」(預設已勾選)\n\n如果您新安裝了外掛程式或 Hook,請重新擷取一次通用設定。",
|
||||
"message": "「通用設定片段」可以在不同供應商之間共用外掛程式、環境變數等設定,避免切換供應商時遺失這些設定。\n\n使用方法:\n① 編輯供應商時點擊「編輯通用設定」→「從編輯內容擷取」\n② 新增供應商時勾選「套用通用設定」(預設已勾選)\n\n如果您新安裝了外掛程式或 Hook,請重新擷取一次通用設定。",
|
||||
"confirm": "我了解了"
|
||||
}
|
||||
},
|
||||
@@ -859,7 +859,17 @@
|
||||
"searchPlaceholder": "搜尋對話內容、目錄或 ID",
|
||||
"searchSessions": "搜尋工作階段",
|
||||
"providerFilterAll": "全部",
|
||||
"providerFilterTooltip": "供應商篩選",
|
||||
"sessionList": "工作階段清單",
|
||||
"viewModeTooltip": "檢視方式",
|
||||
"viewModeFlat": "清單",
|
||||
"viewModeGrouped": "分類",
|
||||
"unknownDirectory": "未知目錄",
|
||||
"toggleProviderGroup": "展開或摺疊 {{provider}} 供應商分組",
|
||||
"toggleDirectoryGroup": "展開或摺疊 {{directory}} 目錄分組",
|
||||
"collapseAllGroups": "全部收起",
|
||||
"selectProviderGroupForBatch": "選擇 {{provider}} 供應商分組內工作階段",
|
||||
"selectDirectoryGroupForBatch": "選擇 {{directory}} 目錄分組內工作階段",
|
||||
"manageBatchTooltip": "進入批次管理",
|
||||
"exitBatchModeTooltip": "退出批次管理",
|
||||
"batchModeHint": "勾選要刪除的工作階段",
|
||||
@@ -977,6 +987,7 @@
|
||||
"runapi": "RunAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊並聯繫客服可領取 ¥14 免費額度。",
|
||||
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 方案 9.9 元起",
|
||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 方案現僅 $2 / 月(2 折優惠!)",
|
||||
"opencode_go": "使用此連結訂閱 OpenCode Go,首月僅需 $5,並可獲得額外的 $5 額度!",
|
||||
"dmxapi": "Claude Code 專屬模型 3.4 折優惠進行中!",
|
||||
"cubence": "Cubence 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「CCSWITCH」優惠碼,每次儲值均可享受 9 折優惠",
|
||||
"aigocode": "AIGoCode 是 CC Switch 的官方合作夥伴,使用此連結註冊首次儲值時可以獲得 10% 額度獎勵!",
|
||||
@@ -994,12 +1005,18 @@
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 Coding Plan,新客戶首兩個月享 2.5 折優惠,再用專屬邀請碼 6J6FV5N2 領取獎勵疊加 9.5 折,低至 9.4 元/月!",
|
||||
"byteplus": "透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。",
|
||||
"ccsub": "CCSub 是穩定、實惠的 AI API 中轉平台,Claude Code 官方訂閱的超強平替,一個 Key 覆蓋全部模型,價格約為官方 1/3。",
|
||||
"subrouter": "SubRouter 是中轉站聚合平台,一個 Key 可接入多個模型與供應商。",
|
||||
"qiniu": "七牛雲是多協定 AI 中轉平台,一個 Key 即可呼叫原生 Claude、GPT、Gemini 模型,相容 Anthropic、OpenAI、Gemini 多種介面。",
|
||||
"fenno": "Fenno.ai 是穩定高效的 API 中轉服務商,可接入多種主流模型。CC Switch 使用者專屬福利:透過此連結訂閱 9.9 元 /150 刀額度的超值 Coding Plan!",
|
||||
"zetaapi": "ZetaAPI 主打模型不摻水、不降智,價格低至官方價 35 折。CC Switch 使用者專屬福利:透過此連結註冊,首次充值使用優惠碼 CC-SWITCH 即可享受九折優惠!",
|
||||
"teamorouter": "TeamoRouter 是企業級 Agentic LLM 閘道,價格最高可達 90% 折扣。為 CC Switch 的使用者提供了特別福利:新使用者透過此連結註冊,首次充值享 10% 折扣!",
|
||||
"unity2": "Unity2.ai 為 CC Switch 使用者提供專屬福利:透過此連結註冊可領取 $2 餘額,加入官方群再送 $10,最高可領 $12 免費額度!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "優雲智算",
|
||||
"ucloudCoding": "優雲智算 Coding Plan",
|
||||
"shengsuanyun": "勝算雲",
|
||||
"qiniu": "七牛雲",
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"together": "Together AI"
|
||||
@@ -1039,6 +1056,13 @@
|
||||
"customUserAgentHint": "僅在開啟本地路由/代理接管後生效,會取代轉發至供應商 API 請求中的 User-Agent。",
|
||||
"customUserAgentInvalid": "User-Agent 不能包含控制字元(如換行字元),否則將被忽略。",
|
||||
"customUserAgentPresets": "預設",
|
||||
"localProxyRequestOverrides": "本地代理請求覆蓋",
|
||||
"localProxyRequestOverridesHint": "僅在本地路由/代理接管後生效,套用於協定轉換後的上游請求。",
|
||||
"localProxyRequestOverridesInvalid": "本地代理請求覆蓋格式錯誤:{{error}}",
|
||||
"localProxyHeaderOverrides": "Header 覆蓋",
|
||||
"localProxyHeaderOverridesInvalidDetail": "Header 覆蓋格式錯誤:{{error}}",
|
||||
"localProxyBodyOverrides": "Body 覆蓋",
|
||||
"localProxyBodyOverridesInvalidDetail": "Body 覆蓋格式錯誤:{{error}}",
|
||||
"fullUrlLabel": "完整 URL",
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "標記為完整 URL",
|
||||
@@ -1219,10 +1243,10 @@
|
||||
"configTomlHint": "Codex config.toml 設定內容",
|
||||
"configTomlStorageHint": "代理接管中顯示的是儲存的 config 配置,不是即時 config.toml",
|
||||
"proxyTakeoverStorageNotice": "💡 目前應用處於代理接管狀態。這裡編輯的是儲存的供應商配置,真實 live 配置已由代理暫時接管",
|
||||
"writeCommonConfig": "寫入通用設定",
|
||||
"writeCommonConfig": "套用通用設定",
|
||||
"editCommonConfig": "編輯通用設定",
|
||||
"editCommonConfigTitle": "編輯 Codex 通用設定片段",
|
||||
"commonConfigHint": "該片段會在勾選「寫入通用設定」時附加到 config.toml 末尾",
|
||||
"commonConfigHint": "該片段會在勾選「套用通用設定」時附加到 config.toml 末尾",
|
||||
"enableGoalMode": "啟用 Goal mode",
|
||||
"enableRemoteCompaction": "啟用遠端壓縮",
|
||||
"remoteCompactionHint": "開啟後會將目前 model_providers 條目的 name 寫為 OpenAI,讓 Codex 嘗試使用遠端壓縮。",
|
||||
@@ -1238,9 +1262,10 @@
|
||||
"autoCompactLimit": "壓縮閾值",
|
||||
"autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史",
|
||||
"autoCompactLimitPlaceholder": "例如: 90000",
|
||||
"localRoutingToggle": "需要本地路由映射",
|
||||
"localRoutingOnHint": "Codex 目前僅原生支援 OpenAI Responses API 與 GPT 系列模型;如果您的供應商使用 Chat Completions 協定或非 GPT 模型(如 DeepSeek、Kimi),則需要打開本開關,並在使用過程中保持本地路由開啟。",
|
||||
"localRoutingOffHint": "如果您的供應商不是原生 OpenAI Responses API,或者模型名不是 Codex 預設的 GPT 系列,請打開此開關。",
|
||||
"upstreamFormatLabel": "上游格式",
|
||||
"upstreamFormatHint": "供應商原生為 Responses API 就選 Responses(直連,不轉換格式);使用 Chat Completions 協定就選 Chat(需開啟路由接管才能轉換為 Chat Completions)。",
|
||||
"upstreamFormatChat": "Chat Completions(需開啟路由)",
|
||||
"upstreamFormatResponses": "Responses(原生)",
|
||||
"modelMappingTitle": "模型映射",
|
||||
"modelMappingHint": "產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。",
|
||||
"addCatalogModel": "新增模型",
|
||||
@@ -1263,17 +1288,17 @@
|
||||
"reasoningEffortHint": "上游支援 low/high/max 等思考深度控制時啟用。啟用後會自動啟用思考模式,並把 Codex 的 reasoning.effort 轉成上游 Chat 參數。",
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "預設供應商已自動設定;自訂供應商會按名稱/位址自動推斷。僅當自動識別不準時才需手動覆寫。",
|
||||
"advancedSectionHint": "包含本地路由映射、模型映射、思考能力與自訂 User-Agent。供應商使用 Chat Completions 協定或非 GPT 模型時,需在此開啟本地路由映射。"
|
||||
"advancedSectionHint": "包含上游格式、模型映射、思考能力與自訂 User-Agent。使用 Chat Completions 協定的供應商需開啟路由接管才能使用。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境變數 (.env)",
|
||||
"envFileHint": "使用 .env 格式設定 Gemini 環境變數",
|
||||
"configJson": "設定檔 (config.json)",
|
||||
"configJsonHint": "使用 JSON 格式設定 Gemini 擴充參數(選填)",
|
||||
"writeCommonConfig": "寫入通用設定",
|
||||
"writeCommonConfig": "套用通用設定",
|
||||
"editCommonConfig": "編輯通用設定",
|
||||
"editCommonConfigTitle": "編輯 Gemini 通用設定片段",
|
||||
"commonConfigHint": "該片段會寫入 Gemini 的 .env(不允許包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
||||
"commonConfigHint": "該片段會在勾選「套用通用設定」時寫入 Gemini 的 .env(不允許包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
||||
"extractFromCurrent": "從編輯內容擷取",
|
||||
"extractNoCommonConfig": "目前編輯內容沒有可擷取的通用設定",
|
||||
"extractFailed": "擷取失敗:{{error}}",
|
||||
@@ -1541,6 +1566,7 @@
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "火山用量查詢需帳號級 AccessKey ID / Secret(與推理 API Key 不同)。請在火山引擎主控台右上角帳號選單 →「API存取金鑰」中建立。",
|
||||
"volcengineKeyConsoleLink": "金鑰建立網址:",
|
||||
"defaultPlan": "預設方案",
|
||||
"queryFailedMessage": "查詢失敗",
|
||||
"queryScript": "查詢腳本(JavaScript)",
|
||||
@@ -1589,6 +1615,23 @@
|
||||
"tip2": "• extractor 函式在沙盒環境中執行,支援 ES2020+ 語法",
|
||||
"tip3": "• 整個設定必須用 () 包覆,形成物件實字運算式"
|
||||
},
|
||||
"dbUpgrade": {
|
||||
"title": "資料庫版本過新",
|
||||
"description": "目前資料庫由較新版本的 CC Switch 建立,需要升級應用後才能繼續使用。升級不會刪除你的資料。",
|
||||
"versionInfo": "資料庫版本 v{{db}} · 應用支援 v{{supported}}",
|
||||
"dbPath": "資料庫檔案",
|
||||
"checking": "正在檢查可用更新…",
|
||||
"updateAvailable": "發現新版本 v{{version}},升級後即可繼續使用。",
|
||||
"incompatibleTitle": "升級也無法解決",
|
||||
"incompatibleDescription": "你已是最新版本,但資料庫版本(v{{db}})仍高於本應用支援的版本(v{{supported}})。該資料庫可能由第三方用戶端或更高版本建立,升級目前官方應用也無法相容。",
|
||||
"preparing": "正在準備更新…",
|
||||
"downloading": "正在下載更新…",
|
||||
"upgradeNow": "升級應用",
|
||||
"retry": "重試升級",
|
||||
"openReleases": "開啟發佈頁",
|
||||
"openConfigDir": "開啟設定目錄",
|
||||
"quit": "結束"
|
||||
},
|
||||
"errors": {
|
||||
"usage_query_failed": "用量查詢失敗",
|
||||
"configLoadFailedTitle": "設定載入失敗",
|
||||
@@ -2166,6 +2209,7 @@
|
||||
"importSuccess": "成功匯入 {{count}} 個技能",
|
||||
"importSelected": "匯入已選 ({{count}})",
|
||||
"noUnmanagedFound": "未發現需要匯入的技能。所有技能已在 CC Switch 統一管理中。",
|
||||
"unmanagedAvailable": "發現可匯入的技能",
|
||||
"foundIn": "發現於",
|
||||
"local": "本地",
|
||||
"uninstallConfirm": "確定要解除安裝技能 \"{{name}}\" 嗎?這將從所有應用程式中移除該技能,並在刪除前自動建立本地備份。",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"commonConfig": {
|
||||
"guideTitle": "什么是通用配置片段?",
|
||||
"guidePurpose": "用来在不同供应商之间共享插件、环境变量等非敏感配置。切换供应商时不会丢失这些设置。",
|
||||
"guideUsage": "用法:① 点击「从编辑内容提取」保存通用部分 ② 新建供应商时勾选「写入通用配置」",
|
||||
"guideUsage": "用法:① 点击「从编辑内容提取」保存通用部分 ② 新建供应商时勾选「应用通用配置」",
|
||||
"guideReExtract": "如果您新安装了插件或 Hook,请重新提取一次通用配置,以便同步到其他供应商。",
|
||||
"guideReassurance": "放心:您的原始配置已保存在默认供应商中,不会丢失。",
|
||||
"emptyTitle": "还没有通用配置片段",
|
||||
@@ -68,10 +68,10 @@
|
||||
},
|
||||
"claudeConfig": {
|
||||
"configLabel": "Claude Code 配置 (JSON) *",
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"writeCommonConfig": "应用通用配置",
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑通用配置片段",
|
||||
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
|
||||
"commonConfigHint": "该片段会在勾选\"应用通用配置\"时合并到 settings.json 中",
|
||||
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
@@ -155,7 +155,7 @@
|
||||
"notes": "备注",
|
||||
"notesPlaceholder": "例如:公司专用账号",
|
||||
"configJson": "配置 JSON",
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"writeCommonConfig": "应用通用配置",
|
||||
"editCommonConfigButton": "编辑通用配置",
|
||||
"configJsonHint": "请填写完整的 Claude Code 配置",
|
||||
"editCommonConfigTitle": "编辑通用配置片段",
|
||||
@@ -311,7 +311,7 @@
|
||||
},
|
||||
"commonConfig": {
|
||||
"title": "关于通用配置",
|
||||
"message": "「通用配置片段」可以在不同供应商之间共享插件、环境变量等配置,避免切换供应商时丢失这些设置。\n\n使用方法:\n① 编辑供应商时点击「编辑通用配置」→「从编辑内容提取」\n② 新建供应商时勾选「写入通用配置」(默认已勾选)\n\n如果您新安装了插件或 Hook,请重新提取一次通用配置。",
|
||||
"message": "「通用配置片段」可以在不同供应商之间共享插件、环境变量等配置,避免切换供应商时丢失这些设置。\n\n使用方法:\n① 编辑供应商时点击「编辑通用配置」→「从编辑内容提取」\n② 新建供应商时勾选「应用通用配置」(默认已勾选)\n\n如果您新安装了插件或 Hook,请重新提取一次通用配置。",
|
||||
"confirm": "我知道了"
|
||||
}
|
||||
},
|
||||
@@ -888,7 +888,17 @@
|
||||
"searchPlaceholder": "搜索会话内容、目录或 ID",
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
"providerFilterTooltip": "供应商筛选",
|
||||
"sessionList": "会话列表",
|
||||
"viewModeTooltip": "查看方式",
|
||||
"viewModeFlat": "列表",
|
||||
"viewModeGrouped": "分类",
|
||||
"unknownDirectory": "未知目录",
|
||||
"toggleProviderGroup": "展开或折叠 {{provider}} 供应商分组",
|
||||
"toggleDirectoryGroup": "展开或折叠 {{directory}} 目录分组",
|
||||
"collapseAllGroups": "全部收起",
|
||||
"selectProviderGroupForBatch": "选择 {{provider}} 供应商分组内会话",
|
||||
"selectDirectoryGroupForBatch": "选择 {{directory}} 目录分组内会话",
|
||||
"manageBatchTooltip": "进入批量管理",
|
||||
"exitBatchModeTooltip": "退出批量管理",
|
||||
"batchModeHint": "勾选要删除的会话",
|
||||
@@ -1006,6 +1016,7 @@
|
||||
"runapi": "RunAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册并联系客服可领取 ¥14 免费额度。",
|
||||
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
|
||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
|
||||
"opencode_go": "使用此链接订阅 OpenCode Go,首月只需 $5,并可获得额外的 $5 额度!",
|
||||
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!",
|
||||
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
|
||||
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!",
|
||||
@@ -1022,12 +1033,18 @@
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!",
|
||||
"byteplus": "通过此链接注册即可获取每个模型50万tokens的免费推理额度。",
|
||||
"ccsub": "CCSub 是稳定、实惠的 AI API 中转平台,Claude Code 官方订阅的超强平替,一个 Key 覆盖全部模型,价格约为官方 1/3。",
|
||||
"subrouter": "SubRouter 是中转站聚合平台,一个 Key 可接入多个模型与供应商。",
|
||||
"qiniu": "七牛云是多协议 AI 中转平台,一个 Key 即可调用原生 Claude、GPT、Gemini 模型,兼容 Anthropic、OpenAI、Gemini 多种接口。",
|
||||
"fenno": "Fenno.ai 是稳定高效的 API 中转服务商,可接入多种主流模型。CC Switch 用户专属福利:通过此链接订阅 9.9 元 /150 刀额度的超值 Coding Plan!",
|
||||
"zetaapi": "ZetaAPI 主打模型不掺水、不降智,价格低至官方价 35 折。CC Switch 用户专属福利:通过此链接注册,首次充值使用优惠码 CC-SWITCH 即可享受九折优惠!",
|
||||
"teamorouter": "TeamoRouter 是企业级 Agentic LLM 网关,价格最高可达 90% 折扣。为 CC Switch 的用户提供了特别福利:新用户通过此链接注册,首次充值享 10% 折扣!",
|
||||
"unity2": "Unity2.ai 为 CC Switch 用户提供专属福利:通过此链接注册可领取 $2 余额,加入官方群再送 $10,最高可领 $12 免费额度!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "优云智算",
|
||||
"ucloudCoding": "优云智算Coding Plan",
|
||||
"shengsuanyun": "胜算云",
|
||||
"qiniu": "七牛云",
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"together": "Together AI"
|
||||
@@ -1067,6 +1084,13 @@
|
||||
"customUserAgentHint": "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
||||
"customUserAgentInvalid": "User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
|
||||
"customUserAgentPresets": "预设",
|
||||
"localProxyRequestOverrides": "本地代理请求覆盖",
|
||||
"localProxyRequestOverridesHint": "仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。",
|
||||
"localProxyRequestOverridesInvalid": "本地代理请求覆盖格式错误:{{error}}",
|
||||
"localProxyHeaderOverrides": "Header 覆盖",
|
||||
"localProxyHeaderOverridesInvalidDetail": "Header 覆盖格式错误:{{error}}",
|
||||
"localProxyBodyOverrides": "Body 覆盖",
|
||||
"localProxyBodyOverridesInvalidDetail": "Body 覆盖格式错误:{{error}}",
|
||||
"fullUrlLabel": "完整 URL",
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "标记为完整 URL",
|
||||
@@ -1247,10 +1271,10 @@
|
||||
"configTomlHint": "Codex config.toml 配置内容",
|
||||
"configTomlStorageHint": "代理接管中显示的是存储的 config 配置,不是实时 config.toml",
|
||||
"proxyTakeoverStorageNotice": "💡 当前应用处于代理接管状态。这里编辑的是存储的供应商配置,真实 live 配置已由代理临时接管",
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"writeCommonConfig": "应用通用配置",
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
|
||||
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
|
||||
"commonConfigHint": "该片段会在勾选'应用通用配置'时追加到 config.toml 末尾",
|
||||
"enableGoalMode": "启用 Goal mode",
|
||||
"enableRemoteCompaction": "启用远程压缩",
|
||||
"remoteCompactionHint": "开启后会将当前 model_providers 条目的 name 写为 OpenAI,使 Codex 尝试使用远程压缩。",
|
||||
@@ -1261,9 +1285,10 @@
|
||||
"saveFailed": "保存失败: {{error}}",
|
||||
"modelNameHint": "指定使用的模型,将自动更新到 config.toml 中",
|
||||
"modelName": "模型名称",
|
||||
"localRoutingToggle": "需要本地路由映射",
|
||||
"localRoutingOffHint": "如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。",
|
||||
"localRoutingOnHint": "Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。",
|
||||
"upstreamFormatLabel": "上游格式",
|
||||
"upstreamFormatHint": "供应商原生为 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(需开启路由接管才能转换为 Chat Completions)。",
|
||||
"upstreamFormatChat": "Chat Completions(需开启路由)",
|
||||
"upstreamFormatResponses": "Responses(原生)",
|
||||
"upstreamModelName": "上游模型名称",
|
||||
"upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;路由会把 Codex 的 Responses 请求转换为 Chat Completions 并保持该模型。",
|
||||
"modelNamePlaceholder": "例如: gpt-5-codex",
|
||||
@@ -1291,17 +1316,17 @@
|
||||
"reasoningEffortHint": "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
|
||||
"advancedSectionHint": "包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。"
|
||||
"advancedSectionHint": "包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "环境变量 (.env)",
|
||||
"envFileHint": "使用 .env 格式配置 Gemini 环境变量",
|
||||
"configJson": "配置文件 (config.json)",
|
||||
"configJsonHint": "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"writeCommonConfig": "应用通用配置",
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑 Gemini 通用配置片段",
|
||||
"commonConfigHint": "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
||||
"commonConfigHint": "该片段会在勾选\"应用通用配置\"时写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
"extractFailed": "提取失败: {{error}}",
|
||||
@@ -1569,6 +1594,7 @@
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "火山用量查询需账号级 AccessKey ID / Secret(与推理 API Key 不同)。请在火山引擎控制台右上角账号菜单 →「API访问密钥」中创建。",
|
||||
"volcengineKeyConsoleLink": "密钥创建地址:",
|
||||
"defaultPlan": "默认套餐",
|
||||
"queryFailedMessage": "查询失败",
|
||||
"queryScript": "查询脚本(JavaScript)",
|
||||
@@ -1617,6 +1643,23 @@
|
||||
"tip2": "• extractor 函数在沙箱环境中执行,支持 ES2020+ 语法",
|
||||
"tip3": "• 整个配置必须用 () 包裹,形成对象字面量表达式"
|
||||
},
|
||||
"dbUpgrade": {
|
||||
"title": "数据库版本过新",
|
||||
"description": "当前数据库由更新版本的 CC Switch 创建,需要升级应用后才能继续使用。升级不会删除你的数据。",
|
||||
"versionInfo": "数据库版本 v{{db}} · 应用支持 v{{supported}}",
|
||||
"dbPath": "数据库文件",
|
||||
"checking": "正在检查可用更新…",
|
||||
"updateAvailable": "发现新版本 v{{version}},升级后即可继续使用。",
|
||||
"incompatibleTitle": "升级也无法解决",
|
||||
"incompatibleDescription": "你已是最新版本,但数据库版本(v{{db}})仍高于本应用支持的版本(v{{supported}})。该数据库可能由第三方客户端或更高版本创建,升级当前官方应用也无法兼容。",
|
||||
"preparing": "正在准备更新…",
|
||||
"downloading": "正在下载更新…",
|
||||
"upgradeNow": "升级应用",
|
||||
"retry": "重试升级",
|
||||
"openReleases": "打开发布页",
|
||||
"openConfigDir": "打开配置目录",
|
||||
"quit": "退出"
|
||||
},
|
||||
"errors": {
|
||||
"usage_query_failed": "用量查询失败",
|
||||
"configLoadFailedTitle": "配置加载失败",
|
||||
@@ -2194,6 +2237,7 @@
|
||||
"importSuccess": "成功导入 {{count}} 个技能",
|
||||
"importSelected": "导入已选 ({{count}})",
|
||||
"noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。",
|
||||
"unmanagedAvailable": "发现可导入的技能",
|
||||
"foundIn": "发现于",
|
||||
"local": "本地",
|
||||
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能,并在删除前自动创建本地备份。",
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 38 KiB |
@@ -11,15 +11,20 @@ import _claudecn from "./claudecn.png";
|
||||
import _cherryin from "./cherryin.png";
|
||||
import _eflowcode from "./eflowcode.png";
|
||||
import _etok from "./etok.png";
|
||||
import _fenno from "./fenno-icon.webp";
|
||||
import _hermes from "./hermes.png";
|
||||
import _huoshan from "./huoshan.png";
|
||||
import _pateway from "./pateway.jpg";
|
||||
import _pipellm from "./pipellm.png";
|
||||
import _qiniu from "./qiniu.png";
|
||||
import _relaxcode from "./relaxcode.png";
|
||||
import _runapi from "./runapi.jpg";
|
||||
import _shengsuanyun from "./shengsuanyun.svg?url";
|
||||
import _subrouter from "./subrouter.svg?url";
|
||||
import _sudocode from "./sudocode.png";
|
||||
import _teamorouter from "./TeamoRouter-icon-dark.png";
|
||||
import _unity2 from "./unity2.png";
|
||||
import _zetaapi from "./zetaapi-icon.png";
|
||||
|
||||
export const icons: Record<string, string> = {
|
||||
aicodemirror: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 1017.97 1056.47"><title>AICodeMirror</title><path fill="#E4906E" fill-rule="nonzero" d="M944.92 1014.53c-17.29,-9.23 -33.98,-19.28 -50.08,-30.16 -5.39,-3.65 -14.99,-11.7 -28.81,-24.16 -6.06,-5.47 -14.07,-13.51 -24.03,-24.13 -15.15,-16.17 -29.61,-29.9 -41.69,-40.4 -3.98,-3.46 -14.2,-11.02 -30.68,-22.69 -6.24,-4.42 -12.88,-12.15 -18.63,-19.09 -23.98,-29.04 -49.53,-58.44 -76.66,-88.19 -11.93,-13.1 -25.64,-26.11 -35.61,-36.5 -28.72,-29.92 -51.92,-51.96 -78.23,-79.66 -11.24,-11.83 -20.52,-21.3 -27.85,-28.41 -0.56,-0.53 -1.3,-0.84 -2.08,-0.84 -0.51,0 -1.02,0.14 -1.47,0.39 -9.76,5.54 -17.53,14.33 -24.91,23.31 -10.71,13.01 -21.86,26.65 -33.44,40.91 -4.37,5.38 -7.9,9.46 -10.56,12.24 -5.43,5.67 -9.83,10.88 -15.08,15.39 -9.57,8.23 -19.57,16.01 -29.98,23.31 -14.73,10.32 -29.07,20.29 -43.04,29.9 -5.22,3.61 -13.68,9.81 -20.14,15.41 -25.71,22.32 -53.59,46.12 -83.65,71.41 -9.46,7.95 -19.65,16.88 -34.02,29.22 -25.66,22.03 -52.94,40.06 -81.67,55.65 -7.71,4.19 -14.15,8.2 -19.32,12.01 -19.3,14.23 -33.84,25.65 -43.62,34.28 -25.99,22.91 -43.04,37.82 -51.16,44.73 -9.19,7.8 -18.6,17.05 -28.42,25.54 -2.71,2.35 -5.7,3.03 -8.96,2.01 -0.78,-0.24 -1.25,-1.02 -1.1,-1.82 0.36,-2 1.19,-4.1 2.47,-6.32 6.86,-11.81 14.46,-23.09 19.95,-36.03 3.48,-8.23 7.87,-16.52 13.18,-24.89 2.03,-3.19 4.73,-8.77 8.11,-16.74 2.98,-7.02 7.34,-15.05 13.07,-24.12 5.79,-9.14 16,-23.36 30.63,-42.67 7.66,-10.11 19.49,-23.6 35.49,-40.47 4.9,-5.16 12.21,-11.87 21.92,-20.14 12.12,-10.31 23.53,-21.19 34.23,-32.65 11.73,-12.54 16.99,-22.33 27.39,-40.8 2.37,-4.19 6.49,-9.43 12.37,-15.71 8.27,-8.82 17,-17.23 26.21,-25.23 30.11,-26.18 55.17,-47.43 75.17,-63.76 8.66,-7.08 26.42,-21.39 39.65,-30.77 17.11,-12.13 28.62,-20.44 34.53,-24.91 4.5,-3.4 8.93,-6.6 13.3,-10.56 26.03,-23.54 51.66,-45.71 77.28,-70.7 0.42,-0.41 0.51,-1.06 0.21,-1.58 -6.8,-11.78 -12.84,-21.8 -18.11,-30.06 -10.22,-15.99 -22.07,-29.65 -35.57,-40.99 -7.56,-6.36 -18.41,-13.85 -28.65,-20.43 -15.08,-9.66 -30.62,-21.97 -46.63,-36.9 -35.08,-32.73 -67.65,-71.22 -85.32,-115.42 -5.53,-13.85 -10.8,-29.31 -15.8,-46.37 -5.89,-20.13 -12.37,-35.63 -23.22,-51.27 -8.93,-12.9 -15.77,-21.94 -19.58,-35.93 -1.27,-4.67 -2.93,-12.75 -4.99,-24.23 -2.07,-11.54 -6.54,-22.62 -13.41,-33.25 -7.54,-11.68 -13.66,-21.04 -18.33,-28.08 -3.68,-5.53 -7.02,-12.39 -9.63,-18.53 -3.9,-9.18 -8.14,-15.7 -13.6,-23.37 -3.94,-5.53 -5.07,-12.75 0,-18.32 4.14,-4.57 17.49,-3.02 21.56,-1.13 3.86,1.81 8.1,5.13 12.71,9.94 16.16,16.88 26.41,27.77 30.74,32.66 4.69,5.31 11.21,13.79 16.69,19.94 20.19,22.63 36.17,39.74 47.36,59.71 10.46,18.66 16.41,30.42 29.84,44.67 9.32,9.92 17.94,19.33 25.85,28.23 9.01,10.15 19.25,22.95 30.72,38.39 7.54,10.17 13.89,20.11 19.05,29.84 6.39,12.05 10.8,30.19 15.13,41.41 4.88,12.67 12.52,23.25 22.92,31.75 0.58,0.47 6.79,5.44 18.62,14.89 13.54,10.82 23.74,23.47 30.61,37.96 4.55,9.58 7.82,16.16 9.8,19.74 6.62,11.85 14.64,22.05 24.07,30.59 8.99,8.14 17.47,13.2 31.06,22.64 4.28,2.96 6.68,5.98 10.65,2.54 7.08,-6.11 13.73,-10.71 17.96,-14.53 6.12,-5.54 11.71,-11.84 16.79,-18.92 3.5,-4.88 8.77,-10.16 15.19,-14.42 22.77,-15.02 38.17,-31.11 63.32,-55.15 22.13,-21.17 46.22,-47.56 69.25,-66.8 32.17,-26.89 54.99,-45.9 68.46,-57.01 17.15,-14.15 35.82,-30.97 56.02,-50.46 16.06,-15.5 29.25,-27.72 39.57,-36.66 9.78,-8.47 17.55,-14.8 23.31,-18.98 8.52,-6.2 18.55,-10.61 30.56,-15.03 12.34,-4.55 23.44,-11.06 35.67,-17.61 9.07,-4.85 19.76,-9.89 30.05,-8.84 0.5,0.06 0.97,0.32 1.3,0.72 0.85,1.07 1.01,2.48 0.48,4.23 -2.1,6.99 -5.15,13.55 -9.66,20.87 -6.42,10.42 -11.51,19.46 -15.29,27.11 -6.09,12.35 -9.66,19.49 -10.69,21.43 -7.78,14.65 -17.56,27.97 -29.34,39.97 -4.8,4.89 -12.93,12.92 -24.37,24.07 -14.23,13.87 -25.02,30.77 -37.12,50.78 -15.21,25.13 -29.56,48.47 -43.06,70.01 -5.21,8.29 -13.68,15.13 -21.58,21.47 -25.71,20.7 -46.75,41.48 -70.98,64.67 -1.97,1.88 -4.98,4.47 -9.03,7.76 -22.62,18.36 -45.88,35.93 -69.78,52.74 -5.96,4.2 -13.77,11.24 -23.42,21.11 -17.12,17.5 -25.93,26.56 -26.42,27.19 -1.22,1.54 -1.08,3.09 0.41,4.67 14.11,14.81 34.25,37.65 60.42,68.51 11.89,14.01 24.87,27.08 36.03,39.46 8.75,9.7 16.81,22.11 31.82,42.59 2.69,3.68 13.53,16.07 32.5,37.17 5.17,5.76 11.64,14.47 19.4,26.12 16.37,24.6 36.28,56.2 59.73,94.79 4.2,6.92 7.74,12.33 10.62,16.21 5.41,7.29 10.37,13.74 14.92,20.97 6.26,9.94 11.3,19.92 15.11,29.92 4.29,11.27 7.73,19.49 10.32,24.69 7.21,14.5 14.81,28.41 22.8,41.73 3.44,5.75 6.78,13.03 6.11,20.05 -0.07,0.76 -0.71,1.34 -1.48,1.34 -0.25,0 -0.49,-0.06 -0.71,-0.18l0 0.01z"/></svg>`,
|
||||
@@ -104,15 +109,20 @@ export const iconUrls: Record<string, string> = {
|
||||
cherryin: _cherryin,
|
||||
eflowcode: _eflowcode,
|
||||
etok: _etok,
|
||||
fenno: _fenno,
|
||||
hermes: _hermes,
|
||||
huoshan: _huoshan,
|
||||
pateway: _pateway,
|
||||
pipellm: _pipellm,
|
||||
qiniu: _qiniu,
|
||||
relaxcode: _relaxcode,
|
||||
runapi: _runapi,
|
||||
shengsuanyun: _shengsuanyun,
|
||||
subrouter: _subrouter,
|
||||
sudocode: _sudocode,
|
||||
teamorouter: _teamorouter,
|
||||
unity2: _unity2,
|
||||
zetaapi: _zetaapi,
|
||||
};
|
||||
|
||||
export const iconList = [
|
||||
|
||||
@@ -140,6 +140,22 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["ccsub", "aggregator", "relay", "claude", "codex", "gateway"],
|
||||
defaultColor: "#1E88E5",
|
||||
},
|
||||
subrouter: {
|
||||
name: "subrouter",
|
||||
displayName: "SubRouter",
|
||||
category: "ai-provider",
|
||||
keywords: [
|
||||
"subrouter",
|
||||
"subrouter.ai",
|
||||
"aggregator",
|
||||
"relay",
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"gateway",
|
||||
],
|
||||
defaultColor: "#0D9488",
|
||||
},
|
||||
chatglm: {
|
||||
name: "chatglm",
|
||||
displayName: "chatglm",
|
||||
@@ -209,6 +225,52 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["etok", "ai", "programming"],
|
||||
defaultColor: "#F97316",
|
||||
},
|
||||
fenno: {
|
||||
name: "fenno",
|
||||
displayName: "FennoAI",
|
||||
category: "ai-provider",
|
||||
keywords: [
|
||||
"fenno",
|
||||
"fennoai",
|
||||
"aggregator",
|
||||
"relay",
|
||||
"claude",
|
||||
"codex",
|
||||
"gpt",
|
||||
"gateway",
|
||||
],
|
||||
defaultColor: "#000000",
|
||||
},
|
||||
zetaapi: {
|
||||
name: "zetaapi",
|
||||
displayName: "ZetaAPI",
|
||||
category: "ai-provider",
|
||||
keywords: [
|
||||
"zetaapi",
|
||||
"zeta",
|
||||
"aggregator",
|
||||
"relay",
|
||||
"claude",
|
||||
"gpt",
|
||||
"gateway",
|
||||
],
|
||||
defaultColor: "#000000",
|
||||
},
|
||||
teamorouter: {
|
||||
name: "teamorouter",
|
||||
displayName: "TeamoRouter",
|
||||
category: "ai-provider",
|
||||
keywords: [
|
||||
"teamorouter",
|
||||
"teamo",
|
||||
"router",
|
||||
"aggregator",
|
||||
"relay",
|
||||
"gateway",
|
||||
"gpt",
|
||||
],
|
||||
defaultColor: "#000000",
|
||||
},
|
||||
cubence: {
|
||||
name: "cubence",
|
||||
displayName: "Cubence",
|
||||
@@ -495,6 +557,24 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["pipellm", "pipe"],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
qiniu: {
|
||||
name: "qiniu",
|
||||
displayName: "七牛云",
|
||||
category: "ai-provider",
|
||||
keywords: [
|
||||
"qiniu",
|
||||
"qnaigc",
|
||||
"modelink",
|
||||
"aggregator",
|
||||
"relay",
|
||||
"claude",
|
||||
"codex",
|
||||
"gpt",
|
||||
"gemini",
|
||||
"gateway",
|
||||
],
|
||||
defaultColor: "#00AAE7",
|
||||
},
|
||||
runapi: {
|
||||
name: "runapi",
|
||||
displayName: "RunAPI",
|
||||
|
||||
|
After Width: | Height: | Size: 69 KiB |