mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7ba3cf51d | |||
| 85295cf23f | |||
| 1f25411524 | |||
| 6495da03b7 | |||
| 95b21ff27e | |||
| ae1fd2b662 | |||
| f4a9021461 | |||
| 2274da0b8e | |||
| 7ac822d241 | |||
| 4dd11ab427 | |||
| 8e9452b7ec | |||
| 38709c94db | |||
| db92f2b3b0 | |||
| eb1cd17ab2 | |||
| 29a28d2a74 | |||
| 1227e29d8b | |||
| 0be75668cc | |||
| 111ddf8d73 | |||
| 08727528aa | |||
| c8d91298c7 | |||
| 21a1518f9f | |||
| a9461ad52f | |||
| f57edfd697 | |||
| abb305a82f | |||
| 3f4739365e | |||
| e10a300c80 | |||
| 185ac2be9b | |||
| b8a3534cb5 | |||
| 31fb998575 | |||
| acc6d795e4 | |||
| 088b47b08a | |||
| 041f74db18 | |||
| ce5c3e5c6a | |||
| 3255b17185 | |||
| b4e29be8a0 | |||
| 8d6353699c | |||
| 828ec2ce07 | |||
| 5056978d80 | |||
| 497a543c1b | |||
| f935bac633 | |||
| 63aa310576 | |||
| 615c430dd3 | |||
| 5d3cd7eb85 | |||
| 1684cb3233 | |||
| 83c3c3b494 | |||
| 50431b7ec9 | |||
| 8b65a31c7c | |||
| d03e6f9951 | |||
| 0ca36b9d51 | |||
| e8953c286f | |||
| 240969d8c7 | |||
| a0b585992a | |||
| 576ff53a75 | |||
| 6d0e9f4c74 | |||
| a2e9e1938b | |||
| 81af0a57f9 | |||
| 701e7d9581 | |||
| e4c34b34e7 | |||
| eab0a69d2c | |||
| 4e790ac059 | |||
| c5b15dd25e | |||
| cc77a040e2 | |||
| 1b345fbefb | |||
| 61bfc29d82 | |||
| 2c9252dec5 | |||
| 34349a2743 | |||
| 8ba92b5470 | |||
| 87635e7fc6 | |||
| 9871d3d1eb | |||
| 1126c7459d | |||
| 03a0f9661b | |||
| de23216e49 | |||
| 507bf038a9 | |||
| ef41e4da46 | |||
| 78198e262b | |||
| 79eb773195 | |||
| 6092a87b40 | |||
| 689ca08409 | |||
| 8b851dc602 | |||
| 0383c13e66 | |||
| 7ae89e9106 | |||
| 04508801ef | |||
| 57343432da | |||
| 5e412d2c30 | |||
| 36f2d6cccb | |||
| 989c445828 | |||
| 7ec92c32a5 | |||
| 4a0b5c3dec | |||
| 449a171238 | |||
| c60f204808 | |||
| d13a8d7353 | |||
| 0739b60341 | |||
| c01338ac33 | |||
| 8c94c23c3c | |||
| d6fa611833 | |||
| 5c1b457520 | |||
| 3c8f9d1287 | |||
| 420f4c8c23 | |||
| ed269cc20e | |||
| 8669b408e9 | |||
| bb7c83c214 | |||
| a514f27937 | |||
| 2937eb6766 | |||
| 313a6e3f6c | |||
| 5566be2b4b | |||
| cfcf9452d0 | |||
| df01755328 | |||
| 74b9f52d90 | |||
| 8838317087 | |||
| 4d570691e1 | |||
| 337224546c | |||
| 7d21663e6d | |||
| 926e69b8c9 | |||
| a03ad4f47f | |||
| c38bef517a | |||
| 7526a031a7 | |||
| 84bc98cf83 | |||
| af679cda25 | |||
| a83bd90fa9 | |||
| 794795cad4 | |||
| eff85dc66d | |||
| 3aef5217cb | |||
| e4b58c7206 | |||
| 8c32610a7d | |||
| afdda27bd5 | |||
| 7f1963ab49 | |||
| 68df60bd4a |
+121
-2
@@ -5,7 +5,103 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
## [3.14.0] - 2026-04-21
|
||||
|
||||
Development since v3.13.0 focuses on onboarding Hermes Agent as a first-class managed app, rolling out Claude Opus 4.7 across the preset matrix, adding a Gemini Native API proxy, and sharpening session, usage, and proxy workflows.
|
||||
|
||||
**Stats**: 100 commits | 219 files changed | +20,548 insertions | -3,569 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Hermes Agent Support (6th Managed App)**: Added Hermes Agent as a first-class managed app with database migration v9→v10, full Rust command surface, YAML-backed `~/.hermes/config.yaml` read/write with atomic backups, MCP sync, Skills sync, session manager with SQLite + JSONL support, and dedicated frontend panels. Supports four API protocols (`chat_completions`, `anthropic_messages`, `codex_responses`, `bedrock_converse`) aligned with Hermes Agent 0.10.0 schema. Read-only rendering for providers owned by the user-authored `providers:` dict, with deep configuration delegated to the Hermes Web UI.
|
||||
- **Hermes Memory Panel**: Added a Memory panel for editing `MEMORY.md` and `USER.md` directly from CC Switch, with an enable switch, character-count limits, and a live save flow. Replaces the Prompts entry for Hermes.
|
||||
- **Hermes Provider Presets**: Added ~50 Hermes provider presets spanning Nous Research, Shengsuanyun, OpenRouter, DeepSeek, Together AI, StepFun, Zhipu GLM, Bailian, Kimi, MiniMax, DouBao, BaiLing, ModelScope, KAT-Coder, PackyCode, Cubence, AIGoCode, RightCode, AICodeMirror, AICoding, CrazyRouter, SSSAiCode, Micu, CTok.ai, DDSHub, E-FlowCode, LionCCAPI, PIPELLM, Compshare, SiliconFlow, AiHubMix, DMXAPI, TheRouter, Novita, Nvidia, and Xiaomi MiMo.
|
||||
- **Claude Opus 4.7 Support**: Added Claude Opus 4.7 with adaptive thinking whitelisting, per-million pricing seed, and Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix). Migrated all aggregator and Bedrock presets to Opus 4.7 as the default Opus model.
|
||||
- **Claude `max` Effort Tier**: Upgraded the Claude effort dropdown from `high` to `max` for extended reasoning capacity.
|
||||
- **Gemini Native API Proxy**: Added `api_format = "gemini_native"` so the proxy can forward to Google's `generateContent` API with full streaming, schema conversion, and shadow request support. Adds `gemini_url.rs`, `gemini_schema.rs`, `gemini_shadow.rs`, `streaming_gemini.rs`, and `transform_gemini.rs` under the proxy providers module.
|
||||
- **GitHub Copilot Enterprise Server**: Added GHES authentication and endpoint configuration for Copilot-backed Claude providers, plus thinking-block stripping before upstream to preserve premium interaction quota.
|
||||
- **Session List Virtualization**: Virtualized the session list via `@tanstack/react-virtual` so long conversations (thousands of records) scroll smoothly; long session messages are now collapsed by default to reduce text layout cost.
|
||||
- **Codex / OpenClaw Session Title Extraction**: Added meaningful title auto-extraction for Codex and OpenClaw sessions with 2-line display; strips OpenClaw `message_id` suffix noise.
|
||||
- **Usage Date Range Picker**: Added a date range selector to the usage dashboard with preset tabs (Today / 1d / 7d / 14d / 30d), a custom date + time calendar picker, and a page-jump input on paginated lists.
|
||||
- **Model Mapping Quick-Set**: Added a quick-set button next to model mapping fields in provider forms for faster edits.
|
||||
- **Stream Check Error Classification**: Classified Stream Check errors and surfaced them as color-coded toasts; refreshed default probe models and added explicit detection for "model not found" responses.
|
||||
- **Block Official Provider Switching During Local Routing**: Blocks switching to official providers while Local Routing is active, since routing official API traffic through the local proxy carries account-suspension risk. A warning toast surfaces the block.
|
||||
- **Pricing Database Refresh (v8 → v9)**: Added ~50 new model pricing entries and corrected stale prices via a reseed-on-migration step, including Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, and GLM-5/5.1. DeepSeek and Kimi K2.5 prices updated.
|
||||
- **Application-Level Window Controls**: Added an opt-in setting to render CC Switch's own minimize / toggle-maximize / close buttons instead of the system decorations, materially improving the experience on Linux Wayland where compositor-drawn buttons can become inert.
|
||||
- **Hermes in Unified Skills Management**: Added Hermes to the unified Skills surface; skill install, enable, and filter now cover the Hermes app alongside Claude / Codex / Gemini / OpenCode / OpenClaw.
|
||||
- **OpenClaw Config Directory Override**: Added a settings option to point CC Switch at a custom `openclaw.json` location.
|
||||
- **Hermes Config Directory Override**: Added a settings option to point CC Switch at a custom `~/.hermes/config.yaml` location, backed by data-driven dispatch.
|
||||
- **StepFun Step Plan Preset**: Added StepFun Step Plan (EN/ZH) provider presets.
|
||||
- **New API Usage Script Template**: Added a User-Agent header to the New API usage script template for better upstream compatibility.
|
||||
- **Launch Hermes Dashboard from Toolbar**: When the Hermes Web UI probe fails, the toolbar entry now offers to run `hermes dashboard` in the user's preferred terminal via a temp bash/batch script. `hermes dashboard` opens the browser itself once ready, so no polling is required. Also corrects the stale `hermes web` hint in the offline toast (the real command is `hermes dashboard`) and reorders Linux terminal detection to try `which` before stat'ing `/usr/bin`, `/bin`, `/usr/local/bin`.
|
||||
- **LemonData Provider Preset (All Six Apps)**: Registered LemonData as a third-party partner preset across Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes, with icon assets and zh/en/ja partner-promotion copy. Claude uses `ANTHROPIC_API_KEY` auth; OpenAI-compatible apps target `gpt-5.4`.
|
||||
- **DDSHub Codex Preset**: Added a Codex-compatible endpoint for DDSHub at the same host as its Claude service; base URL omits the `/v1` suffix because the gateway auto-routes OpenAI SDK paths.
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Local Proxy Takeover" → "Local Routing"**: Unified terminology across UI copy, README, and docs in all three locales. Functional behavior is unchanged.
|
||||
- **Hermes `Auto` api_mode Removed**: Users must now pick an explicit protocol; new deeplinks default to `chat_completions`. Eliminates URL-based heuristic surprises.
|
||||
- **Hermes Provider Form**: Added an API mode dropdown and per-provider model editor; bound per-provider models to the top-level `model:` when switching active providers.
|
||||
- **Hermes Deep Config Delegation**: Deep YAML knobs are now delegated to the Hermes Web UI via a direct launch action, rather than duplicated in the CC Switch form.
|
||||
- **`ANTHROPIC_REASONING_MODEL` Removed from Claude Quick-Set**: Decoupled the reasoning capability from model selection; the legacy field is no longer surfaced in the quick-set form.
|
||||
- **Per-Provider Proxy Config Removed**: Consolidated into global Local Routing; the provider-level proxy toggle and associated storage are gone.
|
||||
- **Unified Toolbar Icon Button Width**: Normalized icon-button widths across Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes panels for a consistent header look.
|
||||
- **Rust Toolchain Pinned to 1.95**: Adopted clippy 1.95 suggestions across the workspace and pinned the toolchain to prevent nightly drift.
|
||||
- **Tray Menu ID Constant**: The tray identifier moved from the hardcoded string `"main"` to a `TRAY_ID` constant (`"cc-switch"`) across all call sites.
|
||||
- **Copilot Request Classification**: Refined request routing inside the Copilot optimizer to further reduce unnecessary premium interaction consumption.
|
||||
- **Usage Script Intranet Support**: Removed private-IP / suspicious-hostname blocking from usage scripts, unblocking enterprise intranet, Docker, and self-hosted API endpoints. Built-in templates still enforce HTTPS (except localhost) and same-origin checks; custom templates remain user-controlled with those request-URL checks skipped.
|
||||
- **Failover Queue Notes**: Provider notes now appear in failover queue selectors and queue rows for easier identification across multi-provider queues.
|
||||
- **Hermes Toolbar Layout**: Swapped the Hermes Web UI button from `ExternalLink` to `LayoutDashboard` (clicking may spawn `hermes dashboard` rather than just opening a URL), and moved MCP to the final toolbar slot so Hermes matches the Claude / Codex / Gemini / OpenCode layout.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Header Auto-Compact Latching After Maximize**: The toolbar no longer stays compacted after maximize/restore; compaction now reevaluates on size changes.
|
||||
- **Hermes YAML Pollution & OAuth MCP Auth Drop**: Round-tripping through CC Switch no longer drops OAuth MCP `auth` blocks or pollutes unrelated YAML keys; guard tests added via `tests/hermes_roundtrip.rs`.
|
||||
- **Hermes Active Provider Display**: Hermes UI now correctly surfaces the active provider and wires add / enable / remove actions.
|
||||
- **Hermes Provider Persistence**: Providers persist under `custom_providers:` so `api_mode` and `model` survive restarts and config reloads.
|
||||
- **Codex `cache_control` Preservation**: Preserve `cache_control` when merging system prompts during Codex format conversion (#1946).
|
||||
- **Claude Prompt Cache Key Leak**: Stopped sending prompt cache keys during Claude chat conversions (#2003).
|
||||
- **Proxy Hop-by-Hop Header Stripping**: Strip hop-by-hop response headers (Connection, Keep-Alive, Transfer-Encoding, etc.) per RFC 7230.
|
||||
- **Permissive Proxy CORS Removed**: Removed the permissive CORS layer from the proxy (#1915).
|
||||
- **Copilot Premium Consumption**: Further reduced unnecessary Copilot premium interaction consumption during pass-through traffic.
|
||||
- **Backend Error Details in Proxy Toast**: Surface backend error payload details in proxy-related toast messages instead of a generic failure string.
|
||||
- **Usage Log Deduplication**: Deduplicated proxy and session-log usage records so the same request is no longer double-counted; synced the request log time range with the dashboard's 1d / 7d / 30d selector.
|
||||
- **Common Config Checkbox Persistence**: Checkbox state for Claude / Codex / Gemini common-config toggles now persists correctly across reopens.
|
||||
- **Claude Plugin `settings.json` Sync**: Editing the current provider now syncs back to `settings.json` for the Claude plugin path.
|
||||
- **Google Official Gemini Env Preservation**: Saving the Google Official Gemini provider no longer clobbers the `env` block.
|
||||
- **OpenCode JSON5 Parser for Trailing Commas**: OpenCode config reads now tolerate trailing commas via a JSON5 parser.
|
||||
- **Preset Refreshes**: Refreshed stale context windows for DeepSeek and Claude 1M; refreshed stale model IDs; backfilled Hermes model lists; fixed the Nous endpoint and replaced the Hermes placeholder icon with Nous brand artwork; pruned unused official Hermes presets.
|
||||
- **Auto-Expand Collapsed Messages on Search Hit**: Collapsed messages now auto-expand when a search match lands inside hidden content.
|
||||
- **Unknown Subscription Quota Tiers Hidden**: Provider cards no longer render unknown subscription quota tiers.
|
||||
- **Weekly Limit Label Unified**: Aligned the weekly_limit tier label with the official 7-day naming across locales.
|
||||
- **Root-Level Skill Repo Install**: Fixed skill installation when the repository root itself is a skill.
|
||||
- **Session ID Parsing Clippy**: Removed a redundant closure in session ID parsing (clippy warning).
|
||||
- **Usage Log Stat Dedup**: Deduplicated proxy-sourced and session-log-sourced usage records for accurate totals.
|
||||
- **Stream Check Default Models Refresh**: Updated stream-check default probe models to match each vendor's current lineup.
|
||||
- **Skills Import Sync**: Imported Skills are now immediately synced into enabled app directories instead of only being recorded in the database, so the UI no longer shows "installed" while the target app directory is missing the skill.
|
||||
- **Ghostty Session Restore**: Fixed Ghostty session restore launch by using shell execution with `--working-directory`, avoiding `cwd` escaping issues when the path contains spaces or special characters.
|
||||
- **Hermes Health Check Borrowing OpenClaw Schema**: Hermes providers were routed through `check_additive_app_stream` (the OpenClaw dispatcher), which reads camelCase `baseUrl` / `apiKey` / `api` and surfaced "OpenClaw provider is missing baseUrl" even when every Hermes field was filled. Introduced `check_hermes_stream` with Hermes-specific extractors that map `api_mode` (`chat_completions` / `anthropic_messages` / `codex_responses`) to the matching `check_claude_stream` `api_format`, and returns `bedrock_converse` as unsupported. `api_mode` is now resolved before URL / API key extraction, so `bedrock_converse` users see the real cause rather than a misleading "missing base_url".
|
||||
- **Usage Query Modal for Hermes & OpenClaw**: `getProviderCredentials` now reads flat `settingsConfig` fields for Hermes (snake_case `base_url` / `api_key`) and OpenClaw (camelCase `baseUrl` / `apiKey`), so the "official balance" template auto-selects for matching providers like SiliconFlow. Also refactored the BALANCE and TOKEN_PLAN test paths to reuse the precomputed `providerCredentials` instead of re-reading `env.ANTHROPIC_*` directly, fixing the "empty key" error for non-Claude apps even when the key was configured.
|
||||
|
||||
### Docs
|
||||
|
||||
- **README Sponsor Updates**: Updated SiliconFlow signup bonus to ¥16, trimmed the SSSAiCode sponsor blurb, updated partner logos, and added LemonData as a new sponsor.
|
||||
- **Global Proxy Hint Clarified**: Clarified the global proxy hint about local routing across all three locales.
|
||||
- **Takeover → Routing Rename**: Renamed takeover docs to routing and updated anchors across all languages.
|
||||
- **PIPELLM Website URL**: Updated the PIPELLM sponsor website URL to `code.pipellm.ai`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **Hermes requires explicit `api_mode`**: The `Auto` mode is gone; imported or deeplinked providers default to `chat_completions`. Users with prior `Auto` configs will be prompted to pick a protocol.
|
||||
- **`ANTHROPIC_REASONING_MODEL` removed from Claude quick-set**: The legacy field is no longer exposed; existing settings are cleaned up automatically.
|
||||
- **Per-provider proxy configuration removed**: Migrate to the global Local Routing setting. Existing per-provider proxy values are ignored.
|
||||
- **Database schema bumped v9 → v10**: Adds `enabled_hermes` columns to `mcp_servers` and `skills` (auto-migrated with `DEFAULT 0`; no data loss).
|
||||
- **Pricing table reseeded (v8 → v9)**: The `model_pricing` table is cleared and reseeded on first launch to pick up new models and corrected prices.
|
||||
- **XCodeAPI preset removed**: Users of the XCodeAPI preset should switch to another provider.
|
||||
|
||||
---
|
||||
|
||||
## [3.13.0] - 2026-04-10
|
||||
|
||||
Development since v3.12.3 focuses on quota visibility, provider workflow upgrades, stronger proxy compatibility, and lower-overhead tray / session workflows.
|
||||
|
||||
@@ -13,7 +109,7 @@ Development since v3.12.3 focuses on quota visibility, provider workflow upgrade
|
||||
|
||||
- **Lightweight Mode**: Added a tray-only mode that destroys the main window and keeps CC Switch running from the system tray, with the window recreated when users reopen it.
|
||||
- **Provider Model Auto-Fetch**: Added OpenAI-compatible `/v1/models` discovery for Claude, Codex, Gemini, OpenCode, and OpenClaw provider forms, including grouped dropdown selection and failure-specific error messages.
|
||||
- **Quota & Balance Visibility**: Added inline quota or balance display for official Claude / Codex / Gemini providers, GitHub Copilot premium interactions, Codex OAuth providers, Token Plan providers (Kimi / Zhipu GLM / MiniMax), and official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI.
|
||||
- **Quota & Balance Visibility**: Added inline quota or balance display for official Claude / Codex / Gemini providers, GitHub Copilot premium interactions, Codex OAuth providers, Token Plan providers (Kimi / Zhipu GLM / MiniMax), and official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI. Copilot / ChatGPT OAuth and CLI subscription quota now only auto-poll for the currently active provider, preventing unnecessary API calls and misleading displays on non-current cards.
|
||||
- **Skills Discovery & Batch Updates**: Added SHA-256 based skill update detection, per-skill and batch update actions, a storage-location toggle between CC Switch and `~/.agents/skills`, and public `skills.sh` search integration.
|
||||
- **Session Workflow Upgrades**: Added batch delete in Session Manager, a directory picker before launching Claude terminal restore commands, usage import from Claude / Codex / Gemini session logs without requiring proxy interception, and per-app usage filtering for Claude / Codex / Gemini dashboards.
|
||||
- **Codex OAuth Reverse Proxy**: Added ChatGPT Plus / Pro based Codex OAuth reverse proxy support for Claude provider cards, including managed OAuth login and inline subscription quota display.
|
||||
@@ -21,6 +117,22 @@ Development since v3.12.3 focuses on quota visibility, provider workflow upgrade
|
||||
- **Full URL Endpoint Mode**: Added a provider option that treats `base_url` as a complete upstream endpoint so proxy forwarding and stream checks can work with vendors that require nonstandard URL layouts.
|
||||
- **OpenCode StepFun Step Plan Preset**: Added a StepFun Step Plan provider preset for OpenCode.
|
||||
- **Copilot Interaction Optimizer**: Added request classification and routing logic to reduce unnecessary GitHub Copilot premium interaction consumption.
|
||||
- **First-Run Welcome Dialog**: Added a one-time welcome dialog on fresh installs explaining how existing configuration is preserved as a default provider and how the bundled official preset enables one-click revert. Upgrade users are excluded.
|
||||
- **Official Provider Seeding**: Added automatic seeding of Claude Official, OpenAI Official, and Google Official provider entries on startup, giving every user a one-click path back to the official endpoint.
|
||||
- **OpenCode / OpenClaw Auto-Import**: Added automatic startup import of live OpenCode and OpenClaw provider configurations, matching the auto-import behavior already present for Claude, Codex, and Gemini.
|
||||
- **Common Config Editor Guidance**: Added an informational guide and empty-state prompt to the Common Config snippet editor modal for Claude, Codex, and Gemini, with i18n support.
|
||||
- **Common Config First-Run Notice**: Added a one-time informational dialog explaining Common Config Snippets when users first open the provider add/edit form.
|
||||
- **Claude Session Titles**: Added meaningful title extraction for Claude sessions using a priority chain: custom-title metadata, first real user message, then directory basename fallback.
|
||||
- **Session Search Highlighting**: Added keyword highlighting in session titles and messages during Session Manager search.
|
||||
- **URL-Based Provider Icons**: Added a dual rendering mode to the icon system supporting Vite URL imports for large SVGs and raster images (PNG, JPG, WebP), keeping small SVGs inlined.
|
||||
- **Kaku Terminal Support**: Added Kaku as a selectable terminal for session launch on macOS, reusing the WezTerm-compatible launch path.
|
||||
- **OMO Slim Council Support**: Restored first-class council support as a built-in oh-my-opencode-slim agent with updated metadata and UI copy.
|
||||
- **TheRouter Provider Preset**: Added TheRouter provider presets across Claude, Codex, Gemini, OpenCode, and OpenClaw.
|
||||
- **DDSHub Provider Preset**: Added DDSHub as a third-party partner provider for Claude with icon and partner promotion text.
|
||||
- **LionCCAPI Provider Preset**: Added LionCCAPI as a third-party partner provider across all five apps with anthropic-messages protocol for OpenCode and OpenClaw.
|
||||
- **Shengsuanyun Provider Preset**: Added Shengsuanyun (胜算云) as an aggregator partner provider across all five apps with URL-based icon and localized display name.
|
||||
- **PIPELLM Provider Preset**: Added PIPELLM provider preset across Claude, Codex, OpenCode, and OpenClaw with full model definitions and icon.
|
||||
- **E-FlowCode Provider Preset**: Added E-FlowCode provider preset across all five apps with per-app protocol configuration.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -47,12 +159,19 @@ Development since v3.12.3 focuses on quota visibility, provider workflow upgrade
|
||||
- **Linux UI Unresponsive on Startup**: Fixed a bug where the window UI (including native title bar buttons) couldn't receive clicks on Linux until the user manually maximized and restored the window. Root causes: (1) Tauri webview did not acquire keyboard focus after `show()` on Linux, so the first click was consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface's input region failed to renegotiate on the `visible:false → show()` path under some WebKitGTK/compositor combinations, leaving the entire window unresponsive. Mitigations: set `WEBKIT_DISABLE_COMPOSITING_MODE=1` at startup, and added a new `linux_fix::nudge_main_window` helper that performs `set_focus` + a ±1px no-op resize ~200ms after show, equivalent to a visually invisible "maximize-and-restore". Wired into all window-re-show paths (normal startup, deeplink, single_instance, tray `show_main`, lightweight exit).
|
||||
- **Linux Drag Region on Header**: Removed `data-tauri-drag-region` from the top header bar on Linux to avoid triggering `gtk_window_begin_move_drag` paths affected by Tauri #13440 under Wayland. macOS drag behavior is preserved.
|
||||
- **OpenCode / OpenClaw Stream Check Edge Cases**: Fixed custom-header passthrough, OpenClaw custom auth-header detection, Bedrock error messaging, and OpenCode default `baseURL` fallback handling in Stream Check.
|
||||
- **Duplicate Toast on Provider Switch**: Fixed double toast notifications (proxy-required warning followed by switch-success) when switching to Copilot, ChatGPT, or OpenAI-format providers with the proxy not running.
|
||||
- **Session Search Accuracy & Chinese Support**: Fixed session search result truncation across providers and switched FlexSearch tokenizer to full mode for proper Chinese substring matching.
|
||||
- **Adaptive Thinking Reasoning Effort**: Fixed `resolve_reasoning_effort()` mapping adaptive thinking to `xhigh` instead of incorrectly using `high` in OpenAI format conversions.
|
||||
- **Thinking Model Fallback Display**: Fixed the Claude provider form showing an empty Thinking model field after saving only a main model by applying read-only fallback to ANTHROPIC_MODEL.
|
||||
- **Auth Tab Localization**: Fixed missing i18n translation keys for the settings auth tab label across all locale bundles.
|
||||
- **Schema Migration Guard**: Fixed database migrations failing when skills or model_pricing tables did not exist by adding table-existence checks before ALTER and UPDATE operations.
|
||||
|
||||
### Docs
|
||||
|
||||
- **User Manual Refresh**: Updated the EN / ZH / JA manuals for tray submenus, lightweight mode, provider model fetching, session management, workspace files, WebDAV v2 behavior, OpenCode / OpenClaw activation, and other provider workflow improvements.
|
||||
- **Community & Contribution Docs**: Added `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, bilingual issue / PR templates, Dependabot config, and CI quality checks.
|
||||
- **Release Notes Risk Notice**: Added a Copilot reverse proxy risk notice and anchored highlight links in the v3.12.3 release notes across all three languages.
|
||||
- **Sponsor Partners**: Added Shengsuanyun, LionCC, and DDS as sponsor partners in README across all languages.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
|
||||
</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 ¥20 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://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -47,8 +47,8 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -72,24 +72,24 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
|
||||
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -98,8 +98,8 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
|
||||
<td>Thanks to XCodeAPI for sponsoring this project! XCodeAPI offers a special benefit for CC Switch users: register via <a href="https://x-code.cc/register?aff=IbPp">this link</a> and get an extra 10% credit bonus on your first order! (Contact the site admin to claim)</td>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
<td>Thanks to LemonData for sponsoring this project! LemonData is a high-performance AI API aggregation platform — one API key for 300+ models including GPT, Claude, Gemini, DeepSeek, and more. All models priced 30–70% below official rates with auto-failover, smart routing, and unlimited concurrency. New users get $1 free credit instantly upon registration — sign up via <a href="https://lemondata.cc/r/FFX1ZDUP">this link</a>to claim your bonus and start building right away</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
+13
-12
@@ -37,8 +37,8 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
|
||||
</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>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -47,8 +47,8 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -73,9 +73,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)</strong>点が特長です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>からご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
@@ -83,23 +80,27 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して出しています。特に高いコストパフォーマンスを誇る Codex の月刊プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)</strong>점이特长です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>からご登録くと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
|
||||
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.openclaudecode.cn/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
|
||||
<td>XCodeAPI のご支援に感謝します!CC Switch ユーザー向けの特別特典:<a href="https://x-code.cc/register?aff=IbPp">こちらのリンク</a>から登録すると、初回注文で 10% の追加クレジットボーナスがもらえます!(サイト管理者に連絡して受け取りください)</td>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
<td>LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — <a href="https://lemondata.cc/r/FFX1ZDUP">こちらのリンク</a>から登録してボーナスを獲得し、すぐに開発を始めましょう!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
+13
-14
@@ -37,8 +37,8 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
|
||||
</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>注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -47,8 +47,8 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -73,34 +73,33 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打<strong>极高性价比</strong>的Codex包月套餐,<strong>提供额度转结,套餐当天用不完的额度,第二天还能接着用!</strong>充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额25%的按量额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册即可获得 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打<strong>极高性价比</strong>的Codex包月套餐,<strong>提供额度转结,套餐当天用不完的额度,第二天还能接着用!</strong>充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额25%的按量额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
|
||||
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.openclaudecode.cn/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
|
||||
<td>感谢 XCodeAPI 赞助了本项目!XCodeAPI 为本软件的用户提供特别福利,使用<a href="https://x-code.cc/register?aff=IbPp">此链接</a>注册后首单加赠10%的额度!(联系站长领取)</td>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
<td>感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过<a href="https://lemondata.cc/r/FFX1ZDUP">此链接</a>注册即可领取奖励,立即开始开发!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -8,11 +8,11 @@
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.13.0 is a major feature release centered on observability, provider workflow ergonomics, and proxy compatibility. It adds inline **quota and balance displays** across official Claude / Codex / Gemini providers plus Token Plan, Copilot, and third-party balance APIs; introduces a **Lightweight Mode** that keeps CC Switch running from the system tray without a main window; delivers **automatic model discovery** via OpenAI-compatible `/v1/models` across all five supported applications; ships a **Codex OAuth reverse proxy** for ChatGPT Plus / Pro subscribers; reorganizes the tray menu into **per-app submenus**; rebuilds the proxy forwarding stack on a **Hyper-based client**; and overhauls the **Skills workflow** with discovery, batch updates, and storage-location toggling. Additional improvements include full URL endpoint mode, session-log usage tracking without proxy interception, the Copilot interaction optimizer, a UTF-8 streaming chunk boundary fix for multi-byte output, and a Linux startup UI responsiveness fix.
|
||||
CC Switch v3.13.0 is a major feature release centered on observability, provider workflow ergonomics, and proxy compatibility. It adds inline **quota and balance displays** across official Claude / Codex / Gemini providers plus Token Plan, Copilot, and third-party balance APIs; introduces a **Lightweight Mode** that keeps CC Switch running from the system tray without a main window; delivers **automatic model discovery** via OpenAI-compatible `/v1/models` across all five supported applications; ships a **Codex OAuth reverse proxy** for ChatGPT subscribers; reorganizes the tray menu into **per-app submenus**; rebuilds the proxy forwarding stack on a **Hyper-based client**; and overhauls the **Skills workflow** with discovery, batch updates, storage-location toggling, and built-in skills.sh search and install. Additional improvements include full URL endpoint mode, enhanced token usage tracking, the Copilot interaction optimizer, a UTF-8 streaming chunk boundary fix for multi-byte output, a Linux startup UI responsiveness fix, and a friendlier new-user onboarding experience.
|
||||
|
||||
**Release Date**: 2026-04-08
|
||||
**Release Date**: 2026-04-10
|
||||
|
||||
**Update Scale**: 98 commits | 229 files changed | +23,891 / -2,305 lines
|
||||
**Update Scale**: 139 commits | 280 files changed | +31,627 / -3,042 lines
|
||||
|
||||
---
|
||||
|
||||
@@ -21,9 +21,9 @@ CC Switch v3.13.0 is a major feature release centered on observability, provider
|
||||
- **Lightweight Mode**: Tray-only operating mode that destroys the main window on exit to tray and recreates it on demand, reducing CC Switch's desktop footprint to near zero when idle
|
||||
- **Quota & Balance Visibility**: Inline quota or balance readout across provider cards — official Claude / Codex / Gemini subscriptions, GitHub Copilot premium interactions, Codex OAuth, Token Plan providers (Kimi / Zhipu GLM / MiniMax), plus official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI
|
||||
- **Provider Model Auto-Fetch**: OpenAI-compatible `/v1/models` discovery across Claude, Codex, Gemini, OpenCode, and OpenClaw provider forms, with grouped dropdown selection and failure-specific error messages
|
||||
- **Codex OAuth Reverse Proxy**: ChatGPT Plus / Pro Codex reverse proxy exposed as a new Claude provider card with managed OAuth login and inline subscription quota display ([⚠️ Risk Notice](#️-risk-notice))
|
||||
- **Codex OAuth Reverse Proxy**: ChatGPT Codex reverse proxy exposed as a new Claude provider card type, allowing users to use their ChatGPT subscription in Claude Code. Includes managed OAuth login and inline subscription quota display ([⚠️ Risk Notice](#️-risk-notice))
|
||||
- **Tray Per-App Submenus**: Reworked the tray menu into per-application submenus so it never overflows the screen and background provider switching scales to dozens of providers per app
|
||||
- **Skills Discovery & Batch Updates**: SHA-256-based skill update detection, per-skill and "Update All" batch actions, public `skills.sh` search integration, and a storage-location toggle between CC Switch storage and `~/.agents/skills`
|
||||
- **Skills Discovery & Batch Updates**: SHA-256-based skill update detection, per-skill and "Update All" batch actions, `skills.sh` search integration, and a storage-location toggle between CC Switch storage and `~/.agents/skills`
|
||||
- **Session Workflow Upgrades**: Batch session deletion, a directory picker before launching Claude terminal restore, usage import from Claude / Codex / Gemini session logs without proxy interception, precise Codex JSONL parsing, and per-app usage filtering
|
||||
- **OpenCode / OpenClaw Stream Check Coverage**: OpenCode detection via npm package mapping, OpenClaw `openai-completions` support, and the remaining OpenClaw protocol variants — with custom-header passthrough and auth-header detection fixes
|
||||
- **Full URL Endpoint Mode**: Provider option that treats `base_url` as a complete upstream endpoint, unblocking vendors that require nonstandard URL layouts
|
||||
@@ -31,6 +31,10 @@ CC Switch v3.13.0 is a major feature release centered on observability, provider
|
||||
- **Copilot Interaction Optimizer**: Request classification and routing logic that reduces unnecessary GitHub Copilot premium interaction consumption
|
||||
- **UTF-8 Stream Chunk Boundary Fix**: All four SSE streaming paths now preserve incomplete multi-byte UTF-8 sequences across TCP chunks, eliminating intermittent U+FFFD garbled output via the Copilot reverse proxy
|
||||
- **Linux Startup UI Fix**: Fixed the long-standing issue where the window UI couldn't receive clicks on Linux until the user manually maximized and restored the window
|
||||
- **First-Run Onboarding**: One-time welcome dialog on fresh installs, automatic seeding of Claude / OpenAI / Google official presets, and auto-import of OpenCode / OpenClaw live configurations on startup
|
||||
- **Claude Session Titles & Search Highlighting**: Meaningful title extraction for Claude sessions using a priority chain (custom-title metadata → first user message → directory basename), plus keyword highlighting in Session Manager search results
|
||||
- **URL-Based Provider Icons**: Dual rendering mode supporting Vite URL imports for large SVGs and raster images (PNG, JPG, WebP), keeping small SVGs inlined
|
||||
- **New Provider Presets**: TheRouter, DDSHub, LionCCAPI, Shengsuanyun (胜算云), PIPELLM, and E-FlowCode across supported applications
|
||||
|
||||
---
|
||||
|
||||
@@ -50,9 +54,9 @@ Added inline quota and balance readouts to provider cards so users can see remai
|
||||
|
||||
- **Official subscriptions**: Inline quota display for Claude, Codex, and Gemini official providers
|
||||
- **GitHub Copilot**: Premium interactions quota display on the Copilot provider card
|
||||
- **Codex OAuth**: ChatGPT Plus / Pro subscription quota inline with the Codex OAuth provider card
|
||||
- **Token Plan providers**: Kimi, Zhipu GLM, and MiniMax with corrected quota math and consistent 0% → 100% usage progression
|
||||
- **Third-party balances**: Official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI
|
||||
- **Codex OAuth**: ChatGPT subscription quota inline with the Codex OAuth provider card
|
||||
- **Token Plan providers**: Kimi, Zhipu GLM, and MiniMax usage progression display (requires manual activation to avoid confusion)
|
||||
- **Third-party balances**: Official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI (requires manual activation to avoid confusion)
|
||||
- Health-check and usage-config buttons are hidden for official providers to keep the card clean
|
||||
|
||||
### Provider Model Auto-Fetch
|
||||
@@ -66,13 +70,12 @@ Added OpenAI-compatible model discovery to every provider form, removing the man
|
||||
|
||||
### Codex OAuth Reverse Proxy
|
||||
|
||||
Added a reverse proxy path for ChatGPT Plus / Pro subscribers who want to route their Codex OAuth session through CC Switch.
|
||||
Added a reverse proxy path for ChatGPT subscribers who want to use their ChatGPT subscription in Claude Code.
|
||||
|
||||
- Managed OAuth login flow with ChatGPT Plus / Pro authentication
|
||||
- Managed OAuth login flow with ChatGPT authentication
|
||||
- Surfaces as a new Claude provider card type alongside API-key providers
|
||||
- Inline subscription quota display
|
||||
- Integrated into the Auth Center with tightened copy, layout, and icon presentation
|
||||
- Bumped the Codex OAuth preset to the GPT-5.4 model family
|
||||
- Integrated into the Auth Center for unified token management
|
||||
- See the [⚠️ Risk Notice](#️-risk-notice) below before enabling
|
||||
|
||||
### Tray Per-App Submenus
|
||||
@@ -131,6 +134,54 @@ Added request classification and routing logic that reduces unnecessary GitHub C
|
||||
- Classifies incoming requests by intent and weight
|
||||
- Routes low-value requests away from premium interaction consumption paths
|
||||
- Designed to extend the usable lifetime of a Copilot subscription
|
||||
- Note: Even with optimized consumption, using the Copilot API outside of Copilot still consumes more than using it within Copilot.
|
||||
|
||||
### First-Run Welcome Dialog
|
||||
|
||||
Added a one-time welcome dialog on fresh installs to guide new users through the CC Switch workflow.
|
||||
|
||||
- Explains how existing live configuration is preserved as a default provider
|
||||
- Introduces the bundled official preset that enables one-click revert to official endpoints
|
||||
- Upgrade users are automatically excluded via empty provider check
|
||||
|
||||
### Official Provider Seeding
|
||||
|
||||
- Added automatic seeding of Claude Official, OpenAI Official, and Google Official provider entries on startup, giving every user a one-click path back to the official endpoint
|
||||
|
||||
### OpenCode / OpenClaw Auto-Import
|
||||
|
||||
- Added automatic startup import of live OpenCode and OpenClaw provider configurations, matching the auto-import behavior already present for Claude, Codex, and Gemini
|
||||
|
||||
### Common Config Editor Guidance
|
||||
|
||||
- Added an informational guide and empty-state prompt to the Common Config snippet editor modal for Claude, Codex, and Gemini
|
||||
- Added a one-time informational dialog explaining Common Config Snippets when users first open the provider add/edit form
|
||||
|
||||
### Claude Session Titles & Search Highlighting
|
||||
|
||||
- Added meaningful title extraction for Claude sessions using a priority chain: custom-title metadata, first real user message, then directory basename fallback
|
||||
- Added keyword highlighting in session titles and messages during Session Manager search
|
||||
|
||||
### URL-Based Provider Icons
|
||||
|
||||
- Added a dual rendering mode to the icon system: small SVGs are inlined as React components, while large SVGs and raster images (PNG, JPG, WebP) are loaded via Vite URL imports as `<img>` tags
|
||||
|
||||
### Kaku Terminal Support
|
||||
|
||||
- Added Kaku as a selectable terminal for session launch on macOS, reusing the WezTerm-compatible launch path (#1983, thanks @yovinchen)
|
||||
|
||||
### OMO Slim Council Support
|
||||
|
||||
- Restored first-class council support as a built-in oh-my-opencode-slim agent with updated metadata and UI copy (#1982, thanks @yovinchen)
|
||||
|
||||
### New Provider Presets
|
||||
|
||||
- **TheRouter**: Added across Claude, Codex, Gemini, OpenCode, and OpenClaw (#1891, #1892, thanks @cmzz)
|
||||
- **DDSHub**: Added as a third-party partner provider for Claude with icon and partner promotion text
|
||||
- **LionCCAPI**: Added across all five apps with anthropic-messages protocol for OpenCode and OpenClaw
|
||||
- **Shengsuanyun (胜算云)**: Added as an aggregator partner provider across all five apps with URL-based icon and localized display name
|
||||
- **PIPELLM**: Added across Claude, Codex, OpenCode, and OpenClaw with full model definitions and icon
|
||||
- **E-FlowCode**: Added across all five apps with per-app protocol configuration
|
||||
|
||||
---
|
||||
|
||||
@@ -263,6 +314,31 @@ Fixed a long-standing Linux bug where the window UI (including native title bar
|
||||
- Bedrock error messaging
|
||||
- OpenCode default `baseURL` fallback handling
|
||||
|
||||
### Duplicate Toast on Provider Switch
|
||||
|
||||
- Fixed double toast notifications (proxy-required warning followed by switch-success) when switching to Copilot, ChatGPT, or OpenAI-format providers with the proxy not running
|
||||
|
||||
### Session Search Accuracy & Chinese Support
|
||||
|
||||
- Fixed session search result truncation across providers
|
||||
- Switched FlexSearch tokenizer to full mode for proper Chinese substring matching
|
||||
|
||||
### Adaptive Thinking Reasoning Effort
|
||||
|
||||
- Fixed `resolve_reasoning_effort()` mapping adaptive thinking to `xhigh` instead of incorrectly using `high` in OpenAI format conversions
|
||||
|
||||
### Thinking Model Fallback Display
|
||||
|
||||
- Fixed the Claude provider form showing an empty Thinking model field after saving only a main model by applying read-only fallback to ANTHROPIC_MODEL (#1984, thanks @yovinchen)
|
||||
|
||||
### Auth Tab Localization
|
||||
|
||||
- Fixed missing i18n translation keys for the settings auth tab label across all locale bundles (#1985, thanks @yovinchen)
|
||||
|
||||
### Schema Migration Guard
|
||||
|
||||
- Fixed database migrations failing when skills or model_pricing tables did not exist by adding table-existence checks before ALTER and UPDATE operations
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
@@ -282,16 +358,20 @@ Fixed a long-standing Linux bug where the window UI (including native title bar
|
||||
|
||||
- Added a Copilot reverse proxy risk notice and anchored highlight links in the v3.12.3 release notes across all three languages
|
||||
|
||||
### Sponsor Partners
|
||||
|
||||
- Added Shengsuanyun, LionCC, and DDS as sponsor partners in README across all languages
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risk Notice
|
||||
|
||||
**Codex OAuth Reverse Proxy Disclaimer**
|
||||
|
||||
The Codex OAuth reverse proxy introduced in this release accesses ChatGPT Plus / Pro Codex services through reverse-engineered OAuth flows. Please be aware of the following risks before enabling this feature:
|
||||
The Codex OAuth reverse proxy introduced in this release accesses ChatGPT Codex services through reverse-engineered OAuth flows. Please be aware of the following risks before enabling this feature:
|
||||
|
||||
1. **Terms of Service**: Using reverse-engineered OAuth flows to access OpenAI services may violate OpenAI's terms of service, which prohibit unauthorized automated access, service reproduction, and circumventing intended access paths.
|
||||
2. **Account Risk**: OpenAI may flag unusual usage patterns as suspicious automated activity, potentially resulting in temporary or permanent restrictions on ChatGPT Plus / Pro access.
|
||||
2. **Account Risk**: OpenAI may flag unusual usage patterns as suspicious automated activity, potentially resulting in temporary or permanent restrictions on ChatGPT access.
|
||||
3. **No Guarantee**: OpenAI may update its authentication and detection mechanisms at any time, and usage patterns that work today may be flagged in the future.
|
||||
|
||||
The **GitHub Copilot reverse proxy** introduced in v3.12.3 also remains subject to its existing risk notice — see the [v3.12.3 release notes](v3.12.3-en.md#️-risk-notice) for the full disclosure.
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの使いやすさ、プロキシ互換性を中心とした大型機能リリースです。Claude / Codex / Gemini の公式プロバイダー、Token Plan、Copilot、サードパーティ残高 API にわたる**クォータと残高のインライン表示**を追加し、メインウィンドウなしでシステムトレイから CC Switch を動作させる**軽量モード**を導入しました。OpenAI 互換の `/v1/models` による**自動モデル発見**を 5 つのサポート対象アプリケーションすべてに提供し、ChatGPT Plus / Pro サブスクライバー向けの **Codex OAuth リバースプロキシ**を同梱しています。トレイメニューを**アプリ別サブメニュー**に再編成し、プロキシ転送スタックを **Hyper ベースのクライアント**に再構築し、**Skills ワークフロー**を発見、バッチ更新、ストレージ位置切り替えで刷新しました。さらに、フル URL エンドポイントモード、プロキシ傍受なしのセッションログ用量追跡、Copilot インタラクション最適化、マルチバイト UTF-8 ストリームチャンク境界修正、Linux 起動時の UI 応答性修正なども含まれます。
|
||||
CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの使いやすさ、プロキシ互換性を中心とした大型機能リリースです。Claude / Codex / Gemini の公式プロバイダー、Token Plan、Copilot、サードパーティ残高 API にわたる**クォータと残高のインライン表示**を追加し、メインウィンドウなしでシステムトレイから CC Switch を動作させる**軽量モード**を導入しました。OpenAI 互換の `/v1/models` による**自動モデル発見**を 5 つのサポート対象アプリケーションすべてに提供し、ChatGPT サブスクライバー向けの **Codex OAuth リバースプロキシ**を同梱しています。トレイメニューを**アプリ別サブメニュー**に再編成し、プロキシ転送スタックを **Hyper ベースのクライアント**に再構築し、**Skills ワークフロー**を発見、バッチ更新、ストレージ位置切り替え、および組み込みの skills.sh 検索・インストールで刷新しました。さらに、フル URL エンドポイントモード、強化されたトークン用量追跡、Copilot インタラクション最適化、マルチバイト UTF-8 ストリームチャンク境界修正、Linux 起動時の UI 応答性修正、およびよりフレンドリーな新規ユーザーオンボーディングなども含まれます。
|
||||
|
||||
**リリース日**: 2026-04-08
|
||||
**リリース日**: 2026-04-10
|
||||
|
||||
**更新規模**: 98 commits | 229 files changed | +23,891 / -2,305 lines
|
||||
**更新規模**: 139 commits | 280 files changed | +31,627 / -3,042 lines
|
||||
|
||||
---
|
||||
|
||||
@@ -21,9 +21,9 @@ CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの
|
||||
- **軽量モード**: トレイ専用の動作モード。トレイへの終了時にメインウィンドウを破棄し、必要時に再作成することで、アイドル時の CC Switch のデスクトップフットプリントを最小化
|
||||
- **クォータと残高の可視化**: プロバイダーカードでのインラインクォータ/残高表示 — Claude / Codex / Gemini 公式サブスクリプション、GitHub Copilot premium interactions、Codex OAuth、Token Plan プロバイダー(Kimi / Zhipu GLM / MiniMax)、および DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI の公式残高クエリをカバー
|
||||
- **プロバイダーモデル自動取得**: Claude / Codex / Gemini / OpenCode / OpenClaw のプロバイダーフォームに OpenAI 互換の `/v1/models` 発見機能を追加。グループ化ドロップダウンと失敗時の具体的なエラーメッセージ付き
|
||||
- **Codex OAuth リバースプロキシ**: ChatGPT Plus / Pro の Codex リバースプロキシを新しい Claude プロバイダーカードタイプとして追加。マネージド OAuth ログインとサブスクリプションクォータのインライン表示を提供([⚠️ リスクに関する注意事項](#️-リスクに関する注意事項))
|
||||
- **Codex OAuth リバースプロキシ**: ChatGPT の Codex リバースプロキシを新しい Claude プロバイダーカードタイプとして追加。ユーザーは ChatGPT サブスクリプションを Claude Code で利用可能に。マネージド OAuth ログインとサブスクリプションクォータのインライン表示を提供([⚠️ リスクに関する注意事項](#️-リスクに関する注意事項))
|
||||
- **トレイのアプリ別サブメニュー**: トレイメニューをアプリ別サブメニューに再編成し、プロバイダー数が多くてもメニューがオーバーフローせず、バックグラウンドのプロバイダー切り替えが長いリストでもスケール
|
||||
- **Skills 発見とバッチ更新**: SHA-256 ベースの skill 更新検出、各 skill および「すべて更新」のバッチ更新、公開 `skills.sh` 検索統合、CC Switch ストレージと `~/.agents/skills` の間のストレージ位置切り替え
|
||||
- **Skills 発見とバッチ更新**: SHA-256 ベースの skill 更新検出、各 skill および「すべて更新」のバッチ更新、`skills.sh` 検索統合、CC Switch ストレージと `~/.agents/skills` の間のストレージ位置切り替え
|
||||
- **セッションワークフローの改善**: Session Manager でのバッチ削除、Claude ターミナル復元前のディレクトリピッカー、プロキシ傍受なしでの Claude / Codex / Gemini セッションログからの用量インポート、正確な Codex JSONL 解析、アプリ別の用量フィルタリング
|
||||
- **OpenCode / OpenClaw Stream Check カバレッジ**: OpenCode の npm パッケージマッピング検出、OpenClaw `openai-completions` サポート、および残りの OpenClaw プロトコルバリアント
|
||||
- **フル URL エンドポイントモード**: `base_url` を完全な上流エンドポイントとして扱うプロバイダーオプションを追加し、非標準 URL レイアウトを要求するベンダーに対応
|
||||
@@ -31,6 +31,10 @@ CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの
|
||||
- **Copilot インタラクション最適化**: GitHub Copilot premium interaction の不要な消費を削減するリクエスト分類とルーティングロジックを追加
|
||||
- **UTF-8 ストリームチャンク境界修正**: マルチバイト UTF-8 シーケンスが TCP チャンクを跨いで分割された際の Copilot リバースプロキシ経由での文字化け(U+FFFD 置換文字)を解消するため、すべての 4 つの SSE ストリーミングパスを修正
|
||||
- **Linux 起動時 UI 修正**: ユーザーが手動でウィンドウを最大化・復元するまでウィンドウ UI がクリックを受け付けない長年の問題を修正
|
||||
- **初回起動オンボーディング**: 新規インストール時のワンタイムウェルカムダイアログ、Claude / OpenAI / Google 公式プリセットの自動シード、起動時の OpenCode / OpenClaw ライブ設定の自動インポート
|
||||
- **Claude セッションタイトルと検索ハイライト**: カスタムタイトルメタデータ → 最初のユーザーメッセージ → ディレクトリベースネームの優先チェーンによる Claude セッションの意味のあるタイトル抽出、Session Manager 検索でのキーワードハイライト
|
||||
- **URL ベースのプロバイダーアイコン**: 大きな SVG とラスター画像(PNG / JPG / WebP)を Vite URL import でロードし、小さな SVG はインライン保持するデュアルレンダリングモード
|
||||
- **新プロバイダープリセット**: TheRouter、DDSHub、LionCCAPI、Shengsuanyun(胜算云)、PIPELLM、E-FlowCode を対応アプリケーションに追加
|
||||
|
||||
---
|
||||
|
||||
@@ -50,9 +54,9 @@ CC Switch のアイドル時のデスクトップフットプリントを大幅
|
||||
|
||||
- **公式サブスクリプション**: Claude / Codex / Gemini 公式プロバイダーのサブスクリプションクォータ表示
|
||||
- **GitHub Copilot**: Copilot プロバイダーカードに premium interactions 残量を表示
|
||||
- **Codex OAuth**: Codex OAuth カードに ChatGPT Plus / Pro サブスクリプションクォータをインライン表示
|
||||
- **Token Plan プロバイダー**: Kimi、Zhipu GLM、MiniMax。クォータ計算と 0% → 100% 使用量進行を修正
|
||||
- **サードパーティ残高**: DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI に公式残高クエリを追加
|
||||
- **Codex OAuth**: Codex OAuth カードに ChatGPT サブスクリプションクォータをインライン表示
|
||||
- **Token Plan プロバイダー**: Kimi、Zhipu GLM、MiniMax の使用量進行表示(混乱を避けるため手動で有効化が必要)
|
||||
- **サードパーティ残高**: DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI に公式残高クエリを追加(混乱を避けるため手動で有効化が必要)
|
||||
- 公式プロバイダーではヘルスチェックと用量設定ボタンを非表示にし、カードをクリーンに保つ
|
||||
|
||||
### プロバイダーモデル自動取得
|
||||
@@ -66,13 +70,12 @@ CC Switch のアイドル時のデスクトップフットプリントを大幅
|
||||
|
||||
### Codex OAuth リバースプロキシ
|
||||
|
||||
ChatGPT Plus / Pro のサブスクライバーが Codex OAuth セッションを CC Switch 経由で利用できるリバースプロキシパスを追加。
|
||||
ChatGPT サブスクライバーが ChatGPT サブスクリプションを Claude Code で利用できるリバースプロキシパスを追加。
|
||||
|
||||
- ChatGPT Plus / Pro 認証を使ったマネージド OAuth ログインフロー
|
||||
- ChatGPT 認証を使ったマネージド OAuth ログインフロー
|
||||
- API キー型プロバイダーと並ぶ新しい Claude プロバイダーカードタイプとして表示
|
||||
- サブスクリプションクォータのインライン表示
|
||||
- Auth Center との緊密な統合と、コピー・レイアウト・アイコンの調整
|
||||
- Codex OAuth プリセットを GPT-5.4 モデルファミリーに更新
|
||||
- Auth Center との統合によるトークンの一元管理
|
||||
- 有効化前に下記の [⚠️ リスクに関する注意事項](#️-リスクに関する注意事項) をご確認ください
|
||||
|
||||
### トレイのアプリ別サブメニュー
|
||||
@@ -131,6 +134,54 @@ GitHub Copilot premium interaction の不要な消費を削減するリクエス
|
||||
- 受信リクエストを意図と重要度で分類
|
||||
- 価値の低いリクエストを premium interaction 消費パスから迂回
|
||||
- Copilot サブスクリプションの使用可能期間を延長することを目的
|
||||
- 注意: 消費を最適化しても、Copilot 外で Copilot API を使用する場合、Copilot 内で使用するよりも消費量は多くなります。
|
||||
|
||||
### 初回起動ウェルカムダイアログ
|
||||
|
||||
新規インストールのユーザーに CC Switch のワークフローを案内するワンタイムウェルカムダイアログを追加。
|
||||
|
||||
- 既存のライブ設定がデフォルトプロバイダーとして保持される仕組みを説明
|
||||
- 内蔵の公式プリセットによるワンクリックでの公式エンドポイント復帰を紹介
|
||||
- アップグレードユーザーは空プロバイダーチェックにより自動的にスキップ
|
||||
|
||||
### 公式プロバイダーの自動シード
|
||||
|
||||
- 起動時に Claude Official / OpenAI Official / Google Official プロバイダーエントリを自動シードし、すべてのユーザーにワンクリックで公式エンドポイントに戻るパスを提供
|
||||
|
||||
### OpenCode / OpenClaw 自動インポート
|
||||
|
||||
- 起動時に OpenCode と OpenClaw のライブプロバイダー設定を自動インポート。Claude / Codex / Gemini で既にある自動インポート動作と同等に
|
||||
|
||||
### Common Config エディタガイダンス
|
||||
|
||||
- Claude / Codex / Gemini の Common Config スニペットエディタモーダルに情報ガイドと空状態プロンプトを追加
|
||||
- ユーザーがプロバイダー追加/編集フォームを初めて開く際、Common Config Snippets を説明するワンタイムダイアログを追加
|
||||
|
||||
### Claude セッションタイトルと検索ハイライト
|
||||
|
||||
- Claude セッションの意味のあるタイトル抽出を追加。優先チェーン: カスタムタイトルメタデータ → 最初の実ユーザーメッセージ → ディレクトリベースネームフォールバック
|
||||
- Session Manager 検索時にセッションタイトルとメッセージ内のキーワードをハイライト
|
||||
|
||||
### URL ベースのプロバイダーアイコン
|
||||
|
||||
- アイコンシステムにデュアルレンダリングモードを追加: 小さな SVG は React コンポーネントとしてインライン、大きな SVG とラスター画像(PNG / JPG / WebP)は Vite URL import で `<img>` タグとしてロード
|
||||
|
||||
### Kaku ターミナルサポート
|
||||
|
||||
- macOS でセッション起動用の選択可能なターミナルとして Kaku を追加。WezTerm 互換の起動パスを再利用 (#1983, @yovinchen に感謝)
|
||||
|
||||
### OMO Slim Council サポート
|
||||
|
||||
- 内蔵 oh-my-opencode-slim エージェントとしての council のファーストクラスサポートを復元。メタデータと UI コピーを更新 (#1982, @yovinchen に感謝)
|
||||
|
||||
### 新プロバイダープリセット
|
||||
|
||||
- **TheRouter**: Claude / Codex / Gemini / OpenCode / OpenClaw の 5 アプリに追加 (#1891, #1892, @cmzz に感謝)
|
||||
- **DDSHub**: Claude のサードパーティパートナープロバイダーとして追加。アイコンとパートナープロモーションテキスト付き
|
||||
- **LionCCAPI**: 5 アプリすべてに追加。OpenCode / OpenClaw は anthropic-messages プロトコルを使用
|
||||
- **Shengsuanyun(胜算云)**: アグリゲーターパートナープロバイダーとして 5 アプリすべてに追加。URL ベースのアイコンとローカライズ名をサポート
|
||||
- **PIPELLM**: Claude / Codex / OpenCode / OpenClaw に追加。完全なモデル定義とアイコン付き
|
||||
- **E-FlowCode**: 5 アプリすべてに追加。アプリごとに異なるプロトコル設定
|
||||
|
||||
---
|
||||
|
||||
@@ -263,6 +314,31 @@ Claude Code で Copilot リバースプロキシ経由時、中国語文字や
|
||||
- Bedrock エラーメッセージを修正
|
||||
- OpenCode デフォルト `baseURL` のフォールバック処理を修正
|
||||
|
||||
### プロバイダー切り替え時の重複 Toast
|
||||
|
||||
- プロキシ未実行時に Copilot / ChatGPT / OpenAI フォーマットプロバイダーに切り替えた際の二重 toast 通知(プロキシ必要警告 + 切り替え成功)を修正
|
||||
|
||||
### セッション検索精度と中国語サポート
|
||||
|
||||
- プロバイダーをまたぐセッション検索結果の切り詰めを修正
|
||||
- FlexSearch トークナイザーを full モードに切り替え、中国語サブストリングマッチングを正しく動作させる
|
||||
|
||||
### 適応的思考の推論エフォート
|
||||
|
||||
- `resolve_reasoning_effort()` が適応的思考を `high` ではなく正しく `xhigh` にマッピングするよう修正(OpenAI フォーマット変換時)
|
||||
|
||||
### Thinking モデルフォールバック表示
|
||||
|
||||
- Claude プロバイダーフォームでメインモデルのみ保存後に Thinking モデルフィールドが空で表示される問題を修正。ANTHROPIC_MODEL への読み取り専用フォールバックを適用 (#1984, @yovinchen に感謝)
|
||||
|
||||
### Auth タブのローカライゼーション
|
||||
|
||||
- 設定の auth タブラベルに不足していた i18n 翻訳キーをすべてのロケールバンドルで修正 (#1985, @yovinchen に感謝)
|
||||
|
||||
### スキーマ移行ガード
|
||||
|
||||
- skills または model_pricing テーブルが存在しない場合にデータベース移行が失敗する問題を修正。ALTER および UPDATE 操作の前にテーブル存在チェックを追加
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
@@ -282,16 +358,20 @@ Claude Code で Copilot リバースプロキシ経由時、中国語文字や
|
||||
|
||||
- v3.12.3 の release notes に Copilot リバースプロキシのリスク通知とハイライトリンクのアンカーを 3 言語すべてに追加
|
||||
|
||||
### スポンサーパートナー
|
||||
|
||||
- README の 3 言語すべてに Shengsuanyun、LionCC、DDS をスポンサーパートナーとして追加
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ リスクに関する注意事項
|
||||
|
||||
**Codex OAuth リバースプロキシに関する免責事項**
|
||||
|
||||
本リリースで追加された Codex OAuth リバースプロキシ機能は、リバースエンジニアリングによる OAuth フローを通じて ChatGPT Plus / Pro の Codex サービスにアクセスします。この機能を有効にする前に、以下のリスクをご確認ください:
|
||||
本リリースで追加された Codex OAuth リバースプロキシ機能は、リバースエンジニアリングによる OAuth フローを通じて ChatGPT の Codex サービスにアクセスします。この機能を有効にする前に、以下のリスクをご確認ください:
|
||||
|
||||
1. **利用規約違反の可能性**: リバースエンジニアリングされた OAuth フローを使用して OpenAI サービスにアクセスすることは、OpenAI の利用規約に違反する可能性があります。これらの規約では、未承認の自動アクセス、サービス複製、および意図されたアクセスパスの回避が禁止されています。
|
||||
2. **アカウントリスク**: OpenAI は異常な使用パターンを疑わしい自動化活動としてフラグ付けし、ChatGPT Plus / Pro へのアクセスに一時的または永久的な制限を科す可能性があります。
|
||||
2. **アカウントリスク**: OpenAI は異常な使用パターンを疑わしい自動化活動としてフラグ付けし、ChatGPT へのアクセスに一時的または永久的な制限を科す可能性があります。
|
||||
3. **将来の利用保証なし**: OpenAI は認証および検出メカニズムをいつでも更新する可能性があり、現在動作する使用パターンが将来的にフラグ付けされる可能性があります。
|
||||
|
||||
v3.12.3 で導入された **GitHub Copilot リバースプロキシ**も、既存のリスク通知の対象となります — 詳細は [v3.12.3 リリースノート](v3.12.3-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供应商工作流与代理兼容性。本版本在各主要供应商卡片上新增了**配额与余额展示**,覆盖 Claude / Codex / Gemini 官方订阅、Token Plan(Kimi / Zhipu GLM / MiniMax)、Copilot premium interactions 以及 DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI 等第三方余额查询;引入了**轻量模式**,让 CC Switch 可以仅驻留在系统托盘中运行;通过 OpenAI 兼容的 `/v1/models` 端点在 Claude / Codex / Gemini / OpenCode / OpenClaw 五个应用的供应商表单中实现了**模型自动发现**;为 ChatGPT Plus / Pro 订阅者提供了 **Codex OAuth 反向代理**;将托盘菜单重构为**按应用分级的子菜单**;将代理转发层重建在 **Hyper 客户端**之上;并完成了 **Skills 工作流**的发现、批量更新和存储位置切换改造。其他改进还包括完整 URL 端点模式、无需代理拦截的会话日志用量追踪、Copilot 调用优化器、多字节 UTF-8 流式分片边界修复以及 Linux 启动时 UI 无响应修复等。
|
||||
CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供应商工作流与代理兼容性。本版本在各主要供应商卡片上新增了**配额与余额展示**,覆盖 Claude / Codex / Gemini 官方订阅、Token Plan(Kimi / Zhipu GLM / MiniMax)、Copilot premium interactions 以及 DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI 等第三方余额查询;引入了**轻量模式**,让 CC Switch 可以仅驻留在系统托盘中运行;通过 OpenAI 兼容的 `/v1/models` 端点在 Claude / Codex / Gemini / OpenCode / OpenClaw 五个应用的供应商表单中实现了**模型自动发现**;为 ChatGPT 订阅者提供了 **Codex OAuth 反向代理**;将托盘菜单重构为**按应用分级的子菜单**;将代理转发层重建在 **Hyper 客户端**之上;并完成了 **Skills 工作流**的发现、批量更新和存储位置切换改造,内置了 skills.sh 搜索安装。其他改进还包括完整 URL 端点模式、更完善的 token 用量追踪、Copilot 调用优化器、多字节 UTF-8 流式分片边界修复以及 Linux 启动时 UI 无响应修复,以及更友善的新用户引导等。
|
||||
|
||||
**发布日期**:2026-04-08
|
||||
**发布日期**:2026-04-10
|
||||
|
||||
**更新规模**:98 commits | 229 files changed | +23,891 / -2,305 lines
|
||||
**更新规模**:139 commits | 280 files changed | +31,627 / -3,042 lines
|
||||
|
||||
---
|
||||
|
||||
@@ -21,9 +21,9 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
|
||||
- **轻量模式**:新增仅托盘运行模式,退出到托盘时销毁主窗口、按需重建,空闲时资源占用接近零
|
||||
- **配额与余额展示**:供应商卡片上直接展示配额或余额 —— 覆盖 Claude / Codex / Gemini 官方订阅、GitHub Copilot premium interactions、Codex OAuth、Token Plan(Kimi / Zhipu GLM / MiniMax),以及 DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI 的官方余额查询
|
||||
- **供应商模型自动获取**:为 Claude / Codex / Gemini / OpenCode / OpenClaw 的供应商表单新增 OpenAI 兼容的 `/v1/models` 发现能力,按分组下拉展示并提供针对性错误提示
|
||||
- **Codex OAuth 反向代理**:新增 ChatGPT Plus / Pro 的 Codex 反向代理,作为新的 Claude 供应商卡片类型,包含受管 OAuth 登录流程和订阅配额展示([⚠️ 风险提示](#️-风险提示))
|
||||
- **Codex OAuth 反向代理**:新增 ChatGPT 的 Codex 反向代理,作为新的 Claude 供应商卡片类型,让用户在可以在 Claude Code 里面使用 ChatGPT 订阅。包含受管 OAuth 登录流程和订阅配额展示([⚠️ 风险提示](#️-风险提示))
|
||||
- **托盘按应用分级菜单**:将托盘菜单重构为按应用分组的子菜单,防止供应商多时菜单溢出,让后台切换供应商在大量供应商场景下仍可用
|
||||
- **Skills 发现与批量更新**:基于 SHA-256 内容哈希的更新检测、单项和"全部更新"批量操作、`skills.sh` 公共注册表搜索集成,以及 CC Switch 与 `~/.agents/skills` 的存储位置切换
|
||||
- **Skills 发现与批量更新**:基于 SHA-256 内容哈希的更新检测、单项和"全部更新"批量操作、`skills.sh` 表搜索集成,以及 CC Switch 与 `~/.agents/skills` 的存储位置切换
|
||||
- **会话工作流升级**:会话管理器批量删除、Claude 终端恢复前的目录选择器、无需代理拦截即可导入 Claude / Codex / Gemini 会话日志用量、精确的 Codex JSONL 解析、按应用筛选用量面板
|
||||
- **OpenCode / OpenClaw 流式检测覆盖**:新增 OpenCode 的 npm 包映射检测、OpenClaw `openai-completions` 支持,以及其余所有 OpenClaw 协议变体
|
||||
- **完整 URL 端点模式**:新增将 `base_url` 视作完整上游端点的供应商选项,支持非标准 URL 布局的厂商
|
||||
@@ -31,6 +31,10 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
|
||||
- **Copilot 调用优化器**:新增请求分类和路由逻辑,降低 GitHub Copilot premium interaction 的不必要消耗
|
||||
- **UTF-8 流式分片边界修复**:所有 4 条 SSE 流式路径改为跨分片保留残留多字节序列,消除 Copilot 反代下中文/emoji 乱码
|
||||
- **Linux 启动 UI 修复**:修复长期存在的 Linux 窗口初次无法响应点击、需用户手动最大化再还原才能操作的问题
|
||||
- **首次运行引导**:新安装时弹出一次性欢迎对话框、自动种入 Claude / OpenAI / Google 官方预设、启动时自动导入 OpenCode / OpenClaw 的 live 配置
|
||||
- **Claude 会话标题与搜索高亮**:从 Claude 会话中提取有意义的标题(自定义标题 → 首条用户消息 → 目录名),在会话管理器搜索时高亮匹配关键词
|
||||
- **URL 图标支持**:图标系统新增双渲染模式,大 SVG 和光栅图片(PNG / JPG / WebP)通过 Vite URL import 加载,小 SVG 保持内联
|
||||
- **新供应商预设**:新增 TheRouter、DDSHub、LionCCAPI、胜算云、PIPELLM、E-FlowCode 预设
|
||||
|
||||
---
|
||||
|
||||
@@ -50,9 +54,9 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
|
||||
|
||||
- **官方订阅**:Claude / Codex / Gemini 官方供应商的订阅配额展示
|
||||
- **GitHub Copilot**:在 Copilot 供应商卡片上显示 premium interactions 剩余量
|
||||
- **Codex OAuth**:在 Codex OAuth 卡片上内联展示 ChatGPT Plus / Pro 订阅配额
|
||||
- **Token Plan 供应商**:Kimi、Zhipu GLM、MiniMax,修正配额数学与 0% → 100% 用量进度
|
||||
- **第三方余额**:为 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 提供官方余额查询
|
||||
- **Codex OAuth**:在 Codex OAuth 卡片上内联展示 ChatGPT 订阅配额
|
||||
- **Token Plan 供应商**:Kimi、Zhipu GLM、MiniMax 用量进度显示(为避免混淆,需要手动开启)
|
||||
- **第三方余额**:为 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 提供官方余额查询(为避免混淆,需要手动开启)
|
||||
- 官方供应商的健康检查和用量配置按钮自动隐藏,保持卡片简洁
|
||||
|
||||
### 供应商模型自动获取
|
||||
@@ -66,13 +70,12 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
|
||||
|
||||
### Codex OAuth 反向代理
|
||||
|
||||
新增 ChatGPT Plus / Pro 订阅者的 Codex OAuth 反向代理路径,让 ChatGPT 订阅者可以在 Claude Code 中使用自己的订阅。
|
||||
新增 ChatGPT 订阅者的 Codex OAuth 反向代理路径,让 ChatGPT 订阅者可以在 Claude Code 中使用自己的订阅。
|
||||
|
||||
- 受管 OAuth 登录流程,通过 ChatGPT Plus / Pro 认证
|
||||
- 受管 OAuth 登录流程,通过 ChatGPT 认证
|
||||
- 作为新的 Claude 供应商卡片类型出现在列表中,与 API-key 型供应商并列
|
||||
- 订阅配额内联展示
|
||||
- 与 Auth Center UI 紧密集成,统一管理 Token
|
||||
- Codex OAuth 预设升级到 GPT-5.4 系列
|
||||
- 启用前请参见下文的 [⚠️ 风险提示](#️-风险提示)
|
||||
|
||||
### 托盘按应用分级菜单
|
||||
@@ -131,6 +134,54 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
|
||||
- 根据请求意图和权重进行分类
|
||||
- 将低价值请求路由到非 premium 通道
|
||||
- 旨在延长 Copilot 订阅的可用时长
|
||||
- 注意,即使优化过消耗以后,在 Copilot 外使用 Copilot 的 API 消耗仍然会高于在 Copilot 内使用。
|
||||
|
||||
### 首次运行欢迎对话框
|
||||
|
||||
新安装用户首次打开时显示一次性欢迎对话框,引导了解 CC Switch 工作流程。
|
||||
|
||||
- 说明已有 live 配置如何被保留为默认供应商
|
||||
- 介绍内置官方预设如何实现一键回滚到官方端点
|
||||
- 升级用户通过空供应商检查自动跳过
|
||||
|
||||
### 官方供应商自动种入
|
||||
|
||||
- 启动时自动种入 Claude Official / OpenAI Official / Google Official 供应商条目,为每位用户提供一键回滚到官方端点的路径
|
||||
|
||||
### OpenCode / OpenClaw 自动导入
|
||||
|
||||
- 启动时自动导入 OpenCode 和 OpenClaw 的 live 供应商配置,与 Claude / Codex / Gemini 已有的自动导入行为对齐
|
||||
|
||||
### Common Config 编辑器引导
|
||||
|
||||
- 在 Claude / Codex / Gemini 的 Common Config 代码片段编辑器弹窗中添加引导信息和空状态提示
|
||||
- 用户首次打开供应商添加/编辑表单时弹出一次性对话框说明 Common Config Snippets
|
||||
|
||||
### Claude 会话标题与搜索高亮
|
||||
|
||||
- 为 Claude 会话新增有意义的标题提取,优先链:自定义标题元数据 → 首条真实用户消息 → 目录名回退
|
||||
- 在会话管理器搜索时高亮匹配关键词
|
||||
|
||||
### URL 图标支持
|
||||
|
||||
- 图标系统新增双渲染模式:小 SVG 以 React 组件内联,大 SVG 和光栅图片(PNG / JPG / WebP)通过 Vite URL import 以 `<img>` 标签加载
|
||||
|
||||
### Kaku 终端支持
|
||||
|
||||
- macOS 上新增 Kaku 作为可选终端用于启动会话,复用 WezTerm 兼容的启动路径 (#1983, 感谢 @yovinchen)
|
||||
|
||||
### OMO Slim Council 支持
|
||||
|
||||
- 恢复 council 作为内置 oh-my-opencode-slim agent 的一等支持,更新元数据和 UI 文案 (#1982, 感谢 @yovinchen)
|
||||
|
||||
### 新供应商预设
|
||||
|
||||
- **TheRouter**:覆盖 Claude / Codex / Gemini / OpenCode / OpenClaw 五个应用 (#1891, #1892, 感谢 @cmzz)
|
||||
- **DDSHub**:作为 Claude 的第三方合作伙伴供应商,含图标和推广文案
|
||||
- **LionCCAPI**:覆盖全部五个应用,OpenCode / OpenClaw 使用 anthropic-messages 协议
|
||||
- **胜算云 (Shengsuanyun)**:作为聚合类合作伙伴供应商覆盖全部五个应用,支持 URL 图标和本地化名称
|
||||
- **PIPELLM**:覆盖 Claude / Codex / OpenCode / OpenClaw,含完整模型定义和图标
|
||||
- **E-FlowCode**:覆盖全部五个应用,按应用配置不同协议
|
||||
|
||||
---
|
||||
|
||||
@@ -264,6 +315,31 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
|
||||
- 修复 Bedrock 错误消息
|
||||
- 修复 OpenCode 默认 `baseURL` 回退处理
|
||||
|
||||
### 供应商切换时重复 Toast
|
||||
|
||||
- 修复代理未运行时切换到 Copilot / ChatGPT / OpenAI 格式供应商时出现双重 toast 通知(代理必需警告 + 切换成功)
|
||||
|
||||
### 会话搜索精度与中文支持
|
||||
|
||||
- 修复会话搜索结果在跨供应商时被截断的问题
|
||||
- 将 FlexSearch 分词器切换为 full 模式以支持中文子串匹配
|
||||
|
||||
### 自适应思维推理力度
|
||||
|
||||
- 修复 `resolve_reasoning_effort()` 将自适应思维错误映射为 `high`,应为 `xhigh`(OpenAI 格式转换场景)
|
||||
|
||||
### Thinking 模型回退显示
|
||||
|
||||
- 修复 Claude 供应商表单仅填写主模型后 Thinking 模型字段显示为空,改为只读回退到 ANTHROPIC_MODEL (#1984, 感谢 @yovinchen)
|
||||
|
||||
### Auth Tab 本地化
|
||||
|
||||
- 修复设置面板 auth tab 标签在所有语言包中缺失 i18n 翻译 key (#1985, 感谢 @yovinchen)
|
||||
|
||||
### 数据库迁移守卫
|
||||
|
||||
- 修复 skills 或 model_pricing 表不存在时数据库迁移失败,在 ALTER 和 UPDATE 操作前添加表存在性检查
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
@@ -283,16 +359,20 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
|
||||
|
||||
- 在三语 v3.12.3 release notes 中新增 Copilot 反代风险提示,并为重点内容添加锚点链接
|
||||
|
||||
### 赞助商合作伙伴
|
||||
|
||||
- 在三语 README 中新增胜算云、LionCC、DDS 作为赞助商合作伙伴
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 风险提示
|
||||
|
||||
**Codex OAuth 反向代理免责声明**
|
||||
|
||||
本版本新增的 Codex OAuth 反向代理功能通过逆向工程的 OAuth 流程访问 ChatGPT Plus / Pro 的 Codex 服务。启用此功能前,请注意以下风险:
|
||||
本版本新增的 Codex OAuth 反向代理功能通过逆向工程的 OAuth 流程访问 ChatGPT 的 Codex 服务。启用此功能前,请注意以下风险:
|
||||
|
||||
1. **违反服务条款**:使用逆向 OAuth 流程访问 OpenAI 服务可能违反 OpenAI 的服务条款,其中禁止未经授权的自动化访问、服务复制以及绕过既定的访问路径。
|
||||
2. **账号风险**:OpenAI 可能将异常使用模式标记为可疑的自动化行为,从而对 ChatGPT Plus / Pro 访问施加临时或永久限制。
|
||||
2. **账号风险**:OpenAI 可能将异常使用模式标记为可疑的自动化行为,从而对 ChatGPT 访问施加临时或永久限制。
|
||||
3. **无法保证长期可用**:OpenAI 可能随时更新其认证和检测机制,当前可用的使用方式未来可能被标记。
|
||||
|
||||
v3.12.3 引入的 **GitHub Copilot 反向代理**同样适用原有风险提示 —— 详见 [v3.12.3 release notes](v3.12.3-zh.md#️-风险提示)。
|
||||
@@ -307,26 +387,26 @@ v3.12.3 引入的 **GitHub Copilot 反向代理**同样适用原有风险提示
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------------------ | ----------------------------------- |
|
||||
| `CC-Switch-v3.13.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.13.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.13.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.13.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.13.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.13.0-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.13.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.13.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.13.0-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.13.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
|
||||
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
# CC Switch v3.14.0
|
||||
|
||||
> Hermes Agent becomes the 6th managed app, Claude Opus 4.7 rolls out across the preset matrix, Gemini Native API proxy, "Local Routing" rename, and application-level window controls
|
||||
|
||||
**[中文版 →](v3.14.0-zh.md) | [日本語版 →](v3.14.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.14.0 is a major release centered on onboarding **Hermes Agent as the 6th first-class managed app** and rolling out **Claude Opus 4.7** across the full aggregator and Bedrock preset matrix. Hermes support covers a database v9 → v10 migration, a complete Rust command surface, YAML-backed `~/.hermes/config.yaml` read/write with atomic backups, MCP sync, Skills sync, SQLite + JSONL session management, and dedicated frontend panels including a Memory editor. All four API protocols aligned with Hermes Agent 0.10.0 (`chat_completions`, `anthropic_messages`, `codex_responses`, `bedrock_converse`) are selectable. Providers owned by the user-authored `providers:` dict are rendered as read-only cards, and deep YAML configuration is delegated directly to the Hermes Web UI.
|
||||
|
||||
Beyond Hermes, this release adds a **Gemini Native API proxy** (`api_format = "gemini_native"`) so the proxy can forward directly to Google's `generateContent` endpoint with full streaming, schema conversion, and shadow request support; renames the legacy "Local Proxy Takeover" to **Local Routing** across UI copy, README, and docs in all three locales; introduces **application-level window controls**, an opt-in setting that materially improves the experience on Linux Wayland where compositor-drawn buttons can become inert; and bundles late additions for launching `hermes dashboard` from the toolbar, a LemonData preset across all six apps, a DDSHub Codex endpoint, plus several Hermes health-check and Usage modal fixes.
|
||||
|
||||
On the session side, the message list is **virtualized** via `@tanstack/react-virtual` so conversations with thousands of records scroll smoothly and long messages collapse by default; the Usage dashboard adds a **date range picker** (Today / 1d / 7d / 14d / 30d + custom date-time calendar) and a page-jump input; **Stream Check error classification** now surfaces color-coded toasts with refreshed default probe models and an explicit "model not found" branch; and switching to official providers is **blocked while Local Routing is active** to avoid account-suspension risk. The pricing database is reseeded from v8 → v9 with ~50 new model entries (Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, GLM-5/5.1 and others) and corrected stale prices.
|
||||
|
||||
**Release Date**: 2026-04-21
|
||||
|
||||
**Update Scale**: 100 commits | 219 files changed | +20,548 / -3,569 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Hermes Agent Support (6th Managed App)**: Database v9 → v10 migration, full Rust command surface, YAML read/write with atomic backups, MCP sync, Skills sync, SQLite + JSONL session management, dedicated frontend panels, and four API protocols (`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`)
|
||||
- **Claude Opus 4.7 Rollout**: Adaptive thinking whitelisting, per-million pricing seed, Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix); all aggregator and Bedrock presets migrated to Opus 4.7 as the default Opus model
|
||||
- **Claude `max` Effort Tier**: Effort dropdown upgraded from `high` to `max`
|
||||
- **Gemini Native API Proxy**: New `api_format = "gemini_native"` forwards directly to Google's `generateContent` with full streaming / schema conversion / shadow request support
|
||||
- **GitHub Copilot Enterprise Server**: GHES authentication and endpoint configuration for Copilot-backed Claude providers
|
||||
- **Copilot Premium Consumption Deep Optimization**: Proactive thinking-block stripping before forwarding, `tool_result` classification fix, subagent detection, `x-interaction-id` billing merge, orphan `tool_result` sanitization, and default warmup downgrade — a systematic reduction in premium interaction consumption
|
||||
- **Session List Virtualization**: Long conversations scroll smoothly and long messages collapse by default to reduce text layout cost
|
||||
- **Codex / OpenClaw Session Title Extraction**: Meaningful title extraction with 2-line display; strips OpenClaw `message_id` suffix noise
|
||||
- **Usage Date Range Picker**: Today / 1d / 7d / 14d / 30d preset tabs + custom date-time calendar; page-jump input on paginated lists
|
||||
- **Stream Check Error Classification**: Color-coded error toasts; refreshed default probe models; explicit "model not found" detection
|
||||
- **Block Official Provider Switching During Local Routing**: Routing official API traffic through the local proxy carries account-suspension risk — switches are blocked with a warning toast
|
||||
- **Pricing Database Refresh (v8 → v9)**: ~50 new model entries and corrected stale prices
|
||||
- **Application-Level Window Controls**: Opt-in setting to render CC Switch's own min/max/close buttons, materially improving Linux Wayland experience
|
||||
- **Hermes in Unified Skills Management**: Skill install, enable, and filter now cover Hermes
|
||||
- **Hermes / OpenClaw Config Directory Override**: Point CC Switch at a custom `~/.hermes/config.yaml` or `openclaw.json` location
|
||||
- **Launch Hermes Dashboard from Toolbar**: When the Hermes Web UI probe fails, the toolbar entry offers to run `hermes dashboard` in the user's preferred terminal
|
||||
- **New Partner Presets**: LemonData across all six apps; DDSHub Codex endpoint; StepFun Step Plan
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### Hermes Agent Support (6th Managed App)
|
||||
|
||||
CC Switch now treats Hermes Agent as a first-class managed app alongside Claude / Codex / Gemini / OpenCode / OpenClaw.
|
||||
|
||||
- **Database Migration v9 → v10**: Adds `enabled_hermes` columns to `mcp_servers` and `skills` tables (`DEFAULT 0`, auto-migrated, no data loss)
|
||||
- **YAML Configuration Read/Write**: `~/.hermes/config.yaml` read/write with atomic backups; `tests/hermes_roundtrip.rs` guards against dropped OAuth MCP `auth` blocks or pollution of unrelated YAML keys
|
||||
- **Four API Protocols**: Aligned with Hermes Agent 0.10.0 — `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`; new deeplinks default to `chat_completions`
|
||||
- **User `providers:` Dict Read-Only Rendering**: User-authored providers in the YAML appear as read-only cards in CC Switch; deep configuration delegates to the Hermes Web UI
|
||||
- **Additive Switching**: Unlike Claude / Codex's "override" style, all Hermes providers coexist in the same YAML
|
||||
|
||||
### Hermes Memory Panel
|
||||
|
||||
- New Memory panel for editing `MEMORY.md` / `USER.md` directly, with an enable switch, character-count limits, and a live save flow
|
||||
- Replaces the Prompts entry for Hermes
|
||||
|
||||
### Hermes Provider Presets (~50)
|
||||
|
||||
- Covers Nous Research, Shengsuanyun, OpenRouter, DeepSeek, Together AI, StepFun, Zhipu GLM, Bailian, Kimi, MiniMax, DouBao, BaiLing, ModelScope, KAT-Coder, PackyCode, Cubence, AIGoCode, RightCode, AICodeMirror, AICoding, CrazyRouter, SSSAiCode, Micu, CTok.ai, DDSHub, E-FlowCode, LionCCAPI, PIPELLM, Compshare, SiliconFlow, AiHubMix, DMXAPI, TheRouter, Novita, Nvidia, and Xiaomi MiMo
|
||||
|
||||
### Launch Hermes Dashboard from Toolbar
|
||||
|
||||
- When the Hermes Web UI probe fails, the toolbar entry opens a confirm dialog offering to run `hermes dashboard` in the user's preferred terminal
|
||||
- Spawned via a temp bash / batch script; `hermes dashboard` opens the browser itself once ready, so no polling is required
|
||||
- The Memory panel and Health banner keep the existing toast behavior
|
||||
- Also corrects the stale `hermes web` hint in the offline toast (the real command is `hermes dashboard`)
|
||||
- Linux terminal detection reordered to try `which` before stat'ing `/usr/bin`, `/bin`, `/usr/local/bin`
|
||||
|
||||
### Claude Opus 4.7 Support
|
||||
|
||||
- New Claude Opus 4.7 with adaptive thinking whitelisting, per-million pricing seed, and Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix)
|
||||
- All aggregator and Bedrock presets migrated to Opus 4.7 as the default Opus model
|
||||
|
||||
### Claude `max` Effort Tier
|
||||
|
||||
- Claude effort dropdown upgraded from `high` to `max` for extended reasoning capacity
|
||||
|
||||
### Gemini Native API Proxy
|
||||
|
||||
- New `api_format = "gemini_native"` so the proxy can forward directly to Google's `generateContent` API (#1918, thanks @yovinchen)
|
||||
- Full streaming, schema conversion, and shadow request support
|
||||
- Adds `gemini_url.rs`, `gemini_schema.rs`, `gemini_shadow.rs`, `streaming_gemini.rs`, and `transform_gemini.rs` under the proxy providers module
|
||||
|
||||
### GitHub Copilot Enterprise Server (GHES)
|
||||
|
||||
- GHES authentication and endpoint configuration for Copilot-backed Claude providers (#2175, thanks @hotelbe)
|
||||
|
||||
### Session List Virtualization
|
||||
|
||||
- Virtualized the session list via `@tanstack/react-virtual` so long conversations (thousands of records) scroll smoothly
|
||||
- Long session messages are collapsed by default to reduce text layout cost
|
||||
|
||||
### Codex / OpenClaw Session Title Extraction
|
||||
|
||||
- Meaningful title auto-extraction for Codex and OpenClaw sessions with 2-line display
|
||||
- Strips OpenClaw `message_id` suffix noise
|
||||
|
||||
### Usage Date Range Picker
|
||||
|
||||
- New date range selector on the usage dashboard with preset tabs (Today / 1d / 7d / 14d / 30d) + custom date + time calendar (#2002, thanks @yovinchen)
|
||||
- Page-jump input added on paginated lists
|
||||
|
||||
### Model Mapping Quick-Set
|
||||
|
||||
- New quick-set button next to model mapping fields in provider forms for faster edits (#2179, thanks @lispking)
|
||||
|
||||
### Stream Check Error Classification
|
||||
|
||||
- Stream Check errors are classified and surfaced as color-coded toasts
|
||||
- Refreshed default probe models to match each vendor's current lineup
|
||||
- Explicit detection for "model not found" responses
|
||||
|
||||
### Block Official Provider Switching During Local Routing
|
||||
|
||||
- Switching to official providers is blocked while Local Routing is active, with a warning toast
|
||||
- Reason: routing official API traffic through the local proxy carries account-suspension risk
|
||||
|
||||
### Pricing Database Refresh (v8 → v9)
|
||||
|
||||
- Reseed-on-migration pricing table
|
||||
- ~50 new model pricing entries including Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, GLM-5/5.1
|
||||
- Corrected stale prices for DeepSeek, Kimi K2.5, and others
|
||||
|
||||
### Application-Level Window Controls
|
||||
|
||||
- Opt-in setting to render CC Switch's own minimize / toggle-maximize / close buttons instead of system decorations (#1119, thanks @git1677967754)
|
||||
- Materially improves the experience on Linux Wayland where compositor-drawn buttons can become inert
|
||||
|
||||
### Hermes in Unified Skills Management
|
||||
|
||||
- Hermes is added to the unified Skills surface
|
||||
- Skill install, enable, and filter now cover the Hermes app alongside Claude / Codex / Gemini / OpenCode / OpenClaw
|
||||
|
||||
### OpenClaw Config Directory Override
|
||||
|
||||
- New settings option to point CC Switch at a custom `openclaw.json` location (#1518, thanks @mrFranklin)
|
||||
|
||||
### Hermes Config Directory Override
|
||||
|
||||
- New settings option to point CC Switch at a custom `~/.hermes/config.yaml` location, backed by data-driven dispatch
|
||||
|
||||
### StepFun Step Plan Preset
|
||||
|
||||
- StepFun Step Plan (EN / ZH) provider presets (#2155, thanks @hengm3467)
|
||||
|
||||
### New API Usage Script Template
|
||||
|
||||
- Added a User-Agent header to the New API usage script template for better upstream compatibility
|
||||
|
||||
### LemonData Provider Preset (All Six Apps)
|
||||
|
||||
- LemonData registered as a third-party partner preset across Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes
|
||||
- Icon assets and zh / en / ja partner-promotion copy
|
||||
- Claude preset uses `ANTHROPIC_API_KEY` auth; OpenAI-compatible apps target `gpt-5.4`
|
||||
|
||||
### DDSHub Codex Preset
|
||||
|
||||
- Added a Codex-compatible endpoint for DDSHub at the same host as its Claude service
|
||||
- Base URL omits the `/v1` suffix because the gateway auto-routes OpenAI SDK paths
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### "Local Proxy Takeover" → "Local Routing"
|
||||
|
||||
- Unified the terminology across UI copy, README, and docs in all three locales
|
||||
- Functional behavior is unchanged
|
||||
|
||||
### Hermes `Auto` api_mode Removed
|
||||
|
||||
- Users must pick an explicit protocol; new deeplinks default to `chat_completions`
|
||||
- Eliminates URL-based heuristic surprises
|
||||
|
||||
### Hermes Provider Form
|
||||
|
||||
- Added an API mode dropdown and per-provider model editor
|
||||
- Binds per-provider models to the top-level `model:` when switching active providers
|
||||
|
||||
### Hermes Deep Config Delegation
|
||||
|
||||
- Deep YAML knobs are no longer duplicated in the CC Switch form — they are delegated to the Hermes Web UI via a direct launch action
|
||||
|
||||
### Hermes Toolbar Layout
|
||||
|
||||
- Swapped the Hermes Web UI button from `ExternalLink` to `LayoutDashboard` (clicking may spawn `hermes dashboard` rather than just opening a URL)
|
||||
- Moved MCP to the final toolbar slot so Hermes matches the Claude / Codex / Gemini / OpenCode layout
|
||||
|
||||
### `ANTHROPIC_REASONING_MODEL` Removed from Claude Quick-Set
|
||||
|
||||
- Decoupled the reasoning capability from model selection; the legacy field is no longer surfaced in the quick-set form
|
||||
|
||||
### Per-Provider Proxy Config Removed
|
||||
|
||||
- Consolidated into global Local Routing
|
||||
- Provider-level proxy toggle and associated storage are gone
|
||||
|
||||
### Unified Toolbar Icon Button Width
|
||||
|
||||
- Normalized icon-button widths across Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes panels for a consistent header look
|
||||
|
||||
### Rust Toolchain Pinned to 1.95
|
||||
|
||||
- Adopted clippy 1.95 suggestions across the workspace and pinned the toolchain to prevent nightly drift
|
||||
|
||||
### Tray Menu ID Constant
|
||||
|
||||
- The tray identifier moved from the hardcoded string `"main"` to a `TRAY_ID` constant (`"cc-switch"`) across all call sites (#1978, thanks @lidaxian121)
|
||||
|
||||
### Copilot Premium Consumption Deep Optimization
|
||||
|
||||
A systematic overhaul to reduce Copilot reverse-proxy premium interaction consumption across multiple dimensions:
|
||||
|
||||
- **Proactive Thinking Block Stripping Before Forwarding**: Anthropic's `thinking` / `redacted_thinking` blocks are rejected by OpenAI-compatible endpoints. Previously, the request failed upstream, burning one premium interaction before the `thinking_rectifier` could retry. A new proactive strip step (Copilot optimization pipeline step 3.5, after `tool_result` merging) eliminates that wasted interaction
|
||||
- **Request Classification Fix**: Messages containing `tool_result` are now classified as agent continuation instead of user-initiated, preventing every tool call from being falsely counted as a premium interaction
|
||||
- **Subagent Detection**: Identifies subagents via `__SUBAGENT_MARKER__` with `metadata._agent_` fallback, setting `x-interaction-type=conversation-subagent`
|
||||
- **Deterministic `x-interaction-id` Billing Merge**: Derives `x-interaction-id` from the session ID so multiple requests within the same session collapse into a single billing interaction
|
||||
- **Orphan `tool_result` Sanitization**: Cleans up orphan `tool_result` entries to prevent upstream errors that would trigger retries and duplicate billing
|
||||
- **Warmup Downgrade Enabled by Default**: Uses `gpt-5-mini` as the default downgrade model
|
||||
- **Optimization Pipeline Reorder**: classify → sanitize → merge → warmup, so classification sees raw `tool_result` semantics
|
||||
- Fixed a `CopilotOptimizerConfig` default-value inconsistency (unified to `gpt-5-mini`)
|
||||
|
||||
### Usage Script Intranet Support
|
||||
|
||||
- Removed private-IP / suspicious-hostname blocking from usage scripts, unblocking enterprise intranet, Docker, and self-hosted API endpoints
|
||||
- Built-in templates still enforce HTTPS (except localhost) and same-origin checks; custom templates remain user-controlled with those request-URL checks skipped
|
||||
|
||||
### Failover Queue Notes
|
||||
|
||||
- Provider notes now appear in failover queue selectors and queue rows for easier identification across multi-provider queues (#2138, thanks @Coconut-Fish)
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Header Auto-Compact Latching After Maximize
|
||||
|
||||
- The toolbar no longer stays compacted after maximize/restore; compaction now reevaluates on size changes
|
||||
|
||||
### Hermes YAML Pollution & OAuth MCP `auth` Drop
|
||||
|
||||
- Round-tripping through CC Switch no longer drops OAuth MCP `auth` blocks or pollutes unrelated YAML keys
|
||||
- Guard tests added via `tests/hermes_roundtrip.rs`
|
||||
|
||||
### Hermes Active Provider Display
|
||||
|
||||
- Hermes UI now correctly surfaces the active provider and wires add / enable / remove actions
|
||||
|
||||
### Hermes Provider Persistence
|
||||
|
||||
- Providers persist under `custom_providers:` so `api_mode` and `model` survive restarts and config reloads
|
||||
|
||||
### Hermes Health Check Borrowing OpenClaw Schema
|
||||
|
||||
- Hermes providers were routed through `check_additive_app_stream` (the OpenClaw dispatcher), which reads camelCase `baseUrl` / `apiKey` / `api` and surfaced "OpenClaw provider is missing baseUrl" even when every Hermes field was filled
|
||||
- Introduced `check_hermes_stream` with Hermes-specific extractors that map `api_mode` (`chat_completions` / `anthropic_messages` / `codex_responses`) to the matching `check_claude_stream` `api_format`; `bedrock_converse` returns as unsupported
|
||||
- `api_mode` is now resolved before URL / API key extraction, so `bedrock_converse` users see the real cause rather than a misleading "missing base_url"
|
||||
|
||||
### Usage Query Modal for Hermes & OpenClaw
|
||||
|
||||
- `getProviderCredentials` now reads flat `settingsConfig` fields for Hermes (snake_case `base_url` / `api_key`) and OpenClaw (camelCase `baseUrl` / `apiKey`), so the "official balance" template auto-selects for matching providers like SiliconFlow
|
||||
- Refactored the BALANCE and TOKEN_PLAN test paths to reuse the precomputed `providerCredentials` instead of re-reading `env.ANTHROPIC_*` directly, fixing the "empty key" error for non-Claude apps even when the key was configured
|
||||
|
||||
### Codex `cache_control` Preservation
|
||||
|
||||
- Preserve `cache_control` when merging system prompts during Codex format conversion (#1946, thanks @yovinchen)
|
||||
|
||||
### Claude Prompt Cache Key Leak
|
||||
|
||||
- Stopped sending prompt cache keys during Claude chat conversions (#2003, thanks @yovinchen)
|
||||
|
||||
### Proxy Hop-by-Hop Header Stripping
|
||||
|
||||
- Strip hop-by-hop response headers (Connection, Keep-Alive, Transfer-Encoding, etc.) per RFC 7230 (#2060, thanks @yovinchen)
|
||||
|
||||
### Permissive Proxy CORS Removed
|
||||
|
||||
- Removed the permissive CORS layer from the proxy (#1915, thanks @zerone0x)
|
||||
|
||||
### Backend Error Details in Proxy Toast
|
||||
|
||||
- Surface backend error payload details in proxy-related toast messages instead of a generic failure string
|
||||
|
||||
### Usage Log Deduplication
|
||||
|
||||
- Deduplicated proxy and session-log usage records so the same request is no longer double-counted
|
||||
- Synced the request log time range with the dashboard's 1d / 7d / 30d selector
|
||||
|
||||
### Common Config Checkbox Persistence
|
||||
|
||||
- Checkbox state for Claude / Codex / Gemini common-config toggles now persists correctly across reopens (#2191, thanks @zxZeng)
|
||||
|
||||
### Claude Plugin `settings.json` Sync
|
||||
|
||||
- Editing the current provider now syncs back to `settings.json` for the Claude plugin path (#1905, thanks @chengww5217)
|
||||
|
||||
### Google Official Gemini Env Preservation
|
||||
|
||||
- Saving the Google Official Gemini provider no longer clobbers the `env` block
|
||||
|
||||
### OpenCode JSON5 Parser for Trailing Commas
|
||||
|
||||
- OpenCode config reads now tolerate trailing commas via a JSON5 parser (#2023, thanks @wwminger)
|
||||
|
||||
### Preset Refreshes
|
||||
|
||||
- Refreshed stale context windows for DeepSeek and Claude 1M
|
||||
- Refreshed stale model IDs; backfilled Hermes model lists
|
||||
- Fixed the Nous endpoint and replaced the Hermes placeholder icon with Nous brand artwork
|
||||
- Pruned unused official Hermes presets
|
||||
|
||||
### Auto-Expand Collapsed Messages on Search Hit
|
||||
|
||||
- Collapsed messages now auto-expand when a search match lands inside hidden content
|
||||
|
||||
### Unknown Subscription Quota Tiers Hidden
|
||||
|
||||
- Provider cards no longer render unknown subscription quota tiers
|
||||
|
||||
### Weekly Limit Label Unified
|
||||
|
||||
- Aligned the `weekly_limit` tier label with the official 7-day naming across locales
|
||||
|
||||
### Root-Level Skill Repo Install
|
||||
|
||||
- Fixed skill installation when the repository root itself is a skill
|
||||
|
||||
### Session ID Parsing Clippy
|
||||
|
||||
- Removed a redundant closure in session ID parsing (clippy warning)
|
||||
|
||||
### Stream Check Default Models Refresh
|
||||
|
||||
- Updated stream-check default probe models to match each vendor's current lineup
|
||||
|
||||
### Skills Import Sync
|
||||
|
||||
- Imported Skills are now immediately synced into enabled app directories instead of only being recorded in the database (#2101, thanks @yaoguohh)
|
||||
- The UI no longer shows "installed" while the target app directory is missing the skill
|
||||
|
||||
### Ghostty Session Restore
|
||||
|
||||
- Fixed Ghostty session restore launch by using shell execution with `--working-directory` (#1976, thanks @Suda202)
|
||||
- Avoids `cwd` escaping issues when the path contains spaces or special characters
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
### README Sponsor Updates
|
||||
|
||||
- Updated SiliconFlow signup bonus to ¥16
|
||||
- Trimmed the SSSAiCode sponsor blurb
|
||||
- Updated partner logos
|
||||
- Added LemonData as a new sponsor
|
||||
|
||||
### Global Proxy Hint Clarified
|
||||
|
||||
- Clarified the global proxy hint about local routing across all three locales
|
||||
|
||||
### Takeover → Routing Rename
|
||||
|
||||
- Renamed takeover docs to routing and updated anchors across all languages
|
||||
|
||||
### PIPELLM Website URL
|
||||
|
||||
- Updated the PIPELLM sponsor website URL to `code.pipellm.ai`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Breaking Changes
|
||||
|
||||
### Hermes requires explicit `api_mode`
|
||||
|
||||
- The `Auto` mode is gone; imported or deeplinked providers default to `chat_completions`
|
||||
- Users with prior `Auto` configs will be prompted to pick a protocol
|
||||
|
||||
### `ANTHROPIC_REASONING_MODEL` removed from Claude quick-set
|
||||
|
||||
- The legacy field is no longer exposed; existing settings are cleaned up automatically
|
||||
|
||||
### Per-provider proxy configuration removed
|
||||
|
||||
- Migrate to the global Local Routing setting
|
||||
- Existing per-provider proxy values are ignored
|
||||
|
||||
### Database schema v9 → v10
|
||||
|
||||
- Adds `enabled_hermes` columns to `mcp_servers` and `skills`
|
||||
- Auto-migrated with `DEFAULT 0`; no data loss
|
||||
|
||||
### Pricing table reseeded (v8 → v9)
|
||||
|
||||
- The `model_pricing` table is cleared and reseeded on first launch to pick up new models and corrected prices
|
||||
|
||||
### XCodeAPI preset removed
|
||||
|
||||
- Users of the XCodeAPI preset should switch to another provider
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risk Notice
|
||||
|
||||
This release inherits the risk notices originally introduced in v3.12.3 / v3.13.0 for reverse-proxy-style features.
|
||||
|
||||
**GitHub Copilot Reverse Proxy**: Using Copilot's reverse-proxy path may violate GitHub / Microsoft's terms of service. See [v3.12.3 release notes](v3.12.3-en.md#️-risk-notice).
|
||||
|
||||
**Codex OAuth Reverse Proxy**: Using the Codex OAuth reverse proxy with a ChatGPT subscription may violate OpenAI's terms of service. See [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice).
|
||||
|
||||
By enabling these features, users **accept all associated risks**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions that result from using these features.
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| OS | Minimum Version | Architecture |
|
||||
| ------- | ----------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ----------------------------------------------- |
|
||||
| `CC-Switch-v3.14.0-Windows.msi` | **Recommended** - MSI installer, supports auto-update |
|
||||
| `CC-Switch-v3.14.0-Windows-Portable.zip` | Portable, extract and run, no registry writes |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | -------------------------------------------------------- |
|
||||
| `CC-Switch-v3.14.0-macOS.dmg` | **Recommended** - DMG installer, drag into Applications |
|
||||
| `CC-Switch-v3.14.0-macOS.zip` | Extract and drag into Applications, Universal Binary |
|
||||
| `CC-Switch-v3.14.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> macOS builds are Apple code-signed and notarized — install directly.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended | Installation |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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` | Add execute permission and run, or use AUR |
|
||||
| Other distros / not sure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,469 @@
|
||||
# CC Switch v3.14.0
|
||||
|
||||
> Hermes Agent が 6 番目の管理対象アプリに、Claude Opus 4.7 をプリセットマトリクス全体へ展開、Gemini Native API プロキシ、「Local Routing」への名称統一、アプリケーションレベルのウィンドウコントロール
|
||||
|
||||
**[中文版 →](v3.14.0-zh.md) | [English →](v3.14.0-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.14.0 は、**Hermes Agent を 6 番目の一等管理対象アプリケーション**として CC Switch に取り込み、**Claude Opus 4.7** をアグリゲーターおよび Bedrock プリセットのマトリクス全体に展開することを中心に据えた大型リリースです。Hermes サポートは、データベース v9 → v10 マイグレーション、完全な Rust コマンド面、アトミックバックアップ付きの YAML ベースな `~/.hermes/config.yaml` 読み書き、MCP 同期、Skills 同期、SQLite + JSONL セッション管理、および Memory エディターを含む専用のフロントエンドパネルをカバーします。Hermes Agent 0.10.0 スキーマに整合する 4 つの API プロトコル(`chat_completions`、`anthropic_messages`、`codex_responses`、`bedrock_converse`)すべてを選択可能です。ユーザーが直接記述した `providers:` dict のエントリは読み取り専用カードとして表示され、深い YAML 設定は Hermes Web UI に委譲されます。
|
||||
|
||||
Hermes に加えて、本リリースでは **Gemini Native API プロキシ**(`api_format = "gemini_native"`)を追加し、プロキシがリクエストを Google の `generateContent` エンドポイントに直接転送できるようにしました(完全なストリーミング、スキーマ変換、シャドウリクエストをサポート)。また、旧「Local Proxy Takeover」を三言語の UI / README / ドキュメント全体で **Local Routing** に統一リネームし、コンポジターが描画するボタンが無反応になり得る Linux Wayland などのシーンで、CC Switch が自前で最小化 / 最大化 / 閉じるボタンを描画できるオプション「**アプリケーションレベルのウィンドウコントロール**」を導入しました。さらにリリース直前に、ツールバーからの `hermes dashboard` 直接起動、LemonData の全アプリプリセット、DDSHub の Codex エンドポイント、および複数の Hermes ヘルスチェックと Usage モーダルの修正が追加されました。
|
||||
|
||||
セッション側では、`@tanstack/react-virtual` によるセッションリストの**仮想化**で数千件のレコードを持つ長い会話も滑らかにスクロールでき、長いメッセージはデフォルトで折り畳まれます。Usage ダッシュボードには**日付範囲ピッカー**(今日 / 1d / 7d / 14d / 30d + カスタム日時カレンダー)とページジャンプ入力が追加され、**Stream Check エラー分類**は色分けされたトーストで提示され、デフォルトの探索モデルが更新され、「モデルが見つからない」レスポンスを個別に識別するようになりました。また、Local Routing が有効な間に公式プロバイダーへの切り替えを**強制的にブロック**する保護を追加し、公式 API トラフィックがローカルプロキシを経由することによるアカウント停止リスクを防ぎます。Pricing データベースは v8 → v9 で再シードされ、約 50 件の新しいモデルエントリ(Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 など)を追加し、いくつかの古い価格を修正しました。
|
||||
|
||||
**リリース日**: 2026-04-21
|
||||
|
||||
**更新規模**: 100 commits | 219 files changed | +20,548 / -3,569 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **Hermes Agent サポート(6 番目の管理対象アプリ)**: データベース v9 → v10 マイグレーション、完全な Rust コマンド面、アトミックバックアップ付き YAML 読み書き、MCP 同期、Skills 同期、SQLite + JSONL セッション管理、専用フロントエンドパネル、4 つの API プロトコル(`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`)
|
||||
- **Claude Opus 4.7 の全面展開**: 適応的思考のホワイトリスト、百万トークン単位の価格シード、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`、旧 `-v1` サフィックスを廃止)、全アグリゲーター / Bedrock プリセットを Opus 4.7 をデフォルト Opus モデルに移行
|
||||
- **Claude `max` エフォートティア**: エフォートのドロップダウンを `high` から `max` に引き上げ
|
||||
- **Gemini Native API プロキシ**: 新しい `api_format = "gemini_native"` により、プロキシが Google の `generateContent` に直接転送可能に(完全なストリーミング / スキーマ変換 / シャドウリクエスト対応)
|
||||
- **GitHub Copilot Enterprise Server**: Copilot ベースの Claude プロバイダーに GHES 認証とエンドポイント設定を追加
|
||||
- **Copilot 交互消費の大幅最適化**: 転送前の thinking ブロック主動削除、`tool_result` メッセージ分類修正、subagent 検出、`x-interaction-id` 課金マージ、孤立 `tool_result` のサニタイズ、Warmup ダウングレードのデフォルト有効化など、premium 交互消費を系統的に削減
|
||||
- **セッションリスト仮想化**: 長い会話が滑らかにスクロール。長いメッセージはデフォルトで折り畳まれ、テキストレイアウトコストを削減
|
||||
- **Codex / OpenClaw セッションタイトル抽出**: 意味のあるタイトルを自動抽出(2 行表示)、OpenClaw の `message_id` 末尾ノイズを除去
|
||||
- **Usage 日付範囲ピッカー**: Today / 1d / 7d / 14d / 30d プリセットタブ + カスタム日時カレンダー。ページネーションリストにページジャンプ入力
|
||||
- **Stream Check エラー分類**: エラーを分類し色分けトーストで提示。デフォルト探索モデル更新。「モデルが見つからない」レスポンスを明示的に検出
|
||||
- **Local Routing 有効時の公式プロバイダー切り替えブロック**: 公式 API トラフィックをローカルプロキシ経由で流すとアカウント停止のリスクがあるため、切り替えを強制ブロックして警告トーストを表示
|
||||
- **Pricing データベース刷新(v8 → v9)**: 約 50 件の新しいモデルエントリを追加し、古い価格を修正
|
||||
- **アプリケーションレベルのウィンドウコントロール**: CC Switch が自前で最小化 / 最大化トグル / 閉じるボタンを描画するオプション設定。Linux Wayland での体験を大きく改善
|
||||
- **統一 Skills 管理への Hermes 追加**: Skill のインストール / 有効化 / フィルターが Hermes をカバー
|
||||
- **Hermes / OpenClaw 設定ディレクトリのカスタマイズ**: 設定で `~/.hermes/config.yaml` や `openclaw.json` のカスタム位置を指定可能
|
||||
- **ツールバーからの Hermes Dashboard 起動**: Hermes Web UI のプローブに失敗した際、ツールバーエントリからユーザーの優先ターミナルで `hermes dashboard` を実行可能
|
||||
- **新パートナープリセット**: LemonData を全 6 アプリにわたって追加、DDSHub の Codex エンドポイント、StepFun Step Plan
|
||||
|
||||
---
|
||||
|
||||
## 新機能
|
||||
|
||||
### Hermes Agent サポート(6 番目の管理対象アプリ)
|
||||
|
||||
CC Switch は Hermes Agent を Claude / Codex / Gemini / OpenCode / OpenClaw と並ぶ一等の管理対象アプリとして初めてサポートします。
|
||||
|
||||
- **データベースマイグレーション v9 → v10**: `mcp_servers` と `skills` テーブルに `enabled_hermes` カラムを追加(`DEFAULT 0`、自動マイグレーション、データ損失なし)
|
||||
- **YAML 設定の読み書き**: `~/.hermes/config.yaml` をアトミックバックアップ付きで読み書き。`tests/hermes_roundtrip.rs` が OAuth MCP `auth` ブロックの消失や無関係なキーの汚染を防止
|
||||
- **4 つの API プロトコル**: Hermes Agent 0.10.0 と整合する `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`。新しいディープリンクはデフォルトで `chat_completions`
|
||||
- **ユーザー `providers:` dict の読み取り専用表示**: YAML に手書きされたプロバイダーエントリは CC Switch で読み取り専用カードとして表示され、深い設定は Hermes Web UI に委譲
|
||||
- **加算的な切り替え**: Claude / Codex の「上書き」型切り替えと異なり、Hermes ではすべてのプロバイダーが同じ YAML に共存
|
||||
|
||||
### Hermes Memory パネル
|
||||
|
||||
- `MEMORY.md` / `USER.md` を直接編集できる Memory パネルを追加(有効化スイッチ、文字数制限、ライブ保存フロー付き)
|
||||
- Hermes の Prompts エントリを置き換え
|
||||
|
||||
### Hermes プロバイダープリセット(約 50 個)
|
||||
|
||||
- Nous Research、Shengsuanyun(胜算云)、OpenRouter、DeepSeek、Together AI、StepFun、Zhipu GLM、Bailian(百炼)、Kimi、MiniMax、DouBao(豆包)、BaiLing(百灵)、ModelScope(魔搭)、KAT-Coder、PackyCode、Cubence、AIGoCode、RightCode、AICodeMirror、AICoding、CrazyRouter、SSSAiCode、Micu、CTok.ai、DDSHub、E-FlowCode、LionCCAPI、PIPELLM、Compshare、SiliconFlow、AiHubMix、DMXAPI、TheRouter、Novita、Nvidia、Xiaomi MiMo をカバー
|
||||
|
||||
### ツールバーからの Hermes Dashboard 起動
|
||||
|
||||
- Hermes Web UI のプローブに失敗した際、ツールバーエントリがユーザーの優先ターミナルで `hermes dashboard` を実行する確認ダイアログを表示
|
||||
- 一時 bash / batch スクリプト経由で起動。`hermes dashboard` 自身が準備完了後にブラウザを開くため、ポーリングは不要
|
||||
- Memory パネルと Health バナーは既存のトースト動作を維持
|
||||
- オフラインのトーストにあった古い `hermes web` のヒントも修正(正しいコマンドは `hermes dashboard`)
|
||||
- Linux ターミナル検出の順序を変更し、`/usr/bin`、`/bin`、`/usr/local/bin` を stat する前に `which` を試すように
|
||||
|
||||
### Claude Opus 4.7 サポート
|
||||
|
||||
- Claude Opus 4.7 を追加。適応的思考のホワイトリスト、百万トークン単位の価格シード、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`、旧 `-v1` サフィックスを廃止)
|
||||
- 全アグリゲーター / Bedrock プリセットをデフォルト Opus モデルとして Opus 4.7 に移行
|
||||
|
||||
### Claude `max` エフォートティア
|
||||
|
||||
- Claude エフォートドロップダウンを `high` から `max` に引き上げ、より強力な推論容量を解放
|
||||
|
||||
### Gemini Native API プロキシ
|
||||
|
||||
- 新しい `api_format = "gemini_native"` により、プロキシが Google の `generateContent` API に直接転送可能 (#1918, 感謝 @yovinchen)
|
||||
- 完全なストリーミング、スキーマ変換、シャドウリクエストに対応
|
||||
- proxy providers モジュール下に `gemini_url.rs`、`gemini_schema.rs`、`gemini_shadow.rs`、`streaming_gemini.rs`、`transform_gemini.rs` を追加
|
||||
|
||||
### GitHub Copilot Enterprise Server(GHES)
|
||||
|
||||
- Copilot ベースの Claude プロバイダーに GHES 認証とエンドポイント設定を追加 (#2175, 感謝 @hotelbe)
|
||||
|
||||
### セッションリスト仮想化
|
||||
|
||||
- `@tanstack/react-virtual` によりセッションリストを仮想化。数千件のレコードを持つ長い会話も滑らかにスクロール
|
||||
- 長いセッションメッセージはデフォルトで折り畳まれ、テキストレイアウトコストを削減
|
||||
|
||||
### Codex / OpenClaw セッションタイトル抽出
|
||||
|
||||
- Codex と OpenClaw セッションから意味のあるタイトルを自動抽出し、2 行表示
|
||||
- OpenClaw の `message_id` 末尾ノイズを除去
|
||||
|
||||
### Usage 日付範囲ピッカー
|
||||
|
||||
- Usage ダッシュボードに日付範囲セレクターを追加。プリセットタブ(Today / 1d / 7d / 14d / 30d)+ カスタム日時カレンダー (#2002, 感謝 @yovinchen)
|
||||
- ページネーションリストにページジャンプ入力を追加
|
||||
|
||||
### モデルマッピングのクイック入力
|
||||
|
||||
- プロバイダーフォームのモデルマッピングフィールドの横にクイック入力ボタンを追加し、編集を高速化 (#2179, 感謝 @lispking)
|
||||
|
||||
### Stream Check エラー分類
|
||||
|
||||
- Stream Check エラーを分類し、色分けトーストとして提示
|
||||
- デフォルトの探索モデルを各ベンダーの現行ラインナップに合わせて更新
|
||||
- 「モデルが見つからない」レスポンスを明示的に検出
|
||||
|
||||
### Local Routing 有効時の公式プロバイダー切り替えブロック
|
||||
|
||||
- Local Routing が有効な状態で公式プロバイダーに切り替えようとすると、強制的にブロックされ警告トーストが表示される
|
||||
- 理由: 公式 API トラフィックをローカルプロキシ経由で流すとアカウント停止のリスクがあるため
|
||||
|
||||
### Pricing データベース刷新(v8 → v9)
|
||||
|
||||
- マイグレーション時に定価テーブルを再シード
|
||||
- Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 などを含む約 50 件の新しいモデルエントリを追加
|
||||
- DeepSeek、Kimi K2.5 などの古い価格を修正
|
||||
|
||||
### アプリケーションレベルのウィンドウコントロール
|
||||
|
||||
- CC Switch が自前で最小化 / 最大化トグル / 閉じるボタンを描画するオプション設定を追加。システム装飾の代わりに使用 (#1119, 感謝 @git1677967754)
|
||||
- コンポジター描画ボタンが無反応になり得る Linux Wayland での体験を大きく改善
|
||||
|
||||
### 統一 Skills 管理への Hermes 追加
|
||||
|
||||
- 統一 Skills サーフェスに Hermes を追加
|
||||
- Skill のインストール / 有効化 / フィルターが、Claude / Codex / Gemini / OpenCode / OpenClaw と並んで Hermes アプリをカバー
|
||||
|
||||
### OpenClaw 設定ディレクトリのカスタマイズ
|
||||
|
||||
- CC Switch が参照する `openclaw.json` のカスタム位置を設定できるオプションを追加 (#1518, 感謝 @mrFranklin)
|
||||
|
||||
### Hermes 設定ディレクトリのカスタマイズ
|
||||
|
||||
- CC Switch が参照する `~/.hermes/config.yaml` のカスタム位置を設定できるオプションを追加。データ駆動 dispatch でサポート
|
||||
|
||||
### StepFun Step Plan プリセット
|
||||
|
||||
- StepFun Step Plan(EN / ZH)プロバイダープリセットを追加 (#2155, 感謝 @hengm3467)
|
||||
|
||||
### New API 用量スクリプトテンプレート
|
||||
|
||||
- New API の用量スクリプトテンプレートに User-Agent ヘッダーを追加し、上流互換性を向上
|
||||
|
||||
### LemonData プロバイダープリセット(全 6 アプリ)
|
||||
|
||||
- LemonData をサードパーティパートナープリセットとして Claude、Codex、Gemini、OpenCode、OpenClaw、Hermes の全 6 アプリに登録
|
||||
- アイコンアセットと zh / en / ja 三言語のパートナー推奨文面を追加
|
||||
- Claude プリセットは `ANTHROPIC_API_KEY` 認証を使用。OpenAI 互換アプリは `gpt-5.4` をターゲット
|
||||
|
||||
### DDSHub Codex プリセット
|
||||
|
||||
- DDSHub の Codex 互換エンドポイントを追加(Claude サービスと同じホスト)
|
||||
- ベース URL は `/v1` サフィックスを省略(ゲートウェイが OpenAI SDK パスを自動ルーティング)
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### 「Local Proxy Takeover」→「Local Routing」
|
||||
|
||||
- 三言語の UI 文言、README、ドキュメント全体で用語を統一リネーム
|
||||
- 機能的な動作は変更なし
|
||||
|
||||
### Hermes `Auto` api_mode の削除
|
||||
|
||||
- ユーザーは明示的にプロトコルを選択する必要あり。新しいディープリンクはデフォルトで `chat_completions`
|
||||
- URL ベースのヒューリスティックによる意外な挙動を排除
|
||||
|
||||
### Hermes プロバイダーフォーム
|
||||
|
||||
- API モードドロップダウンとプロバイダー単位のモデルエディターを追加
|
||||
- アクティブなプロバイダーを切り替える際、プロバイダー単位のモデルをトップレベルの `model:` にバインド
|
||||
|
||||
### Hermes 深い設定の委譲
|
||||
|
||||
- 深い YAML 設定は CC Switch フォームで重複させず、「Hermes Web UI を起動」ボタン経由で Web UI に直接委譲
|
||||
|
||||
### Hermes ツールバーレイアウト
|
||||
|
||||
- Hermes Web UI ボタンのアイコンを `ExternalLink` から `LayoutDashboard` に変更(クリック時に単に URL を開くのではなく `hermes dashboard` を起動する場合があるため、パネル型アイコンのほうが意味的に正確)
|
||||
- MCP をツールバーの末尾に移動し、Hermes のレイアウトを Claude / Codex / Gemini / OpenCode と揃える
|
||||
|
||||
### Claude Quick-Set から `ANTHROPIC_REASONING_MODEL` を削除
|
||||
|
||||
- 推論能力とモデル選択を分離。レガシーフィールドは Quick-Set フォームから除外
|
||||
|
||||
### プロバイダー単位のプロキシ設定を削除
|
||||
|
||||
- グローバルな Local Routing に統合
|
||||
- プロバイダー単位のプロキシトグルと関連ストレージは削除済み
|
||||
|
||||
### ツールバーアイコンボタン幅の統一
|
||||
|
||||
- Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes パネルの間でアイコンボタン幅を正規化し、ヘッダーの見た目を統一
|
||||
|
||||
### Rust Toolchain を 1.95 にピン留め
|
||||
|
||||
- ワークスペース全体で clippy 1.95 の提案を採用し、nightly ドリフトを防ぐためツールチェーンをピン留め
|
||||
|
||||
### トレイメニュー ID 定数
|
||||
|
||||
- トレイ識別子をハードコーディング文字列 `"main"` から `TRAY_ID` 定数(`"cc-switch"`)に移行。すべての呼び出し箇所で同期 (#1978, 感謝 @lidaxian121)
|
||||
|
||||
### Copilot 交互消費の大幅最適化
|
||||
|
||||
Copilot リバースプロキシの premium 交互消費を削減するための系統的な最適化。以下の複数の改善をカバー:
|
||||
|
||||
- **転送前に thinking ブロックを主動削除**: Anthropic の `thinking` / `redacted_thinking` ブロックは OpenAI 互換エンドポイントに拒否される。従来は上流でリクエストが失敗して premium 交互を 1 回消費した後、`thinking_rectifier` によってリトライされていた。新しい主動削除ステップ(Copilot 最適化パイプラインの 3.5 ステップ目、`tool_result` マージ後)により、この無駄な premium 消費を直接解消
|
||||
- **リクエスト分類の修正**: `tool_result` を含むメッセージをユーザー発起の新規リクエストではなく、エージェント継続として分類。ツール呼び出しが毎回 premium 交互としてカウントされる問題を防止
|
||||
- **subagent 検出**: `__SUBAGENT_MARKER__` と `metadata._agent_` フォールバックで subagent を識別し、`x-interaction-type=conversation-subagent` を設定
|
||||
- **決定論的 `x-interaction-id` による課金マージ**: セッション ID から `x-interaction-id` を導出し、同一セッション内の複数リクエストを 1 回の課金交互に統合
|
||||
- **孤立 `tool_result` のサニタイズ**: 孤立した `tool_result` を整理し、上流エラーによるリトライおよび重複課金を防止
|
||||
- **Warmup ダウングレードをデフォルトで有効化**: `gpt-5-mini` をデフォルトのダウングレードモデルとして使用
|
||||
- **最適化パイプラインの並び替え**: classify → sanitize → merge → warmup の順序で、分類が生の `tool_result` セマンティクスを参照可能に
|
||||
- `CopilotOptimizerConfig` のデフォルト値の不一致を修正(`gpt-5-mini` に統一)
|
||||
|
||||
### 用量スクリプトのイントラネットサポート
|
||||
|
||||
- 用量スクリプトからプライベート IP / 不審なホスト名のブロッキングを削除し、エンタープライズイントラネット、Docker、自己ホスト API エンドポイントを解放
|
||||
- ビルトインテンプレートは引き続き HTTPS(localhost を除く)と同一オリジンチェックを強制。カスタムテンプレートはユーザー制御のまま、リクエスト URL のチェックをスキップ
|
||||
|
||||
### Failover キューの備考表示
|
||||
|
||||
- プロバイダーの備考が failover キューセレクターとキュー行に表示され、マルチプロバイダーキューでの識別が容易に (#2138, 感謝 @Coconut-Fish)
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### 最大化後のツールバー自動折り畳みラッチ
|
||||
|
||||
- ウィンドウの最大化 / 復元後、ツールバーが折り畳まれたままになる問題を修正。折り畳み判定はサイズ変更時に再評価される
|
||||
|
||||
### Hermes YAML 汚染と OAuth MCP `auth` 消失
|
||||
|
||||
- CC Switch 経由でラウンドトリップしても OAuth MCP `auth` ブロックが消失したり、無関係な YAML キーが汚染されたりしなくなった
|
||||
- `tests/hermes_roundtrip.rs` をガードテストとして追加
|
||||
|
||||
### Hermes アクティブプロバイダー表示
|
||||
|
||||
- Hermes UI がアクティブプロバイダーを正しく表示するようになり、追加 / 有効化 / 削除アクションが正しく動作
|
||||
|
||||
### Hermes プロバイダーの永続化
|
||||
|
||||
- プロバイダーは `custom_providers:` の下に永続化され、`api_mode` と `model` が再起動 / 設定再読み込みを生き延びる
|
||||
|
||||
### Hermes ヘルスチェックが OpenClaw のスキーマを流用していた問題
|
||||
|
||||
- 以前 Hermes プロバイダーは `check_additive_app_stream`(OpenClaw のディスパッチャー)にルーティングされており、これは camelCase の `baseUrl` / `apiKey` / `api` を読むため、Hermes フィールドをすべて記入しても "OpenClaw provider is missing baseUrl" と表示されていた
|
||||
- `check_hermes_stream` を導入し、Hermes 専用のエクストラクターで `api_mode`(`chat_completions` / `anthropic_messages` / `codex_responses`)を対応する `check_claude_stream` の `api_format` にマッピング。`bedrock_converse` は非対応として返す
|
||||
- URL / API キーの抽出前に `api_mode` を解決することで、`bedrock_converse` を選んだユーザーには「missing base_url」という誤解を招くメッセージではなく実際の原因が表示される
|
||||
|
||||
### Hermes / OpenClaw 向け Usage クエリモーダル
|
||||
|
||||
- `getProviderCredentials` が Hermes(snake_case の `base_url` / `api_key`)と OpenClaw(camelCase の `baseUrl` / `apiKey`)のフラットな `settingsConfig` フィールドを読むようになり、SiliconFlow などマッチするプロバイダーで「official balance」テンプレートが自動選択される
|
||||
- BALANCE と TOKEN_PLAN テストパスをリファクタリングし、`env.ANTHROPIC_*` を直接再読するのではなく、事前計算された `providerCredentials` を再利用するように変更。これにより非 Claude アプリでキーが設定されていても「empty key」エラーが出ていた問題を修正
|
||||
|
||||
### Codex `cache_control` 保持
|
||||
|
||||
- Codex フォーマット変換中に system prompt をマージする際の `cache_control` を保持 (#1946, 感謝 @yovinchen)
|
||||
|
||||
### Claude プロンプトキャッシュキーのリーク
|
||||
|
||||
- Claude chat 変換時にプロンプトキャッシュキーを送信しないように修正 (#2003, 感謝 @yovinchen)
|
||||
|
||||
### プロキシ Hop-by-Hop レスポンスヘッダーの削除
|
||||
|
||||
- RFC 7230 に従ってプロキシレスポンスの hop-by-hop ヘッダー(Connection、Keep-Alive、Transfer-Encoding など)を削除 (#2060, 感謝 @yovinchen)
|
||||
|
||||
### プロキシの寛容な CORS レイヤー削除
|
||||
|
||||
- プロキシの寛容な CORS レイヤーを削除 (#1915, 感謝 @zerone0x)
|
||||
|
||||
### プロキシトーストでのバックエンドエラー詳細表示
|
||||
|
||||
- プロキシ関連のトーストメッセージで、汎用的な失敗文字列ではなくバックエンドのエラーペイロードの詳細を表示
|
||||
|
||||
### Usage ログの重複排除
|
||||
|
||||
- プロキシとセッションログの用量レコードを重複排除し、同じリクエストが二重にカウントされないように修正
|
||||
- リクエストログの時間範囲をダッシュボードの 1d / 7d / 30d セレクターと同期
|
||||
|
||||
### Common Config チェックボックスの永続化
|
||||
|
||||
- Claude / Codex / Gemini の common-config トグルのチェック状態が再オープンをまたいで正しく保持されるように修正 (#2191, 感謝 @zxZeng)
|
||||
|
||||
### Claude プラグイン `settings.json` 同期
|
||||
|
||||
- 現在のプロバイダーを編集すると、Claude プラグインパスの `settings.json` に同期されるように修正 (#1905, 感謝 @chengww5217)
|
||||
|
||||
### Google Official Gemini の env 保持
|
||||
|
||||
- Google Official Gemini プロバイダーを保存しても `env` ブロックが消えないように修正
|
||||
|
||||
### OpenCode の JSON5 による末尾カンマ解析
|
||||
|
||||
- OpenCode 設定読み取りが JSON5 パーサーにより末尾カンマを許容するように修正 (#2023, 感謝 @wwminger)
|
||||
|
||||
### プリセットの刷新
|
||||
|
||||
- DeepSeek と Claude 1M の古いコンテキストウィンドウを刷新
|
||||
- 古いモデル ID を刷新。Hermes のモデルリストをバックフィル
|
||||
- Nous エンドポイントを修正し、Hermes のプレースホルダーアイコンを Nous ブランドのアートワークに置き換え
|
||||
- 未使用の公式 Hermes プリセットを整理
|
||||
|
||||
### 検索ヒット時の折り畳みメッセージの自動展開
|
||||
|
||||
- 隠されたコンテンツ内部で検索マッチが発生した場合、折り畳みメッセージを自動展開してマッチを示す
|
||||
|
||||
### 不明なサブスクリプション配額ティアの非表示
|
||||
|
||||
- プロバイダーカードは不明なサブスクリプション配額ティアを表示しないように変更
|
||||
|
||||
### weekly_limit ラベルの統一
|
||||
|
||||
- `weekly_limit` ティアラベルを公式の「7 日」命名にロケール間で揃えた
|
||||
|
||||
### ルートレベルの Skill リポジトリインストール
|
||||
|
||||
- リポジトリのルート自体が skill の場合のインストール失敗を修正
|
||||
|
||||
### Session ID 解析の clippy 警告
|
||||
|
||||
- session ID 解析内の冗長なクロージャを削除(clippy 警告)
|
||||
|
||||
### Stream Check デフォルトモデルの刷新
|
||||
|
||||
- Stream Check のデフォルト探索モデルを各ベンダーの現行ラインナップに合わせて更新
|
||||
|
||||
### Skills インポートの同期
|
||||
|
||||
- インポートされた Skills はデータベースに記録されるだけでなく、有効化されたアプリディレクトリにも即座に同期されるように変更 (#2101, 感謝 @yaoguohh)
|
||||
- UI が「インストール済み」と表示しているのに対象アプリディレクトリに skill が存在しない状態を解消
|
||||
|
||||
### Ghostty セッション復元
|
||||
|
||||
- Ghostty セッション復元の起動を `--working-directory` 付きのシェル実行に変更 (#1976, 感謝 @Suda202)
|
||||
- パスにスペースや特殊文字が含まれる場合の `cwd` エスケープ問題を回避
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### README スポンサー更新
|
||||
|
||||
- SiliconFlow のサインアップボーナスを ¥16 に更新
|
||||
- SSSAiCode のスポンサー文面を簡潔化
|
||||
- パートナーロゴを更新
|
||||
- 新しいスポンサーとして LemonData を追加
|
||||
|
||||
### グローバルプロキシヒントの明確化
|
||||
|
||||
- 三言語でグローバルプロキシと Local Routing の関係を明確化
|
||||
|
||||
### Takeover → Routing ドキュメントのリネーム
|
||||
|
||||
- テイクオーバー関連ドキュメントを三言語で routing にリネームし、アンカーを同期更新
|
||||
|
||||
### PIPELLM ウェブサイト URL
|
||||
|
||||
- PIPELLM スポンサーのウェブサイト URL を `code.pipellm.ai` に更新
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要な変更(Breaking)
|
||||
|
||||
### Hermes は明示的な `api_mode` が必須
|
||||
|
||||
- `Auto` モードは廃止。インポートまたはディープリンクで取得したプロバイダーはデフォルトで `chat_completions`
|
||||
- 既存の `Auto` 設定のユーザーはプロトコルを選択するよう促される
|
||||
|
||||
### Claude Quick-Set から `ANTHROPIC_REASONING_MODEL` を削除
|
||||
|
||||
- レガシーフィールドは公開されなくなった。既存の設定は自動的にクリーンアップされる
|
||||
|
||||
### プロバイダー単位のプロキシ設定を削除
|
||||
|
||||
- グローバル Local Routing 設定に移行
|
||||
- 既存のプロバイダー単位のプロキシ値は無視される
|
||||
|
||||
### データベーススキーマ v9 → v10
|
||||
|
||||
- `mcp_servers` と `skills` に `enabled_hermes` カラムを追加
|
||||
- `DEFAULT 0` で自動マイグレーション、データ損失なし
|
||||
|
||||
### Pricing テーブルの再シード(v8 → v9)
|
||||
|
||||
- 新しいモデルと修正済み価格を取り込むため、初回起動時に `model_pricing` テーブルがクリアされ再シードされる
|
||||
|
||||
### XCodeAPI プリセットの削除
|
||||
|
||||
- XCodeAPI プリセットを使用していたユーザーは別のプロバイダーに切り替える必要がある
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ リスクに関する注意事項
|
||||
|
||||
本リリースは、リバースプロキシ型機能について v3.12.3 / v3.13.0 で提起された既存のリスク注意事項を継承します。
|
||||
|
||||
**GitHub Copilot リバースプロキシ**: Copilot のリバースプロキシパスを使用すると、GitHub / Microsoft の利用規約に違反する可能性があります。詳細は [v3.12.3 リリースノート](v3.12.3-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションで Codex OAuth リバースプロキシを使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 リリースノート](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
これらの機能を有効にすることで、ユーザーは**すべての関連リスクを自己責任で受諾**したものとみなされます。CC Switch はこれらの機能の使用に起因するアカウントの制限、警告、サービス停止について一切の責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロード・インストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から対応バージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| OS | 最小バージョン | アーキテクチャ |
|
||||
| ------- | ---------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
| `CC-Switch-v3.14.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.14.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ不要 |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | -------------------------------------------------------- |
|
||||
| `CC-Switch-v3.14.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications にドラッグ |
|
||||
| `CC-Switch-v3.14.0-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.14.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> macOS 版は Apple のコード署名および公証済みで、直接インストールして使用できます。
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
@@ -0,0 +1,468 @@
|
||||
# CC Switch v3.14.0
|
||||
|
||||
> Hermes Agent 成为第 6 个受管应用、Claude Opus 4.7 全面接入、Gemini Native API 代理、Local Routing 统一重命名、应用级窗口控件
|
||||
|
||||
**[English →](v3.14.0-en.md) | [日本語版 →](v3.14.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.14.0 是一次大版本更新,核心焦点是把 **Hermes Agent 作为第 6 个一等受管应用**接入 CC Switch,并把 **Claude Opus 4.7** 铺设到全部聚合器与 Bedrock 预设矩阵。Hermes 支持覆盖数据库 v9 → v10 迁移、完整的 Rust 命令面、基于 YAML 的 `~/.hermes/config.yaml` 读写(含原子备份)、MCP 同步、Skills 同步、SQLite + JSONL 会话管理,以及专属的前端面板和 Memory 编辑面板;与 Hermes Agent 0.10.0 schema 对齐的四种协议(`chat_completions`、`anthropic_messages`、`codex_responses`、`bedrock_converse`)全部可选。用户自行维护的 `providers:` dict 条目以只读卡片形式呈现,深度 YAML 配置则直接委托给 Hermes Web UI。
|
||||
|
||||
除了 Hermes,本次还新增了 **Gemini Native API 代理**(`api_format = "gemini_native"`),让代理可以把请求直接转发到 Google 的 `generateContent` 端点,完整支持流式、schema 转换和 shadow 请求;把老的 "Local Proxy Takeover" 在三语 UI / README / 文档中统一重命名为 **Local Routing**;新增 **应用级窗口控件**,在 Linux Wayland 等合成器绘制按钮失灵的场景下可选让 CC Switch 自绘最小化 / 最大化 / 关闭按钮;并在本版本发布前额外合入了从工具栏直接启动 `hermes dashboard`、LemonData 全应用预设、DDSHub Codex 端点以及若干 Hermes 健康检查与 Usage 模态框的修复。
|
||||
|
||||
会话侧通过 `@tanstack/react-virtual` **虚拟化会话列表**,让上千条记录的长会话也能流畅滚动,长消息默认折叠;Usage 面板新增**日期范围选择器**(今日 / 1d / 7d / 14d / 30d + 自定义日期时间)和翻页输入;**Stream Check 错误分类**以彩色 toast 呈现,默认探测模型重新梳理,"模型不存在"响应被单独识别;并新增在 Local Routing 激活时**阻止切换到官方供应商**的保护,以免官方流量被引入本地代理造成账号风险。Pricing 数据库 v8 → v9 重新种入约 50 个新模型条目(包括 Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 等),并修正了多项陈旧价格。
|
||||
|
||||
**发布日期**:2026-04-21
|
||||
|
||||
**更新规模**:100 commits | 219 files changed | +20,548 / -3,569 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **Hermes Agent 支持(第 6 个受管应用)**:数据库 v9 → v10 迁移、完整 Rust 命令面、YAML 读写带原子备份、MCP 同步、Skills 同步、SQLite + JSONL 会话管理、专属前端面板、四种 API 协议(`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`)
|
||||
- **Claude Opus 4.7 全面接入**:自适应思维白名单、按百万 token 定价种子、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`,丢弃老 `-v1` 后缀),全部聚合器 / Bedrock 预设升级为默认 Opus 模型
|
||||
- **Claude `max` 推理力度**:推理下拉从 `high` 升级到 `max`
|
||||
- **Gemini Native API 代理**:新增 `api_format = "gemini_native"`,代理可直达 Google `generateContent`,完整流式 / schema 转换 / shadow 请求
|
||||
- **GitHub Copilot 企业版**:为 Copilot 型 Claude 供应商新增 GHES 认证与端点配置
|
||||
- **Copilot 次数消耗深度优化**:转发前主动剥离 thinking 块、`tool_result` 消息归类修正、subagent 检测、`x-interaction-id` 合并计费、orphan `tool_result` 清理、默认启用 warmup 降级 —— 系统性降低 premium 交互消耗
|
||||
- **会话列表虚拟化**:长会话流畅滚动,长消息默认折叠降低文字布局成本
|
||||
- **Codex / OpenClaw 会话标题提取**:自动抽取有意义标题,两行显示,剥离 OpenClaw `message_id` 尾噪声
|
||||
- **Usage 日期范围选择器**:Today / 1d / 7d / 14d / 30d 预设 + 自定义日期时间日历;分页列表支持页码跳转输入
|
||||
- **Stream Check 错误分类**:错误按类别分色 toast;默认探测模型刷新;单独识别 "model not found"
|
||||
- **Local Routing 激活时阻止官方供应商切换**:官方流量走本地代理有账号暂停风险,强制拦截并 toast 警告
|
||||
- **Pricing 数据库刷新(v8 → v9)**:新增 ~50 条模型条目并修正陈旧价格
|
||||
- **应用级窗口控件**:可选让 CC Switch 自绘 min/max/close,显著改善 Linux Wayland 体验
|
||||
- **Hermes 接入统一 Skills 管理**:Skills 安装 / 启用 / 过滤现覆盖 Hermes
|
||||
- **Hermes / OpenClaw 配置目录自定义**:在设置里指定 `~/.hermes/config.yaml` 或 `openclaw.json` 的自定义位置
|
||||
- **从工具栏启动 Hermes Dashboard**:Web UI 探测失败时,点击可在用户首选终端中启动 `hermes dashboard`
|
||||
- **新合作伙伴预设**:LemonData 覆盖全部 6 个应用;DDSHub 新增 Codex 端点;StepFun Step Plan
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### Hermes Agent 支持(第 6 个受管应用)
|
||||
|
||||
CC Switch 首次支持 Hermes Agent 作为一等受管应用,与 Claude / Codex / Gemini / OpenCode / OpenClaw 并列。
|
||||
|
||||
- **数据库迁移 v9 → v10**:为 `mcp_servers` 和 `skills` 表新增 `enabled_hermes` 列(`DEFAULT 0` 自动迁移,无数据丢失)
|
||||
- **YAML 配置读写**:`~/.hermes/config.yaml` 读写带原子备份;`tests/hermes_roundtrip.rs` 守护不损坏不相关键和 OAuth MCP `auth` 块
|
||||
- **四种 API 协议**:与 Hermes Agent 0.10.0 对齐的 `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`;新 deeplink 默认为 `chat_completions`
|
||||
- **用户 `providers:` dict 只读呈现**:用户在 YAML 里手写的 providers 条目在 CC Switch 中以只读卡片展示,深度配置跳转到 Hermes Web UI
|
||||
- **累加式切换**:与 Claude / Codex 的"覆盖式"切换不同,Hermes 所有供应商共存于同一 YAML
|
||||
|
||||
### Hermes Memory 面板
|
||||
|
||||
- 新增 Memory 面板直接编辑 `MEMORY.md` / `USER.md`,带启用开关、字符数限制和保存流
|
||||
- 替换 Hermes 的 Prompts 入口
|
||||
|
||||
### Hermes 供应商预设(约 50 个)
|
||||
|
||||
- 覆盖 Nous Research、胜算云、OpenRouter、DeepSeek、Together AI、StepFun、智谱 GLM、百炼、Kimi、MiniMax、豆包、百灵、魔搭、KAT-Coder、PackyCode、Cubence、AIGoCode、RightCode、AICodeMirror、AICoding、CrazyRouter、SSSAiCode、Micu、CTok.ai、DDSHub、E-FlowCode、LionCCAPI、PIPELLM、Compshare、SiliconFlow、AiHubMix、DMXAPI、TheRouter、Novita、Nvidia、小米 MiMo
|
||||
|
||||
### 从工具栏启动 Hermes Dashboard
|
||||
|
||||
- Hermes Web UI 探测失败时,工具栏按钮改为弹出确认框,提供在用户首选终端里运行 `hermes dashboard`
|
||||
- 通过临时 bash / batch 脚本启动,`hermes dashboard` 就绪后自动打开浏览器,无需轮询
|
||||
- Memory 面板和 Health banner 保留原有 toast 行为
|
||||
- 顺便修正了离线 toast 里过时的 `hermes web` 提示(正确命令是 `hermes dashboard`)
|
||||
- Linux 终端探测改为先 `which` 后 stat,提升兼容性
|
||||
|
||||
### Claude Opus 4.7 支持
|
||||
|
||||
- 新增 Claude Opus 4.7 及其自适应思维白名单、按百万 token 定价种子、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`,丢弃老 `-v1` 后缀)
|
||||
- 全部聚合器 / Bedrock 预设升级为默认 Opus 模型
|
||||
|
||||
### Claude `max` 推理力度
|
||||
|
||||
- Claude 推理下拉从 `high` 升级到 `max`,解锁更强的思考容量
|
||||
|
||||
### Gemini Native API 代理
|
||||
|
||||
- 新增 `api_format = "gemini_native"`,代理可直接转发到 Google `generateContent` API (#1918, 感谢 @yovinchen)
|
||||
- 完整支持流式、schema 转换、shadow 请求
|
||||
- 在 proxy providers 模块下新增 `gemini_url.rs`、`gemini_schema.rs`、`gemini_shadow.rs`、`streaming_gemini.rs`、`transform_gemini.rs`
|
||||
|
||||
### GitHub Copilot 企业版(GHES)
|
||||
|
||||
- 为 Copilot 型 Claude 供应商新增 GHES 认证与端点配置 (#2175, 感谢 @hotelbe)
|
||||
|
||||
### 会话列表虚拟化
|
||||
|
||||
- 通过 `@tanstack/react-virtual` 虚拟化会话列表,上千条记录流畅滚动
|
||||
- 长会话消息默认折叠,减少文字布局开销
|
||||
|
||||
### Codex / OpenClaw 会话标题提取
|
||||
|
||||
- Codex 和 OpenClaw 会话自动抽取有意义的标题,两行显示
|
||||
- 剥离 OpenClaw `message_id` 后缀噪声
|
||||
|
||||
### Usage 日期范围选择器
|
||||
|
||||
- Usage 面板新增日期范围选择器,预设 Tab(Today / 1d / 7d / 14d / 30d)+ 自定义日期 + 时间日历 (#2002, 感谢 @yovinchen)
|
||||
- 分页列表新增页码跳转输入
|
||||
|
||||
### 模型映射快速填入
|
||||
|
||||
- 供应商表单的模型映射字段旁新增快速填入按钮,加快编辑 (#2179, 感谢 @lispking)
|
||||
|
||||
### Stream Check 错误分类
|
||||
|
||||
- 按类别为 Stream Check 错误上色并以 toast 呈现
|
||||
- 刷新所有厂商默认探测模型到当前主力机型
|
||||
- 对 "model not found" 响应做单独识别
|
||||
|
||||
### Local Routing 激活时阻止官方供应商切换
|
||||
|
||||
- 在 Local Routing 激活状态下,切换到官方供应商会被强制拦截并弹出警告 toast
|
||||
- 原因:官方 API 流量经由本地代理存在账号暂停风险
|
||||
|
||||
### Pricing 数据库刷新(v8 → v9)
|
||||
|
||||
- 迁移时重新种入定价表
|
||||
- 新增约 50 条模型条目,覆盖 Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1
|
||||
- 修正 DeepSeek、Kimi K2.5 等陈旧价格
|
||||
|
||||
### 应用级窗口控件
|
||||
|
||||
- 新增可选设置,让 CC Switch 自绘最小化 / 切换最大化 / 关闭按钮,代替系统装饰 (#1119, 感谢 @git1677967754)
|
||||
- 在合成器按钮可能失灵的 Linux Wayland 上显著改善体验
|
||||
|
||||
### Hermes 接入统一 Skills 管理
|
||||
|
||||
- 统一的 Skills 界面新增 Hermes
|
||||
- Skills 安装 / 启用 / 过滤现覆盖 Hermes,与 Claude / Codex / Gemini / OpenCode / OpenClaw 并列
|
||||
|
||||
### OpenClaw 配置目录自定义
|
||||
|
||||
- 新增设置项,允许把 CC Switch 指向自定义的 `openclaw.json` 位置 (#1518, 感谢 @mrFranklin)
|
||||
|
||||
### Hermes 配置目录自定义
|
||||
|
||||
- 新增设置项,允许把 CC Switch 指向自定义的 `~/.hermes/config.yaml` 位置,底层通过数据驱动 dispatch
|
||||
|
||||
### StepFun Step Plan 预设
|
||||
|
||||
- 新增 StepFun Step Plan(EN / ZH)供应商预设 (#2155, 感谢 @hengm3467)
|
||||
|
||||
### New API 用量脚本模板
|
||||
|
||||
- 为 New API 用量脚本模板新增 User-Agent 头,提升上游兼容性
|
||||
|
||||
### LemonData 全应用预设
|
||||
|
||||
- LemonData 作为第三方合作伙伴预设覆盖 Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes 全部 6 个应用
|
||||
- 含图标资源和 zh / en / ja 三语合作伙伴推广文案
|
||||
- Claude 预设使用 `ANTHROPIC_API_KEY` 认证,OpenAI 兼容应用目标为 `gpt-5.4`
|
||||
|
||||
### DDSHub Codex 预设
|
||||
|
||||
- 新增 DDSHub 的 Codex 兼容端点(与 Claude 服务同 host)
|
||||
- base URL 省略 `/v1` 后缀,由网关自动路由 OpenAI SDK 路径
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### "Local Proxy Takeover" → "Local Routing"
|
||||
|
||||
- 三语 UI 文案、README、文档中全部统一重命名
|
||||
- 功能行为保持不变
|
||||
|
||||
### Hermes `Auto` api_mode 移除
|
||||
|
||||
- 用户必须显式选择协议;新 deeplink 默认为 `chat_completions`
|
||||
- 消除了基于 URL 的启发式识别带来的意外
|
||||
|
||||
### Hermes 供应商表单
|
||||
|
||||
- 新增 API mode 下拉和按供应商的模型编辑器
|
||||
- 切换激活供应商时,把按供应商的模型绑定到顶层 `model:`
|
||||
|
||||
### Hermes 深度配置委托
|
||||
|
||||
- 深度 YAML 配置不再在 CC Switch 表单里重复,直接通过"启动 Hermes Web UI"按钮交给 Web UI
|
||||
|
||||
### Hermes 工具栏布局
|
||||
|
||||
- Web UI 按钮图标从 `ExternalLink` 换成 `LayoutDashboard` —— 点击可能启动 `hermes dashboard` 而非仅仅打开 URL,面板式图标语义更准
|
||||
- MCP 移到工具栏末尾,与 Claude / Codex / Gemini / OpenCode 的布局对齐
|
||||
|
||||
### Claude Quick-Set 移除 `ANTHROPIC_REASONING_MODEL`
|
||||
|
||||
- 把推理能力和模型选择解耦,quick-set 表单不再暴露该遗留字段
|
||||
|
||||
### 按供应商代理配置移除
|
||||
|
||||
- 统一到全局的 Local Routing
|
||||
- 按供应商的代理开关和存储都已移除
|
||||
|
||||
### 统一工具栏图标按钮宽度
|
||||
|
||||
- 在 Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes 面板之间规格化图标按钮宽度,表头视觉一致
|
||||
|
||||
### Rust Toolchain 锁定 1.95
|
||||
|
||||
- 全仓库采纳 clippy 1.95 建议并锁定 toolchain,防止 nightly 漂移
|
||||
|
||||
### 托盘菜单 ID 常量
|
||||
|
||||
- 托盘标识符从硬编码字符串 `"main"` 改为 `TRAY_ID` 常量(`"cc-switch"`),所有调用点同步 (#1978, 感谢 @lidaxian121)
|
||||
|
||||
### Copilot 次数消耗深度优化
|
||||
|
||||
一次系统性优化专门降低 Copilot 反向代理的 premium 交互消耗,涵盖以下多项改进:
|
||||
|
||||
- **转发前主动剥离 thinking 块**:Anthropic 的 `thinking` / `redacted_thinking` 块会被 OpenAI 兼容端点拒绝,过去一次请求先失败消耗一次 premium 交互、再由 `thinking_rectifier` 触发重试。新增主动剥离步骤(Copilot 优化管线第 3.5 步,位于 `tool_result` 合并之后),直接省掉那一次无谓的 premium 消耗
|
||||
- **请求分类修正**:含 `tool_result` 的消息归类为代理继续,而不是用户发起的新请求 —— 避免每次工具调用都被错误计入 premium 次数
|
||||
- **subagent 检测**:通过 `__SUBAGENT_MARKER__` 和 `metadata._agent_` 回退识别 subagent,设置 `x-interaction-type=conversation-subagent`
|
||||
- **确定性 `x-interaction-id` 合并计费**:从 session ID 推导 `x-interaction-id`,把同一会话内的多次请求合并为一次计费交互
|
||||
- **Orphan `tool_result` 清理**:清理孤立的 `tool_result`,避免触发上游错误导致重试和重复计费
|
||||
- **Warmup 降级默认开启**:使用 `gpt-5-mini` 作为默认降级模型
|
||||
- **优化管线重排**:classify → sanitize → merge → warmup,让分类看到原始 `tool_result` 语义
|
||||
- 修复 `CopilotOptimizerConfig` 默认值不一致(统一到 `gpt-5-mini`)
|
||||
|
||||
### 用量脚本内网支持
|
||||
|
||||
- 移除 usage script 的私网 IP / 可疑主机名屏蔽,解锁企业内网、Docker、自建 API 端点
|
||||
- 内置模板仍强制 HTTPS(localhost 除外)和同源检查;自定义模板仍由用户控制,这类请求 URL 检查跳过
|
||||
|
||||
### Failover 队列备注
|
||||
|
||||
- 供应商备注现在在 failover 队列选择器和队列行中显示,方便在多供应商队列里识别 (#2138, 感谢 @Coconut-Fish)
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### 工具栏最大化后持续折叠
|
||||
|
||||
- 窗口最大化 / 还原后,工具栏不再卡在折叠状态;折叠判定会随尺寸变化重新计算
|
||||
|
||||
### Hermes YAML 污染与 OAuth MCP `auth` 丢失
|
||||
|
||||
- 经 CC Switch 往返写入不再丢失 OAuth MCP `auth` 块、也不污染不相关的 YAML 键
|
||||
- 新增 `tests/hermes_roundtrip.rs` 作为守护测试
|
||||
|
||||
### Hermes 激活供应商展示
|
||||
|
||||
- Hermes UI 现在正确展示激活供应商,并连通添加 / 启用 / 移除动作
|
||||
|
||||
### Hermes 供应商持久化
|
||||
|
||||
- 供应商持久化到 `custom_providers:` 下,`api_mode` 和 `model` 可跨重启 / 配置重载存活
|
||||
|
||||
### Hermes 健康检查错借 OpenClaw schema
|
||||
|
||||
- 以前 Hermes 供应商被路由到 `check_additive_app_stream`(OpenClaw 的调度器),后者读 camelCase 的 `baseUrl` / `apiKey` / `api`,导致即便 Hermes 字段全填还是报 "OpenClaw provider is missing baseUrl"
|
||||
- 新增 `check_hermes_stream`,用 Hermes 专用提取器把 `api_mode`(`chat_completions` / `anthropic_messages` / `codex_responses`)映射到对应的 `check_claude_stream` `api_format`,`bedrock_converse` 明确标记为不支持
|
||||
- 先解析 `api_mode` 再抽 URL / API key,让 `bedrock_converse` 用户看到真实原因,而不是误导性的 "missing base_url"
|
||||
|
||||
### Usage 查询模态框支持 Hermes / OpenClaw
|
||||
|
||||
- `getProviderCredentials` 新增对 Hermes(snake_case `base_url` / `api_key`)和 OpenClaw(camelCase `baseUrl` / `apiKey`)的扁平 `settingsConfig` 字段读取,让 SiliconFlow 等匹配供应商自动选中 "official balance" 模板
|
||||
- 重构 BALANCE 和 TOKEN_PLAN 测试路径复用 `providerCredentials`,不再直接读 `env.ANTHROPIC_*`,修正了非 Claude 应用即使配置了 key 也报 "empty key" 的问题
|
||||
|
||||
### Codex `cache_control` 保留
|
||||
|
||||
- 在 Codex 格式转换合并 system prompt 时保留 `cache_control` (#1946, 感谢 @yovinchen)
|
||||
|
||||
### Claude prompt cache key 泄漏
|
||||
|
||||
- Claude chat 转换时不再发送 prompt cache key (#2003, 感谢 @yovinchen)
|
||||
|
||||
### 代理逐跳响应头剥离
|
||||
|
||||
- 按 RFC 7230 剥离代理响应的 hop-by-hop 头(Connection、Keep-Alive、Transfer-Encoding 等) (#2060, 感谢 @yovinchen)
|
||||
|
||||
### 代理 CORS 层移除
|
||||
|
||||
- 移除代理中过于宽松的 CORS 层 (#1915, 感谢 @zerone0x)
|
||||
|
||||
### 代理 toast 显示后端错误详情
|
||||
|
||||
- 代理相关 toast 现在展示后端错误 payload 的详情,而不是一句笼统的失败
|
||||
|
||||
### Usage 日志去重
|
||||
|
||||
- 代理和会话日志的用量记录去重,相同请求不再被重复计数
|
||||
- 请求日志时间范围与面板的 1d / 7d / 30d 选择器同步
|
||||
|
||||
### Common Config 勾选持久化
|
||||
|
||||
- Claude / Codex / Gemini common-config 勾选状态重开后正确保留 (#2191, 感谢 @zxZeng)
|
||||
|
||||
### Claude 插件 `settings.json` 同步
|
||||
|
||||
- 编辑当前供应商时,会同步回 Claude 插件路径下的 `settings.json` (#1905, 感谢 @chengww5217)
|
||||
|
||||
### Google Official Gemini env 保留
|
||||
|
||||
- 保存 Google Official Gemini 供应商时不再清空 `env` 块
|
||||
|
||||
### OpenCode JSON5 尾逗号解析
|
||||
|
||||
- OpenCode 配置读取容忍尾逗号(JSON5) (#2023, 感谢 @wwminger)
|
||||
|
||||
### 预设刷新
|
||||
|
||||
- 刷新 DeepSeek 和 Claude 1M 的陈旧 context 窗口
|
||||
- 刷新陈旧模型 ID,回填 Hermes 模型列表
|
||||
- 修正 Nous 端点,Hermes 占位图替换为 Nous 品牌图
|
||||
- 移除未使用的官方 Hermes 预设
|
||||
|
||||
### 搜索命中时折叠消息自动展开
|
||||
|
||||
- 搜索匹配落在折叠内容内部时,消息自动展开以定位匹配
|
||||
|
||||
### 未知订阅配额等级隐藏
|
||||
|
||||
- 供应商卡片不再渲染未知订阅配额等级
|
||||
|
||||
### weekly_limit 标签统一
|
||||
|
||||
- 跨语言把 `weekly_limit` 等级标签对齐到官方的"7 天"命名
|
||||
|
||||
### 根级 Skill 仓库安装
|
||||
|
||||
- 修复当仓库根本身就是一个 skill 时的安装失败
|
||||
|
||||
### Session ID 解析 clippy
|
||||
|
||||
- 移除 session ID 解析里的冗余闭包(clippy 警告)
|
||||
|
||||
### Stream Check 默认探测模型刷新
|
||||
|
||||
- 默认探测模型更新到每家厂商当前主力
|
||||
|
||||
### Skills 导入同步
|
||||
|
||||
- 导入的 Skills 即时同步到启用应用目录,不再仅记录在数据库里导致 UI 显示"已安装"但目标目录空缺 (#2101, 感谢 @yaoguohh)
|
||||
|
||||
### Ghostty 会话恢复
|
||||
|
||||
- 改为通过 shell 执行 + `--working-directory` 启动 Ghostty 会话恢复 (#1976, 感谢 @Suda202)
|
||||
- 避免路径含空格 / 特殊字符时 `cwd` 转义问题
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### README 赞助商更新
|
||||
|
||||
- SiliconFlow 注册赠送更新为 ¥16
|
||||
- 精简 SSSAiCode 赞助文案
|
||||
- 更新合作伙伴 logo
|
||||
- 新增 LemonData 赞助商
|
||||
|
||||
### 全局代理提示澄清
|
||||
|
||||
- 三语澄清全局代理与 Local Routing 的关系
|
||||
|
||||
### Takeover → Routing 文档重命名
|
||||
|
||||
- 接管相关文档在三语下重命名为 routing,同步更新锚点
|
||||
|
||||
### PIPELLM 网站 URL
|
||||
|
||||
- PIPELLM 赞助商网站 URL 更新为 `code.pipellm.ai`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要变更(Breaking)
|
||||
|
||||
### Hermes 必须显式 `api_mode`
|
||||
|
||||
- `Auto` 模式移除;导入或 deeplink 得到的供应商默认落到 `chat_completions`
|
||||
- 既有 `Auto` 配置的用户会被提示选择协议
|
||||
|
||||
### Claude Quick-Set 移除 `ANTHROPIC_REASONING_MODEL`
|
||||
|
||||
- 该遗留字段不再暴露;既有设置自动清理
|
||||
|
||||
### 按供应商代理配置移除
|
||||
|
||||
- 迁移到全局 Local Routing 设置
|
||||
- 既有按供应商代理值被忽略
|
||||
|
||||
### 数据库 schema v9 → v10
|
||||
|
||||
- 为 `mcp_servers` 和 `skills` 表新增 `enabled_hermes` 列
|
||||
- 自动迁移,`DEFAULT 0`,无数据丢失
|
||||
|
||||
### Pricing 表 v8 → v9 重置
|
||||
|
||||
- 首次启动时 `model_pricing` 表被清空并重新种入,以应用新模型和修正后的价格
|
||||
|
||||
### XCodeAPI 预设移除
|
||||
|
||||
- 使用 XCodeAPI 预设的用户请迁移到其它供应商
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 风险提示
|
||||
|
||||
本版本在涉及反向代理类功能上沿用 v3.12.3 / v3.13.0 提出的风险提示。
|
||||
|
||||
**GitHub Copilot 反向代理**:使用 Copilot 的反代路径可能违反 GitHub / Microsoft 服务条款。详情见 [v3.12.3 release notes](v3.12.3-zh.md#️-风险提示)。
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
用户启用上述功能即表示**自行承担所有风险**。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.14.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.14.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.14.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.14.0-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.14.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
@@ -509,7 +509,7 @@ When editing Claude providers, a set of **quick toggles** is available above the
|
||||
| **Hide Attribution** | Clears commit/PR attribution metadata | Sets `attribution: {commit: "", pr: ""}` |
|
||||
| **Enable Teammates** | Enables the agent teams feature | Sets `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
|
||||
| **Enable Tool Search** | Enables tool search functionality | Sets `env.ENABLE_TOOL_SEARCH = "true"` |
|
||||
| **High Effort** | Sets effort level to high | Sets `effortLevel = "high"` |
|
||||
| **Max Effort** | Sets effort level to max | Sets `effortLevel = "max"` |
|
||||
| **Disable Auto Upgrade** | Prevents Claude Code auto-updates | Sets `env.DISABLE_AUTOUPDATER = "1"` |
|
||||
|
||||
When a toggle is unchecked, its corresponding config entry is removed entirely. Changes are reflected in the JSON editor in real-time.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 4.2 App Routing
|
||||
|
||||
## Overview
|
||||
|
||||
App routing means letting CC Switch route a specific application's API requests through the local routing service.
|
||||
|
||||
When routing is enabled:
|
||||
- The app's API requests are forwarded through local routing
|
||||
- Request logs and usage statistics can be recorded
|
||||
- Failover functionality becomes available
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The routing service must be started before using the app routing feature.
|
||||
|
||||
## Enable Routing
|
||||
|
||||
### Location
|
||||
|
||||
Settings > Advanced > Routing Service > App Routing area
|
||||
|
||||
### Steps
|
||||
|
||||
1. Ensure the routing service is started
|
||||
2. Find the "App Routing" area
|
||||
3. Enable the toggle for the desired apps
|
||||
|
||||
### Routing Toggles
|
||||
|
||||
| Toggle | Effect |
|
||||
|--------|--------|
|
||||
| Claude Routing | Route Claude Code requests |
|
||||
| Codex Routing | Route Codex requests |
|
||||
| Gemini Routing | Route Gemini CLI requests |
|
||||
|
||||
Multiple app routings can be enabled simultaneously.
|
||||
|
||||
## How Routing Works
|
||||
|
||||
### Configuration Changes
|
||||
|
||||
When routing is enabled, CC Switch modifies the app's configuration file to point the API endpoint to the local routing service.
|
||||
|
||||
**Claude configuration change**:
|
||||
|
||||
```json
|
||||
// Before routing
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
|
||||
// After routing
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex configuration change**:
|
||||
|
||||
```toml
|
||||
# Before routing
|
||||
base_url = "https://api.openai.com/v1"
|
||||
|
||||
# After routing
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
```
|
||||
|
||||
**Gemini configuration change**:
|
||||
|
||||
```bash
|
||||
# Before routing
|
||||
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||
|
||||
# After routing
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
### Request Forwarding
|
||||
|
||||
When the routing service receives a request:
|
||||
|
||||
1. Identifies the request source (Claude/Codex/Gemini)
|
||||
2. Looks up the currently enabled provider for that app
|
||||
3. Forwards the request to the provider's actual endpoint
|
||||
4. Records the request log
|
||||
5. Returns the response to the app
|
||||
|
||||
## Routing Status Indicators
|
||||
|
||||
### Main Interface Indicators
|
||||
|
||||
When routing is enabled, the main interface shows the following changes:
|
||||
|
||||
- **Routing logo color**: Changes from colorless to green
|
||||
- **Provider cards**: The currently active provider shows a green border
|
||||
|
||||
### Provider Card States
|
||||
|
||||
| State | Border Color | Description |
|
||||
|-------|--------------|-------------|
|
||||
| Currently Active | Blue | Provider in the config file (non-routing mode) |
|
||||
| Routing Active | Green | Provider actually used by routing |
|
||||
| Normal | Default | Unused provider |
|
||||
|
||||
## Disable Routing
|
||||
|
||||
### Steps
|
||||
|
||||
1. Turn off the corresponding app's routing toggle in the routing panel
|
||||
2. Or directly stop the routing service
|
||||
|
||||
### Configuration Restoration
|
||||
|
||||
When disabling routing, CC Switch will:
|
||||
|
||||
1. Restore the app configuration to its pre-routing state
|
||||
2. Save current request logs
|
||||
|
||||
## Routing and Provider Switching
|
||||
|
||||
### Switching Providers in Routing Mode
|
||||
|
||||
When switching providers in routing mode:
|
||||
|
||||
1. Click the "Enable" button on a provider in the main interface
|
||||
2. The routing service immediately uses the new provider to forward requests
|
||||
3. **No need to restart the CLI tool**
|
||||
|
||||
This is a major advantage of routing mode: provider switching takes effect instantly.
|
||||
|
||||
### Switching Without Routing
|
||||
|
||||
When switching providers without routing:
|
||||
|
||||
1. Configuration file is modified
|
||||
2. CLI tool must be restarted for changes to take effect
|
||||
|
||||
## Multi-app Routing
|
||||
|
||||
Multiple apps can be routed simultaneously, each managed independently:
|
||||
|
||||
- Independent provider configurations
|
||||
- Independent failover queues
|
||||
- Independent request statistics
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Scenario 1: Usage Monitoring
|
||||
|
||||
Enable routing + log recording to monitor API usage.
|
||||
|
||||
### Scenario 2: Quick Switching
|
||||
|
||||
With routing enabled, switching providers does not require restarting CLI tools.
|
||||
|
||||
### Scenario 3: Failover
|
||||
|
||||
Enabling routing is a prerequisite for using the failover feature.
|
||||
|
||||
## Notes
|
||||
|
||||
### Performance Impact
|
||||
|
||||
Routing adds minimal latency (typically < 10ms), negligible for most scenarios.
|
||||
|
||||
### Network Requirements
|
||||
|
||||
In routing mode, CLI tools must be able to access the local routing address.
|
||||
|
||||
### Configuration Backup
|
||||
|
||||
Before enabling routing, CC Switch backs up the original configuration and restores it when disabled.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Requests Fail After Enabling Routing
|
||||
|
||||
Check:
|
||||
- Is the routing service running normally
|
||||
- Is the provider configuration correct
|
||||
- Is the network working properly
|
||||
|
||||
### Configuration Not Restored After Disabling Routing
|
||||
|
||||
Possible causes:
|
||||
- Routing service exited abnormally
|
||||
- Configuration file was modified by another program
|
||||
|
||||
Solutions:
|
||||
- Manually edit the provider and re-save
|
||||
- Or re-enable and then disable routing
|
||||
@@ -1,195 +0,0 @@
|
||||
# 4.2 App Takeover
|
||||
|
||||
## Overview
|
||||
|
||||
App takeover means letting CC Switch's proxy intercept and forward a specific application's API requests.
|
||||
|
||||
When takeover is enabled:
|
||||
- The app's API requests are forwarded through the local proxy
|
||||
- Request logs and usage statistics can be recorded
|
||||
- Failover functionality becomes available
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The proxy service must be started before using the app takeover feature.
|
||||
|
||||
## Enable Takeover
|
||||
|
||||
### Location
|
||||
|
||||
Settings > Advanced > Proxy Service > App Takeover area
|
||||
|
||||
### Steps
|
||||
|
||||
1. Ensure the proxy service is started
|
||||
2. Find the "App Takeover" area
|
||||
3. Enable the toggle for the desired apps
|
||||
|
||||
### Takeover Toggles
|
||||
|
||||
| Toggle | Effect |
|
||||
|--------|--------|
|
||||
| Claude Takeover | Intercept Claude Code requests |
|
||||
| Codex Takeover | Intercept Codex requests |
|
||||
| Gemini Takeover | Intercept Gemini CLI requests |
|
||||
|
||||
Multiple app takeovers can be enabled simultaneously.
|
||||
|
||||
## How Takeover Works
|
||||
|
||||
### Configuration Changes
|
||||
|
||||
When takeover is enabled, CC Switch modifies the app's configuration file to point the API endpoint to the local proxy.
|
||||
|
||||
**Claude configuration change**:
|
||||
|
||||
```json
|
||||
// Before takeover
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
|
||||
// After takeover
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex configuration change**:
|
||||
|
||||
```toml
|
||||
# Before takeover
|
||||
base_url = "https://api.openai.com/v1"
|
||||
|
||||
# After takeover
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
```
|
||||
|
||||
**Gemini configuration change**:
|
||||
|
||||
```bash
|
||||
# Before takeover
|
||||
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||
|
||||
# After takeover
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
### Request Forwarding
|
||||
|
||||
When the proxy receives a request:
|
||||
|
||||
1. Identifies the request source (Claude/Codex/Gemini)
|
||||
2. Looks up the currently enabled provider for that app
|
||||
3. Forwards the request to the provider's actual endpoint
|
||||
4. Records the request log
|
||||
5. Returns the response to the app
|
||||
|
||||
## Takeover Status Indicators
|
||||
|
||||
### Main Interface Indicators
|
||||
|
||||
When takeover is enabled, the main interface shows the following changes:
|
||||
|
||||
- **Proxy logo color**: Changes from colorless to green
|
||||
- **Provider cards**: The currently active provider shows a green border
|
||||
|
||||
### Provider Card States
|
||||
|
||||
| State | Border Color | Description |
|
||||
|-------|--------------|-------------|
|
||||
| Currently Active | Blue | Provider in the config file (non-proxy mode) |
|
||||
| Proxy Active | Green | Provider actually used by the proxy |
|
||||
| Normal | Default | Unused provider |
|
||||
|
||||
## Disable Takeover
|
||||
|
||||
### Steps
|
||||
|
||||
1. Turn off the corresponding app's takeover toggle in the proxy panel
|
||||
2. Or directly stop the proxy service
|
||||
|
||||
### Configuration Restoration
|
||||
|
||||
When disabling takeover, CC Switch will:
|
||||
|
||||
1. Restore the app configuration to its pre-takeover state
|
||||
2. Save current request logs
|
||||
|
||||
## Takeover and Provider Switching
|
||||
|
||||
### Switching Providers in Takeover Mode
|
||||
|
||||
When switching providers in takeover mode:
|
||||
|
||||
1. Click the "Enable" button on a provider in the main interface
|
||||
2. The proxy immediately uses the new provider to forward requests
|
||||
3. **No need to restart the CLI tool**
|
||||
|
||||
This is a major advantage of takeover mode: provider switching takes effect instantly.
|
||||
|
||||
### Switching Without Takeover
|
||||
|
||||
When switching providers without takeover:
|
||||
|
||||
1. Configuration file is modified
|
||||
2. CLI tool must be restarted for changes to take effect
|
||||
|
||||
## Multi-app Takeover
|
||||
|
||||
Multiple apps can be taken over simultaneously, each managed independently:
|
||||
|
||||
- Independent provider configurations
|
||||
- Independent failover queues
|
||||
- Independent request statistics
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Scenario 1: Usage Monitoring
|
||||
|
||||
Enable takeover + log recording to monitor API usage.
|
||||
|
||||
### Scenario 2: Quick Switching
|
||||
|
||||
With takeover enabled, switching providers does not require restarting CLI tools.
|
||||
|
||||
### Scenario 3: Failover
|
||||
|
||||
Enabling takeover is a prerequisite for using the failover feature.
|
||||
|
||||
## Notes
|
||||
|
||||
### Performance Impact
|
||||
|
||||
The proxy adds minimal latency (typically < 10ms), negligible for most scenarios.
|
||||
|
||||
### Network Requirements
|
||||
|
||||
In takeover mode, CLI tools must be able to access the local proxy address.
|
||||
|
||||
### Configuration Backup
|
||||
|
||||
Before enabling takeover, CC Switch backs up the original configuration and restores it when disabled.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Requests Fail After Takeover
|
||||
|
||||
Check:
|
||||
- Is the proxy service running normally
|
||||
- Is the provider configuration correct
|
||||
- Is the network working properly
|
||||
|
||||
### Configuration Not Restored After Disabling Takeover
|
||||
|
||||
Possible causes:
|
||||
- Proxy exited abnormally
|
||||
- Configuration file was modified by another program
|
||||
|
||||
Solutions:
|
||||
- Manually edit the provider and re-save
|
||||
- Or re-enable and then disable takeover
|
||||
@@ -79,7 +79,7 @@ CC Switch User Manual
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [4.1-service.md](./4-proxy/4.1-service.md) | Start proxy, configuration, running status |
|
||||
| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | App takeover, configuration changes, status indicators |
|
||||
| [4.2-routing.md](./4-proxy/4.2-routing.md) | App routing, configuration changes, status indicators |
|
||||
| [4.3-failover.md](./4-proxy/4.3-failover.md) | Failover queue, circuit breaker, health status |
|
||||
| [4.4-usage.md](./4-proxy/4.4-usage.md) | Usage statistics, trend charts, pricing configuration |
|
||||
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | Model test, health check, latency testing |
|
||||
|
||||
@@ -509,7 +509,7 @@ Claude プロバイダーの編集時、JSON エディタの上部に **クイ
|
||||
| **帰属情報を非表示** | コミット/PR の帰属メタデータをクリア | `attribution: {commit: "", pr: ""}` を設定 |
|
||||
| **チームメイトを有効化** | エージェントチーム機能を有効化 | `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` を設定 |
|
||||
| **ツール検索を有効化** | ツール検索機能を有効化 | `env.ENABLE_TOOL_SEARCH = "true"` を設定 |
|
||||
| **高強度** | エフォートレベルを高に設定 | `effortLevel = "high"` を設定 |
|
||||
| **最大強度思考** | エフォートレベルを max に設定 | `effortLevel = "max"` を設定 |
|
||||
| **自動アップグレードを無効化** | Claude Code の自動更新を防止 | `env.DISABLE_AUTOUPDATER = "1"` を設定 |
|
||||
|
||||
トグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 4.2 アプリケーションルーティング
|
||||
|
||||
## 機能説明
|
||||
|
||||
アプリケーションルーティングとは、CC Switch のルーティングサービスが特定アプリの API リクエストをルーティングすることです。
|
||||
|
||||
ルーティングを有効にすると:
|
||||
- アプリの API リクエストがローカルルーティング経由で転送される
|
||||
- リクエストログと使用量の統計を記録できる
|
||||
- フェイルオーバー機能を使用できる
|
||||
|
||||
## 前提条件
|
||||
|
||||
アプリケーションルーティング機能を使用する前に、ルーティングサービスを起動する必要があります。
|
||||
|
||||
## ルーティングの有効化
|
||||
|
||||
### 操作場所
|
||||
|
||||
設定 → 詳細 → ルーティングサービス → アプリケーションルーティングエリア
|
||||
|
||||
### 操作手順
|
||||
|
||||
1. ルーティングサービスが起動していることを確認
|
||||
2. 「アプリケーションルーティング」エリアを見つける
|
||||
3. 必要なアプリのスイッチをオンにする
|
||||
|
||||
### ルーティングスイッチ
|
||||
|
||||
| スイッチ | 作用 |
|
||||
|------|------|
|
||||
| Claude ルーティング | Claude Code のリクエストをルーティング |
|
||||
| Codex ルーティング | Codex のリクエストをルーティング |
|
||||
| Gemini ルーティング | Gemini CLI のリクエストをルーティング |
|
||||
|
||||
複数のアプリのルーティングを同時に有効にできます。
|
||||
|
||||
## ルーティングの仕組み
|
||||
|
||||
### 設定の変更
|
||||
|
||||
ルーティングを有効にすると、CC Switch はアプリの設定ファイルを変更し、API エンドポイントをローカルルーティングに向けます。
|
||||
|
||||
**Claude 設定の変更**:
|
||||
|
||||
```json
|
||||
// ルーティング前
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
|
||||
// ルーティング後
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex 設定の変更**:
|
||||
|
||||
```toml
|
||||
# ルーティング前
|
||||
base_url = "https://api.openai.com/v1"
|
||||
|
||||
# ルーティング後
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
```
|
||||
|
||||
**Gemini 設定の変更**:
|
||||
|
||||
```bash
|
||||
# ルーティング前
|
||||
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||
|
||||
# ルーティング後
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
### リクエストの転送
|
||||
|
||||
ルーティングサービスがリクエストを受信すると:
|
||||
|
||||
1. リクエスト元を識別(Claude/Codex/Gemini)
|
||||
2. そのアプリで現在有効なプロバイダーを検索
|
||||
3. プロバイダーの実際のエンドポイントにリクエストを転送
|
||||
4. リクエストログを記録
|
||||
5. アプリにレスポンスを返却
|
||||
|
||||
## ルーティングステータスの表示
|
||||
|
||||
### メイン画面の表示
|
||||
|
||||
ルーティングを有効にすると、メイン画面に以下の変化があります:
|
||||
|
||||
- **ルーティング Logo の色**:無色から緑に変化
|
||||
- **プロバイダーカード**:現在アクティブなプロバイダーに緑の枠が表示
|
||||
|
||||
### プロバイダーカードの状態
|
||||
|
||||
| 状態 | 枠の色 | 説明 |
|
||||
|------|----------|------|
|
||||
| 現在有効 | 青 | 設定ファイル内のプロバイダー(非ルーティングモード) |
|
||||
| ルーティングアクティブ | 緑 | ルーティングが実際に使用しているプロバイダー |
|
||||
| 通常 | デフォルト | 使用されていないプロバイダー |
|
||||
|
||||
## ルーティングの無効化
|
||||
|
||||
### 操作手順
|
||||
|
||||
1. ルーティングパネルで対応するアプリのルーティングスイッチをオフにする
|
||||
2. またはルーティングサービスを直接停止
|
||||
|
||||
### 設定の復元
|
||||
|
||||
ルーティングを無効にすると、CC Switch は以下を実行します:
|
||||
|
||||
1. アプリの設定をルーティング前の状態に復元
|
||||
2. 現在のリクエストログを保存
|
||||
|
||||
## ルーティングとプロバイダーの切り替え
|
||||
|
||||
### ルーティングモードでのプロバイダー切り替え
|
||||
|
||||
ルーティングモードでプロバイダーを切り替える場合:
|
||||
|
||||
1. メイン画面でプロバイダーの「有効化」ボタンをクリック
|
||||
2. ルーティングサービスが新しいプロバイダーを使用してリクエストを即座に転送
|
||||
3. **CLI ツールの再起動は不要**
|
||||
|
||||
これがルーティングモードの大きなメリットです:プロバイダーの切り替えが即座に反映されます。
|
||||
|
||||
### 非ルーティングモードでのプロバイダー切り替え
|
||||
|
||||
非ルーティングモードでプロバイダーを切り替える場合:
|
||||
|
||||
1. 設定ファイルを変更
|
||||
2. CLI ツールの再起動が必要
|
||||
|
||||
## 複数アプリのルーティング
|
||||
|
||||
複数のアプリを同時にルーティングでき、それぞれ独立して管理されます:
|
||||
|
||||
- 独立したプロバイダー設定
|
||||
- 独立したフェイルオーバーキュー
|
||||
- 独立したリクエスト統計
|
||||
|
||||
## 使用シーン
|
||||
|
||||
### シーン 1:使用量の監視
|
||||
|
||||
ルーティング + ログ記録を有効にして、API の使用状況を監視します。
|
||||
|
||||
### シーン 2:素早い切り替え
|
||||
|
||||
ルーティングを有効にすると、プロバイダーの切り替えに CLI ツールの再起動が不要になります。
|
||||
|
||||
### シーン 3:フェイルオーバー
|
||||
|
||||
ルーティングの有効化はフェイルオーバー機能を使用するための前提条件です。
|
||||
|
||||
## 注意事項
|
||||
|
||||
### パフォーマンスへの影響
|
||||
|
||||
ルーティングにより少量のレイテンシ(通常 < 10ms)が追加されますが、ほとんどのシーンでは無視できます。
|
||||
|
||||
### ネットワーク要件
|
||||
|
||||
ルーティングモードでは、CLI ツールがローカルルーティングアドレスにアクセスできる必要があります。
|
||||
|
||||
### 設定のバックアップ
|
||||
|
||||
ルーティングを有効にする前に、CC Switch は元の設定をバックアップし、無効化時に復元します。
|
||||
|
||||
## よくある質問
|
||||
|
||||
### ルーティング後にリクエストが失敗する
|
||||
|
||||
確認事項:
|
||||
- ルーティングサービスが正常に実行されているか
|
||||
- プロバイダーの設定が正しいか
|
||||
- ネットワークが正常か
|
||||
|
||||
### ルーティングを無効にしても設定が復元されない
|
||||
|
||||
考えられる原因:
|
||||
- ルーティングサービスの異常終了
|
||||
- 設定ファイルが他のプログラムに変更された
|
||||
|
||||
解決方法:
|
||||
- プロバイダーを手動で編集して保存し直す
|
||||
- または再度ルーティングを有効にしてから無効にする
|
||||
@@ -1,195 +0,0 @@
|
||||
# 4.2 アプリケーション接管
|
||||
|
||||
## 機能説明
|
||||
|
||||
アプリケーション接管とは、CC Switch のプロキシが特定アプリの API リクエストを接管することです。
|
||||
|
||||
接管を有効にすると:
|
||||
- アプリの API リクエストがローカルプロキシ経由で転送される
|
||||
- リクエストログと使用量の統計を記録できる
|
||||
- フェイルオーバー機能を使用できる
|
||||
|
||||
## 前提条件
|
||||
|
||||
アプリケーション接管機能を使用する前に、プロキシサービスを起動する必要があります。
|
||||
|
||||
## 接管の有効化
|
||||
|
||||
### 操作場所
|
||||
|
||||
設定 → 詳細 → プロキシサービス → アプリケーション接管エリア
|
||||
|
||||
### 操作手順
|
||||
|
||||
1. プロキシサービスが起動していることを確認
|
||||
2. 「アプリケーション接管」エリアを見つける
|
||||
3. 必要なアプリのスイッチをオンにする
|
||||
|
||||
### 接管スイッチ
|
||||
|
||||
| スイッチ | 作用 |
|
||||
|------|------|
|
||||
| Claude 接管 | Claude Code のリクエストを接管 |
|
||||
| Codex 接管 | Codex のリクエストを接管 |
|
||||
| Gemini 接管 | Gemini CLI のリクエストを接管 |
|
||||
|
||||
複数のアプリの接管を同時に有効にできます。
|
||||
|
||||
## 接管の仕組み
|
||||
|
||||
### 設定の変更
|
||||
|
||||
接管を有効にすると、CC Switch はアプリの設定ファイルを変更し、API エンドポイントをローカルプロキシに向けます。
|
||||
|
||||
**Claude 設定の変更**:
|
||||
|
||||
```json
|
||||
// 接管前
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
|
||||
// 接管後
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex 設定の変更**:
|
||||
|
||||
```toml
|
||||
# 接管前
|
||||
base_url = "https://api.openai.com/v1"
|
||||
|
||||
# 接管後
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
```
|
||||
|
||||
**Gemini 設定の変更**:
|
||||
|
||||
```bash
|
||||
# 接管前
|
||||
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||
|
||||
# 接管後
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
### リクエストの転送
|
||||
|
||||
プロキシがリクエストを受信すると:
|
||||
|
||||
1. リクエスト元を識別(Claude/Codex/Gemini)
|
||||
2. そのアプリで現在有効なプロバイダーを検索
|
||||
3. プロバイダーの実際のエンドポイントにリクエストを転送
|
||||
4. リクエストログを記録
|
||||
5. アプリにレスポンスを返却
|
||||
|
||||
## 接管ステータスの表示
|
||||
|
||||
### メイン画面の表示
|
||||
|
||||
接管を有効にすると、メイン画面に以下の変化があります:
|
||||
|
||||
- **プロキシ Logo の色**:無色から緑に変化
|
||||
- **プロバイダーカード**:現在アクティブなプロバイダーに緑の枠が表示
|
||||
|
||||
### プロバイダーカードの状態
|
||||
|
||||
| 状態 | 枠の色 | 説明 |
|
||||
|------|----------|------|
|
||||
| 現在有効 | 青 | 設定ファイル内のプロバイダー(非プロキシモード) |
|
||||
| プロキシアクティブ | 緑 | プロキシが実際に使用しているプロバイダー |
|
||||
| 通常 | デフォルト | 使用されていないプロバイダー |
|
||||
|
||||
## 接管の無効化
|
||||
|
||||
### 操作手順
|
||||
|
||||
1. プロキシパネルで対応するアプリの接管スイッチをオフにする
|
||||
2. またはプロキシサービスを直接停止
|
||||
|
||||
### 設定の復元
|
||||
|
||||
接管を無効にすると、CC Switch は以下を実行します:
|
||||
|
||||
1. アプリの設定を接管前の状態に復元
|
||||
2. 現在のリクエストログを保存
|
||||
|
||||
## 接管とプロバイダーの切り替え
|
||||
|
||||
### 接管モードでのプロバイダー切り替え
|
||||
|
||||
接管モードでプロバイダーを切り替える場合:
|
||||
|
||||
1. メイン画面でプロバイダーの「有効化」ボタンをクリック
|
||||
2. プロキシが新しいプロバイダーを使用してリクエストを即座に転送
|
||||
3. **CLI ツールの再起動は不要**
|
||||
|
||||
これが接管モードの大きなメリットです:プロバイダーの切り替えが即座に反映されます。
|
||||
|
||||
### 非接管モードでのプロバイダー切り替え
|
||||
|
||||
非接管モードでプロバイダーを切り替える場合:
|
||||
|
||||
1. 設定ファイルを変更
|
||||
2. CLI ツールの再起動が必要
|
||||
|
||||
## 複数アプリの接管
|
||||
|
||||
複数のアプリを同時に接管でき、それぞれ独立して管理されます:
|
||||
|
||||
- 独立したプロバイダー設定
|
||||
- 独立したフェイルオーバーキュー
|
||||
- 独立したリクエスト統計
|
||||
|
||||
## 使用シーン
|
||||
|
||||
### シーン 1:使用量の監視
|
||||
|
||||
接管 + ログ記録を有効にして、API の使用状況を監視します。
|
||||
|
||||
### シーン 2:素早い切り替え
|
||||
|
||||
接管を有効にすると、プロバイダーの切り替えに CLI ツールの再起動が不要になります。
|
||||
|
||||
### シーン 3:フェイルオーバー
|
||||
|
||||
接管の有効化はフェイルオーバー機能を使用するための前提条件です。
|
||||
|
||||
## 注意事項
|
||||
|
||||
### パフォーマンスへの影響
|
||||
|
||||
プロキシにより少量のレイテンシ(通常 < 10ms)が追加されますが、ほとんどのシーンでは無視できます。
|
||||
|
||||
### ネットワーク要件
|
||||
|
||||
接管モードでは、CLI ツールがローカルプロキシアドレスにアクセスできる必要があります。
|
||||
|
||||
### 設定のバックアップ
|
||||
|
||||
接管を有効にする前に、CC Switch は元の設定をバックアップし、無効化時に復元します。
|
||||
|
||||
## よくある質問
|
||||
|
||||
### 接管後にリクエストが失敗する
|
||||
|
||||
確認事項:
|
||||
- プロキシサービスが正常に実行されているか
|
||||
- プロバイダーの設定が正しいか
|
||||
- ネットワークが正常か
|
||||
|
||||
### 接管を無効にしても設定が復元されない
|
||||
|
||||
考えられる原因:
|
||||
- プロキシの異常終了
|
||||
- 設定ファイルが他のプログラムに変更された
|
||||
|
||||
解決方法:
|
||||
- プロバイダーを手動で編集して保存し直す
|
||||
- または接管を再度有効にしてから無効にする
|
||||
@@ -79,7 +79,7 @@ CC Switch ユーザーマニュアル
|
||||
| ファイル | 内容 |
|
||||
|------|------|
|
||||
| [4.1-service.md](./4-proxy/4.1-service.md) | プロキシの起動、設定項目、実行状態 |
|
||||
| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | アプリケーション接管、設定変更、ステータス表示 |
|
||||
| [4.2-routing.md](./4-proxy/4.2-routing.md) | アプリケーションルーティング、設定変更、ステータス表示 |
|
||||
| [4.3-failover.md](./4-proxy/4.3-failover.md) | フェイルオーバーキュー、サーキットブレーカー、ヘルスステータス |
|
||||
| [4.4-usage.md](./4-proxy/4.4-usage.md) | 使用量統計、トレンドグラフ、料金設定 |
|
||||
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | モデルテスト、ヘルスチェック、レイテンシテスト |
|
||||
|
||||
@@ -509,7 +509,7 @@ v3.13.0 起新增的高级选项。默认情况下,CC Switch 会把配置的 `
|
||||
| **隐藏署名** | 清除提交/PR 的署名元数据 | 设置 `attribution: {commit: "", pr: ""}` |
|
||||
| **启用 Teammates** | 启用 Agent 团队功能 | 设置 `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
|
||||
| **启用工具搜索** | 启用工具搜索功能 | 设置 `env.ENABLE_TOOL_SEARCH = "true"` |
|
||||
| **高效能模式** | 将 effort 级别设为 high | 设置 `effortLevel = "high"` |
|
||||
| **最大强度思考** | 将 effort 级别设为 max | 设置 `effortLevel = "max"` |
|
||||
| **禁用自动更新** | 阻止 Claude Code 自动更新 | 设置 `env.DISABLE_AUTOUPDATER = "1"` |
|
||||
|
||||
取消勾选开关时,对应的配置项会被完全移除。更改会实时反映在 JSON 编辑器中。
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 4.2 应用路由
|
||||
|
||||
## 功能说明
|
||||
|
||||
应用路由是指让 CC Switch 路由特定应用的 API 请求。
|
||||
|
||||
开启路由后:
|
||||
- 应用的 API 请求会通过本地路由转发
|
||||
- 可以记录请求日志和统计用量
|
||||
- 可以使用故障转移功能
|
||||
|
||||
## 前提条件
|
||||
|
||||
使用应用路由功能前,需要先启动路由服务。
|
||||
|
||||
## 开启路由
|
||||
|
||||
### 操作位置
|
||||
|
||||
设置 → 高级 → 路由服务 → 应用路由区域
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 确保路由服务已启动
|
||||
2. 找到「应用路由」区域
|
||||
3. 为需要的应用开启开关
|
||||
|
||||
### 路由开关
|
||||
|
||||
| 开关 | 作用 |
|
||||
|------|------|
|
||||
| Claude 路由 | 路由 Claude Code 的请求 |
|
||||
| Codex 路由 | 路由 Codex 的请求 |
|
||||
| Gemini 路由 | 路由 Gemini CLI 的请求 |
|
||||
|
||||
可以同时开启多个应用的路由。
|
||||
|
||||
## 路由原理
|
||||
|
||||
### 配置修改
|
||||
|
||||
开启路由后,CC Switch 会修改应用的配置文件,将 API 端点指向本地路由。
|
||||
|
||||
**Claude 配置变更**:
|
||||
|
||||
```json
|
||||
// 路由前
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
|
||||
// 路由后
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex 配置变更**:
|
||||
|
||||
```toml
|
||||
# 路由前
|
||||
base_url = "https://api.openai.com/v1"
|
||||
|
||||
# 路由后
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
```
|
||||
|
||||
**Gemini 配置变更**:
|
||||
|
||||
```bash
|
||||
# 路由前
|
||||
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||
|
||||
# 路由后
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
### 请求转发
|
||||
|
||||
路由收到请求后:
|
||||
|
||||
1. 识别请求来源(Claude/Codex/Gemini)
|
||||
2. 查找该应用当前启用的供应商
|
||||
3. 将请求转发到供应商的实际端点
|
||||
4. 记录请求日志
|
||||
5. 返回响应给应用
|
||||
|
||||
## 路由状态指示
|
||||
|
||||
### 主界面指示
|
||||
|
||||
开启路由后,主界面会有以下变化:
|
||||
|
||||
- **路由 Logo 颜色**:从无色变为绿色
|
||||
- **供应商卡片**:当前活跃的供应商显示绿色边框
|
||||
|
||||
### 供应商卡片状态
|
||||
|
||||
| 状态 | 边框颜色 | 说明 |
|
||||
|------|----------|------|
|
||||
| 当前启用 | 蓝色 | 配置文件中的供应商(非路由模式) |
|
||||
| 路由活跃 | 绿色 | 路由实际使用的供应商 |
|
||||
| 普通 | 默认 | 未使用的供应商 |
|
||||
|
||||
## 关闭路由
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 在路由面板中关闭对应应用的路由开关
|
||||
2. 或直接停止路由服务
|
||||
|
||||
### 配置恢复
|
||||
|
||||
关闭路由时,CC Switch 会:
|
||||
|
||||
1. 将应用配置恢复到路由前的状态
|
||||
2. 保存当前的请求日志
|
||||
|
||||
## 路由与供应商切换
|
||||
|
||||
### 路由模式下切换供应商
|
||||
|
||||
在路由模式下切换供应商:
|
||||
|
||||
1. 在主界面点击供应商的「启用」按钮
|
||||
2. 路由立即使用新供应商转发请求
|
||||
3. **无需重启 CLI 工具**
|
||||
|
||||
这是路由模式的一大优势:切换供应商即时生效。
|
||||
|
||||
### 非路由模式下切换
|
||||
|
||||
在非路由模式下切换供应商:
|
||||
|
||||
1. 修改配置文件
|
||||
2. 需要重启 CLI 工具才能生效
|
||||
|
||||
## 多应用路由
|
||||
|
||||
可以同时路由多个应用,每个应用独立管理:
|
||||
|
||||
- 独立的供应商配置
|
||||
- 独立的故障转移队列
|
||||
- 独立的请求统计
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景一:用量监控
|
||||
|
||||
开启路由 + 日志记录,监控 API 使用情况。
|
||||
|
||||
### 场景二:快速切换
|
||||
|
||||
开启路由后,切换供应商无需重启 CLI 工具。
|
||||
|
||||
### 场景三:故障转移
|
||||
|
||||
开启路由是使用故障转移功能的前提。
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 性能影响
|
||||
|
||||
路由会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
|
||||
|
||||
### 网络要求
|
||||
|
||||
路由模式下,CLI 工具需要能够访问本地路由地址。
|
||||
|
||||
### 配置备份
|
||||
|
||||
开启路由前,CC Switch 会备份原始配置,关闭时恢复。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 路由后请求失败
|
||||
|
||||
检查:
|
||||
- 路由服务是否正常运行
|
||||
- 供应商配置是否正确
|
||||
- 网络是否正常
|
||||
|
||||
### 关闭路由后配置未恢复
|
||||
|
||||
可能原因:
|
||||
- 路由异常退出
|
||||
- 配置文件被其他程序修改
|
||||
|
||||
解决方法:
|
||||
- 手动编辑供应商,重新保存
|
||||
- 或重新启用再关闭路由
|
||||
@@ -1,195 +0,0 @@
|
||||
# 4.2 应用接管
|
||||
|
||||
## 功能说明
|
||||
|
||||
应用接管是指让 CC Switch 代理接管特定应用的 API 请求。
|
||||
|
||||
开启接管后:
|
||||
- 应用的 API 请求会通过本地代理转发
|
||||
- 可以记录请求日志和统计用量
|
||||
- 可以使用故障转移功能
|
||||
|
||||
## 前提条件
|
||||
|
||||
使用应用接管功能前,需要先启动代理服务。
|
||||
|
||||
## 开启接管
|
||||
|
||||
### 操作位置
|
||||
|
||||
设置 → 高级 → 代理服务 → 应用接管区域
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 确保代理服务已启动
|
||||
2. 找到「应用接管」区域
|
||||
3. 为需要的应用开启开关
|
||||
|
||||
### 接管开关
|
||||
|
||||
| 开关 | 作用 |
|
||||
|------|------|
|
||||
| Claude 接管 | 接管 Claude Code 的请求 |
|
||||
| Codex 接管 | 接管 Codex 的请求 |
|
||||
| Gemini 接管 | 接管 Gemini CLI 的请求 |
|
||||
|
||||
可以同时开启多个应用的接管。
|
||||
|
||||
## 接管原理
|
||||
|
||||
### 配置修改
|
||||
|
||||
开启接管后,CC Switch 会修改应用的配置文件,将 API 端点指向本地代理。
|
||||
|
||||
**Claude 配置变更**:
|
||||
|
||||
```json
|
||||
// 接管前
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
|
||||
// 接管后
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex 配置变更**:
|
||||
|
||||
```toml
|
||||
# 接管前
|
||||
base_url = "https://api.openai.com/v1"
|
||||
|
||||
# 接管后
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
```
|
||||
|
||||
**Gemini 配置变更**:
|
||||
|
||||
```bash
|
||||
# 接管前
|
||||
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||
|
||||
# 接管后
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
### 请求转发
|
||||
|
||||
代理收到请求后:
|
||||
|
||||
1. 识别请求来源(Claude/Codex/Gemini)
|
||||
2. 查找该应用当前启用的供应商
|
||||
3. 将请求转发到供应商的实际端点
|
||||
4. 记录请求日志
|
||||
5. 返回响应给应用
|
||||
|
||||
## 接管状态指示
|
||||
|
||||
### 主界面指示
|
||||
|
||||
开启接管后,主界面会有以下变化:
|
||||
|
||||
- **代理 Logo 颜色**:从无色变为绿色
|
||||
- **供应商卡片**:当前活跃的供应商显示绿色边框
|
||||
|
||||
### 供应商卡片状态
|
||||
|
||||
| 状态 | 边框颜色 | 说明 |
|
||||
|------|----------|------|
|
||||
| 当前启用 | 蓝色 | 配置文件中的供应商(非代理模式) |
|
||||
| 代理活跃 | 绿色 | 代理实际使用的供应商 |
|
||||
| 普通 | 默认 | 未使用的供应商 |
|
||||
|
||||
## 关闭接管
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 在代理面板中关闭对应应用的接管开关
|
||||
2. 或直接停止代理服务
|
||||
|
||||
### 配置恢复
|
||||
|
||||
关闭接管时,CC Switch 会:
|
||||
|
||||
1. 将应用配置恢复到接管前的状态
|
||||
2. 保存当前的请求日志
|
||||
|
||||
## 接管与供应商切换
|
||||
|
||||
### 接管模式下切换供应商
|
||||
|
||||
在接管模式下切换供应商:
|
||||
|
||||
1. 在主界面点击供应商的「启用」按钮
|
||||
2. 代理立即使用新供应商转发请求
|
||||
3. **无需重启 CLI 工具**
|
||||
|
||||
这是接管模式的一大优势:切换供应商即时生效。
|
||||
|
||||
### 非接管模式下切换
|
||||
|
||||
在非接管模式下切换供应商:
|
||||
|
||||
1. 修改配置文件
|
||||
2. 需要重启 CLI 工具才能生效
|
||||
|
||||
## 多应用接管
|
||||
|
||||
可以同时接管多个应用,每个应用独立管理:
|
||||
|
||||
- 独立的供应商配置
|
||||
- 独立的故障转移队列
|
||||
- 独立的请求统计
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景一:用量监控
|
||||
|
||||
开启接管 + 日志记录,监控 API 使用情况。
|
||||
|
||||
### 场景二:快速切换
|
||||
|
||||
开启接管后,切换供应商无需重启 CLI 工具。
|
||||
|
||||
### 场景三:故障转移
|
||||
|
||||
开启接管是使用故障转移功能的前提。
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 性能影响
|
||||
|
||||
代理会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
|
||||
|
||||
### 网络要求
|
||||
|
||||
接管模式下,CLI 工具需要能够访问本地代理地址。
|
||||
|
||||
### 配置备份
|
||||
|
||||
开启接管前,CC Switch 会备份原始配置,关闭时恢复。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 接管后请求失败
|
||||
|
||||
检查:
|
||||
- 代理服务是否正常运行
|
||||
- 供应商配置是否正确
|
||||
- 网络是否正常
|
||||
|
||||
### 关闭接管后配置未恢复
|
||||
|
||||
可能原因:
|
||||
- 代理异常退出
|
||||
- 配置文件被其他程序修改
|
||||
|
||||
解决方法:
|
||||
- 手动编辑供应商,重新保存
|
||||
- 或重新启用再关闭接管
|
||||
@@ -79,7 +79,7 @@
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| [4.1-service.md](./4-proxy/4.1-service.md) | 启动代理、配置项、运行状态 |
|
||||
| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | 应用接管、配置修改、状态指示 |
|
||||
| [4.2-routing.md](./4-proxy/4.2-routing.md) | 应用路由、配置修改、状态指示 |
|
||||
| [4.3-failover.md](./4-proxy/4.3-failover.md) | 故障转移队列、熔断器、健康状态 |
|
||||
| [4.4-usage.md](./4-proxy/4.4-usage.md) | 用量统计、趋势图表、定价配置 |
|
||||
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | 模型检查、健康检测、延迟测试 |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.12.3",
|
||||
"version": "3.14.0",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -67,6 +67,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
|
||||
Generated
+20
@@ -89,6 +89,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.3
|
||||
version: 5.90.3(react@18.3.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.23
|
||||
version: 3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.8.0
|
||||
version: 2.8.0
|
||||
@@ -1468,6 +1471,15 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-virtual@3.13.23':
|
||||
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.13.23':
|
||||
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
|
||||
|
||||
'@tauri-apps/api@2.8.0':
|
||||
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
|
||||
|
||||
@@ -4275,6 +4287,14 @@ snapshots:
|
||||
'@tanstack/query-core': 5.90.3
|
||||
react: 18.3.1
|
||||
|
||||
'@tanstack/react-virtual@3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/virtual-core@3.13.23': {}
|
||||
|
||||
'@tauri-apps/api@2.8.0': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.8.1':
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.95"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
@@ -1,115 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
|
||||
const INDEX_FILE = path.join(ICONS_DIR, 'index.ts');
|
||||
const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts');
|
||||
|
||||
// Known metadata from previous configuration
|
||||
const KNOWN_METADATA = {
|
||||
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
|
||||
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
|
||||
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
|
||||
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
|
||||
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
|
||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
|
||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
|
||||
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
|
||||
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
|
||||
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
|
||||
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
|
||||
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
|
||||
packycode: { name: 'packycode', displayName: 'PackyCode', category: 'ai-provider', keywords: ['packycode', 'packy', 'packyapi'], defaultColor: 'currentColor' },
|
||||
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
|
||||
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
|
||||
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
|
||||
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
|
||||
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
|
||||
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
|
||||
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
|
||||
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
|
||||
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
|
||||
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
|
||||
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
|
||||
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
|
||||
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
|
||||
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
|
||||
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
|
||||
};
|
||||
|
||||
// Get all SVG files
|
||||
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
|
||||
|
||||
console.log(`Found ${files.length} SVG files.`);
|
||||
|
||||
// Generate index.ts
|
||||
const indexContent = `// Auto-generated icon index
|
||||
// Do not edit manually
|
||||
|
||||
export const icons: Record<string, string> = {
|
||||
${files.map(file => {
|
||||
const name = path.basename(file, '.svg');
|
||||
const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8');
|
||||
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
return ` '${name}': \`${escaped}\`,`;
|
||||
}).join('\n')}
|
||||
};
|
||||
|
||||
export const iconList = Object.keys(icons);
|
||||
|
||||
export function getIcon(name: string): string {
|
||||
return icons[name.toLowerCase()] || '';
|
||||
}
|
||||
|
||||
export function hasIcon(name: string): boolean {
|
||||
return name.toLowerCase() in icons;
|
||||
}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(INDEX_FILE, indexContent);
|
||||
console.log(`Generated ${INDEX_FILE}`);
|
||||
|
||||
// Generate metadata.ts
|
||||
const metadataEntries = files.map(file => {
|
||||
const name = path.basename(file, '.svg').toLowerCase();
|
||||
const known = KNOWN_METADATA[name];
|
||||
|
||||
if (known) {
|
||||
return ` ${name}: ${JSON.stringify(known)},`;
|
||||
}
|
||||
|
||||
// Default metadata for unknown icons
|
||||
return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`;
|
||||
});
|
||||
|
||||
const metadataContent = `// Icon metadata for search and categorization
|
||||
import { IconMetadata } from '@/types/icon';
|
||||
|
||||
export const iconMetadata: Record<string, IconMetadata> = {
|
||||
${metadataEntries.join('\n')}
|
||||
};
|
||||
|
||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||
return iconMetadata[name.toLowerCase()];
|
||||
}
|
||||
|
||||
export function searchIcons(query: string): string[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return Object.values(iconMetadata)
|
||||
.filter(meta =>
|
||||
meta.name.includes(lowerQuery) ||
|
||||
meta.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
meta.keywords.some(k => k.includes(lowerQuery))
|
||||
)
|
||||
.map(meta => meta.name);
|
||||
}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(METADATA_FILE, metadataContent);
|
||||
console.log(`Generated ${METADATA_FILE}`);
|
||||
Generated
+1
-1
@@ -735,7 +735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.12.3"
|
||||
version = "3.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.12.3"
|
||||
version = "3.14.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"updater:default",
|
||||
"core:window:allow-set-skip-taskbar",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-decorations",
|
||||
"process:allow-restart",
|
||||
"dialog:default"
|
||||
]
|
||||
|
||||
@@ -15,6 +15,8 @@ pub struct McpApps {
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
}
|
||||
|
||||
impl McpApps {
|
||||
@@ -26,6 +28,7 @@ impl McpApps {
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
AppType::Hermes => self.hermes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +40,7 @@ impl McpApps {
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,12 +59,15 @@ impl McpApps {
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
if self.hermes {
|
||||
apps.push(AppType::Hermes);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +82,8 @@ pub struct SkillApps {
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
}
|
||||
|
||||
impl SkillApps {
|
||||
@@ -85,6 +94,7 @@ impl SkillApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
}
|
||||
}
|
||||
@@ -96,6 +106,7 @@ impl SkillApps {
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
}
|
||||
}
|
||||
@@ -115,12 +126,15 @@ impl SkillApps {
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
if self.hermes {
|
||||
apps.push(AppType::Hermes);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
@@ -251,6 +265,9 @@ pub struct McpRoot {
|
||||
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub openclaw: McpConfig,
|
||||
/// Hermes MCP 配置(实际使用 config.yaml)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub hermes: McpConfig,
|
||||
}
|
||||
|
||||
impl Default for McpRoot {
|
||||
@@ -264,6 +281,7 @@ impl Default for McpRoot {
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
openclaw: McpConfig::default(),
|
||||
hermes: McpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,6 +306,8 @@ pub struct PromptRoot {
|
||||
pub opencode: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub openclaw: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub hermes: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
@@ -304,6 +324,7 @@ pub enum AppType {
|
||||
Gemini,
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
Hermes,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -314,15 +335,19 @@ impl AppType {
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
AppType::Hermes => "hermes",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this app uses additive mode
|
||||
///
|
||||
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw, Hermes)
|
||||
pub fn is_additive_mode(&self) -> bool {
|
||||
matches!(self, AppType::OpenCode | AppType::OpenClaw)
|
||||
matches!(
|
||||
self,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
|
||||
)
|
||||
}
|
||||
|
||||
/// Return an iterator over all app types
|
||||
@@ -333,6 +358,7 @@ impl AppType {
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
@@ -349,10 +375,11 @@ impl FromStr for AppType {
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"openclaw" => Ok(AppType::OpenClaw),
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -375,6 +402,9 @@ pub struct CommonConfigSnippets {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openclaw: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hermes: Option<String>,
|
||||
}
|
||||
|
||||
impl CommonConfigSnippets {
|
||||
@@ -386,6 +416,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
AppType::Hermes => self.hermes.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +428,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
AppType::Hermes => self.hermes = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,6 +470,7 @@ impl Default for MultiAppConfig {
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("openclaw".to_string(), ProviderManager::default());
|
||||
apps.insert("hermes".to_string(), ProviderManager::default());
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -598,6 +631,7 @@ impl MultiAppConfig {
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
AppType::OpenClaw => &self.mcp.openclaw,
|
||||
AppType::Hermes => &self.mcp.hermes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,6 +643,7 @@ impl MultiAppConfig {
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
AppType::OpenClaw => &mut self.mcp.openclaw,
|
||||
AppType::Hermes => &mut self.mcp.hermes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,6 +659,7 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -645,6 +681,7 @@ impl MultiAppConfig {
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
|| !self.prompts.openclaw.prompts.is_empty()
|
||||
|| !self.prompts.hermes.prompts.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -658,6 +695,7 @@ impl MultiAppConfig {
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
] {
|
||||
// 复用已有的单应用导入逻辑
|
||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||
@@ -729,6 +767,7 @@ impl MultiAppConfig {
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
|
||||
AppType::Hermes => &mut config.prompts.hermes.prompts,
|
||||
};
|
||||
|
||||
prompts.insert(id, prompt);
|
||||
@@ -769,6 +808,7 @@ impl MultiAppConfig {
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
|
||||
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
|
||||
};
|
||||
|
||||
for (id, entry) in old_servers {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct ManagedAuthAccount {
|
||||
pub avatar_url: Option<String>,
|
||||
pub authenticated_at: i64,
|
||||
pub is_default: bool,
|
||||
pub github_domain: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -59,6 +60,7 @@ fn map_account(
|
||||
login: account.login,
|
||||
avatar_url: account.avatar_url,
|
||||
authenticated_at: account.authenticated_at,
|
||||
github_domain: account.github_domain,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +81,7 @@ fn map_device_code_response(
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_start_login(
|
||||
auth_provider: String,
|
||||
github_domain: Option<String>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<ManagedAuthDeviceCodeResponse, String> {
|
||||
@@ -87,7 +90,7 @@ pub async fn auth_start_login(
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.start_device_flow(github_domain.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
@@ -108,6 +111,7 @@ pub async fn auth_start_login(
|
||||
pub async fn auth_poll_for_account(
|
||||
auth_provider: String,
|
||||
device_code: String,
|
||||
github_domain: Option<String>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<Option<ManagedAuthAccount>, String> {
|
||||
@@ -115,7 +119,10 @@ pub async fn auth_poll_for_account(
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
match auth_manager
|
||||
.poll_for_token(&device_code, github_domain.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account.map(|account| {
|
||||
|
||||
@@ -101,6 +101,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::Hermes => {
|
||||
let config_path = crate::hermes_config::get_hermes_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::hermes_config::get_hermes_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +126,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
};
|
||||
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
@@ -130,6 +140,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
};
|
||||
|
||||
if !config_dir.exists() {
|
||||
|
||||
@@ -20,11 +20,12 @@ pub struct CopilotAuthState(pub Arc<RwLock<CopilotAuthManager>>);
|
||||
/// 返回设备码和用户码,用于 OAuth 认证
|
||||
#[tauri::command]
|
||||
pub async fn copilot_start_device_flow(
|
||||
github_domain: Option<String>,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<GitHubDeviceCodeResponse, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.start_device_flow()
|
||||
.start_device_flow(github_domain.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -36,10 +37,14 @@ pub async fn copilot_start_device_flow(
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_poll_for_auth(
|
||||
device_code: String,
|
||||
github_domain: Option<String>,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<bool, String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
match auth_manager
|
||||
.poll_for_token(&device_code, github_domain.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(_account)) => {
|
||||
log::info!("[CopilotAuth] 用户已授权");
|
||||
Ok(true)
|
||||
@@ -61,10 +66,14 @@ pub async fn copilot_poll_for_auth(
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_poll_for_account(
|
||||
device_code: String,
|
||||
github_domain: Option<String>,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<GitHubAccount>, String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
match auth_manager
|
||||
.poll_for_token(&device_code, github_domain.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(account) => Ok(account),
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(None)
|
||||
|
||||
@@ -162,7 +162,7 @@ pub async fn set_auto_failover_enabled(
|
||||
|
||||
// 刷新托盘菜单,确保状态同步
|
||||
if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
|
||||
let _ = tray.set_menu(Some(new_menu));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::hermes_config;
|
||||
use crate::store::AppState;
|
||||
|
||||
/// Error string returned when `open_hermes_web_ui` cannot reach the Hermes
|
||||
/// FastAPI server. Kept in sync with the `HERMES_WEB_OFFLINE_ERROR` constant
|
||||
/// in `src/hooks/useHermes.ts` so the frontend can branch on it.
|
||||
const HERMES_WEB_OFFLINE_ERROR: &str = "hermes_web_offline";
|
||||
|
||||
// ============================================================================
|
||||
// Hermes Provider Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Import providers from Hermes live config to database.
|
||||
///
|
||||
/// Hermes uses additive mode — users may already have providers
|
||||
/// configured in config.yaml.
|
||||
#[tauri::command]
|
||||
pub fn import_hermes_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
crate::services::provider::import_hermes_providers_from_live(state.inner())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get provider names in the Hermes live config.
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
hermes_config::get_providers()
|
||||
.map(|providers| providers.keys().cloned().collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get a single Hermes provider fragment from live config.
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_live_provider(
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
hermes_config::get_provider(&providerId).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Scan config.yaml for known configuration hazards.
|
||||
#[tauri::command]
|
||||
pub fn scan_hermes_config_health() -> Result<Vec<hermes_config::HermesHealthWarning>, String> {
|
||||
hermes_config::scan_hermes_config_health().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Get Hermes model config (model section of config.yaml). Read-only — writes
|
||||
/// happen implicitly through `apply_switch_defaults` when switching providers.
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_model_config() -> Result<Option<hermes_config::HermesModelConfig>, String> {
|
||||
hermes_config::get_model_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory Files Commands
|
||||
// ============================================================================
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_memory(kind: hermes_config::MemoryKind) -> Result<String, String> {
|
||||
hermes_config::read_memory(kind).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_hermes_memory(kind: hermes_config::MemoryKind, content: String) -> Result<(), String> {
|
||||
hermes_config::write_memory(kind, &content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_memory_limits() -> Result<hermes_config::HermesMemoryLimits, String> {
|
||||
hermes_config::read_memory_limits().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_hermes_memory_enabled(
|
||||
kind: hermes_config::MemoryKind,
|
||||
enabled: bool,
|
||||
) -> Result<hermes_config::HermesWriteOutcome, String> {
|
||||
hermes_config::set_memory_enabled(kind, enabled).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hermes Web UI launcher
|
||||
// ============================================================================
|
||||
|
||||
/// Probe the local Hermes Web UI (FastAPI) and open it in the system browser.
|
||||
///
|
||||
/// Port discovery priority:
|
||||
/// 1. `HERMES_WEB_PORT` environment variable
|
||||
/// 2. Default 9119
|
||||
///
|
||||
/// Hermes wraps all `/api/*` routes in a Bearer-token middleware, so a GET
|
||||
/// against `/api/status` returning **either 200 or 401** confirms the server
|
||||
/// is live. The session token lives only in the Hermes process memory and is
|
||||
/// injected into the returned HTML via `window.__HERMES_SESSION_TOKEN__`, so
|
||||
/// there is no need (and no way) for CC Switch to inject it — we just open
|
||||
/// the URL and let Hermes handle auth.
|
||||
#[tauri::command]
|
||||
pub async fn open_hermes_web_ui(app: AppHandle, path: Option<String>) -> Result<(), String> {
|
||||
let port = std::env::var("HERMES_WEB_PORT")
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<u16>().ok())
|
||||
.unwrap_or(9119);
|
||||
|
||||
let base = format!("http://127.0.0.1:{port}");
|
||||
|
||||
// Probe /api/status with a short timeout. Hermes returns 200 when open or
|
||||
// 401 when the session token is required — either way the server is live.
|
||||
// Only a connection error / timeout means the server isn't running.
|
||||
let probe_url = format!("{base}/api/status");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(1200))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build probe client: {e}"))?;
|
||||
|
||||
match client.get(&probe_url).send().await {
|
||||
Ok(_) => {}
|
||||
Err(_) => return Err(HERMES_WEB_OFFLINE_ERROR.to_string()),
|
||||
}
|
||||
|
||||
let target = match path.as_deref() {
|
||||
Some(p) if p.starts_with('/') => format!("{base}{p}"),
|
||||
Some(p) if !p.is_empty() => format!("{base}/{p}"),
|
||||
_ => format!("{base}/"),
|
||||
};
|
||||
|
||||
app.opener()
|
||||
.open_url(&target, None::<String>)
|
||||
.map_err(|e| format!("failed to open Hermes Web UI: {e}"))
|
||||
}
|
||||
|
||||
/// Open the preferred terminal and run `hermes dashboard`. Non-blocking —
|
||||
/// callers should reinvoke `open_hermes_web_ui` once the server is ready,
|
||||
/// since Hermes startup can take several seconds and may fail outright if
|
||||
/// the `hermes-agent[web]` extras are missing.
|
||||
#[tauri::command]
|
||||
pub async fn launch_hermes_dashboard() -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
crate::commands::misc::launch_terminal_running("hermes dashboard", "hermes_dashboard")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("launch task join error: {e}"))?
|
||||
}
|
||||
@@ -202,5 +202,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
|
||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||
total += McpService::import_from_hermes(&state).unwrap_or(0);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
@@ -948,6 +948,7 @@ exec bash --norc --noprofile
|
||||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||||
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
|
||||
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
|
||||
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||
};
|
||||
|
||||
@@ -1309,6 +1310,188 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打开用户首选终端并在其中执行一条命令行。脚本尾部 `read -n 1` / `pause`
|
||||
/// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command
|
||||
/// not found` / `ModuleNotFoundError` 这类诊断信息。
|
||||
///
|
||||
/// **Security**:`command_line` 会被原样拼进 shell/batch 脚本,调用方必须
|
||||
/// 保证它是可信字符串(当前只由后端硬编码调用)。
|
||||
pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let pid = std::process::id();
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
let (script_file, script_content) = {
|
||||
let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid));
|
||||
let content = format!(
|
||||
r#"#!/bin/bash
|
||||
trap 'rm -f "{script_path}"' EXIT
|
||||
echo "[cc-switch] Starting: {cmd}"
|
||||
echo ""
|
||||
{cmd}
|
||||
echo ""
|
||||
echo "[cc-switch] Command exited. Press any key to close."
|
||||
read -n 1 -s
|
||||
"#,
|
||||
script_path = file.display(),
|
||||
cmd = command_line,
|
||||
);
|
||||
(file, content)
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
std::fs::write(&script_file, &script_content)
|
||||
.map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("terminal");
|
||||
|
||||
let result = match terminal {
|
||||
"iterm2" => launch_macos_iterm2(&script_file),
|
||||
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
||||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||||
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
|
||||
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
|
||||
_ => launch_macos_terminal_app(&script_file),
|
||||
};
|
||||
|
||||
if result.is_err() && terminal != "terminal" {
|
||||
log::warn!(
|
||||
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
|
||||
terminal,
|
||||
result.as_ref().err()
|
||||
);
|
||||
return launch_macos_terminal_app(&script_file);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
std::fs::write(&script_file, &script_content)
|
||||
.map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let default_terminals = [
|
||||
("gnome-terminal", vec!["--"]),
|
||||
("konsole", vec!["-e"]),
|
||||
("xfce4-terminal", vec!["-e"]),
|
||||
("mate-terminal", vec!["--"]),
|
||||
("lxterminal", vec!["-e"]),
|
||||
("alacritty", vec!["-e"]),
|
||||
("kitty", vec!["-e"]),
|
||||
("ghostty", vec!["-e"]),
|
||||
];
|
||||
|
||||
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
|
||||
let pref_args = default_terminals
|
||||
.iter()
|
||||
.find(|(name, _)| *name == pref.as_str())
|
||||
.map(|(_, args)| args.to_vec())
|
||||
.unwrap_or_else(|| vec!["-e"]);
|
||||
let mut list = vec![(pref.as_str(), pref_args)];
|
||||
for (name, args) in &default_terminals {
|
||||
if *name != pref.as_str() {
|
||||
list.push((*name, args.to_vec()));
|
||||
}
|
||||
}
|
||||
list
|
||||
} else {
|
||||
default_terminals
|
||||
.iter()
|
||||
.map(|(name, args)| (*name, args.to_vec()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut last_error = String::from("未找到可用的终端");
|
||||
|
||||
for (terminal, args) in terminals_to_try {
|
||||
let terminal_exists = which_command(terminal)
|
||||
|| ["/usr/bin", "/bin", "/usr/local/bin"]
|
||||
.iter()
|
||||
.any(|dir| std::path::Path::new(&format!("{}/{}", dir, terminal)).exists());
|
||||
|
||||
if terminal_exists {
|
||||
let spawn_result = Command::new(terminal)
|
||||
.args(&args)
|
||||
.arg("bash")
|
||||
.arg(script_file.to_string_lossy().as_ref())
|
||||
.spawn();
|
||||
match spawn_result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = format!("执行 {} 失败: {}", terminal, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&script_file);
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("cmd");
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_{}_{}.bat", label, pid));
|
||||
let content = format!(
|
||||
"@echo off\r\necho [cc-switch] Starting: {cmd}\r\necho.\r\n{cmd}\r\necho.\r\necho [cc-switch] Command exited. Press any key to close.\r\npause >nul\r\ndel \"%~f0\" >nul 2>&1\r\n",
|
||||
cmd = command_line,
|
||||
);
|
||||
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
|
||||
let bat_path = bat_file.to_string_lossy();
|
||||
let ps_cmd = format!("& '{}'", bat_path);
|
||||
|
||||
let result = match terminal {
|
||||
"powershell" => run_windows_start_command(
|
||||
&["powershell", "-NoExit", "-Command", &ps_cmd],
|
||||
"PowerShell",
|
||||
),
|
||||
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
|
||||
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"),
|
||||
};
|
||||
|
||||
let final_result = if result.is_err() && terminal != "cmd" {
|
||||
log::warn!(
|
||||
"首选终端 {} 启动失败,回退到 cmd: {:?}",
|
||||
terminal,
|
||||
result.as_ref().err()
|
||||
);
|
||||
run_windows_start_command(&["cmd", "/K", &bat_path], "cmd")
|
||||
} else {
|
||||
result
|
||||
};
|
||||
|
||||
// The .bat self-deletes (`del "%~f0"`) after it runs, but that only
|
||||
// fires if *some* terminal actually launched it. If every attempt
|
||||
// failed, sweep the temp file ourselves to avoid pollution.
|
||||
if final_result.is_err() {
|
||||
let _ = std::fs::remove_file(&bat_file);
|
||||
}
|
||||
final_result
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
let _ = (temp_dir, pid, command_line, label);
|
||||
Err("不支持的操作系统".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置窗口主题(Windows/macOS 标题栏颜色)
|
||||
/// theme: "dark" | "light" | "system"
|
||||
#[tauri::command]
|
||||
|
||||
@@ -10,6 +10,7 @@ mod deeplink;
|
||||
mod env;
|
||||
mod failover;
|
||||
mod global_proxy;
|
||||
mod hermes;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
@@ -42,6 +43,7 @@ pub use deeplink::*;
|
||||
pub use env::*;
|
||||
pub use failover::*;
|
||||
pub use global_proxy::*;
|
||||
pub use hermes::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
|
||||
@@ -253,6 +253,19 @@ pub async fn switch_proxy_provider(
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
// Block official providers during proxy takeover
|
||||
let provider = state
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, &app_type)
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
state
|
||||
.proxy_service
|
||||
.switch_proxy_target(&app_type, &provider_id)
|
||||
|
||||
@@ -25,6 +25,7 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,15 +116,25 @@ pub async fn stream_check_all_providers(
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
.unwrap_or_else(|e| {
|
||||
let (http_status, message) = match &e {
|
||||
crate::error::AppError::HttpStatus { status, .. } => (
|
||||
Some(*status),
|
||||
StreamCheckService::classify_http_status(*status).to_string(),
|
||||
),
|
||||
_ => (None, e.to_string()),
|
||||
};
|
||||
StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message,
|
||||
response_time_ms: None,
|
||||
http_status,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
error_category: None,
|
||||
}
|
||||
});
|
||||
|
||||
let _ = state
|
||||
|
||||
@@ -35,18 +35,26 @@ pub fn get_usage_trends(
|
||||
#[tauri::command]
|
||||
pub fn get_provider_stats(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<ProviderStats>, AppError> {
|
||||
state.db.get_provider_stats(app_type.as_deref())
|
||||
state
|
||||
.db
|
||||
.get_provider_stats(start_date, end_date, app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
#[tauri::command]
|
||||
pub fn get_model_stats(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<ModelStats>, AppError> {
|
||||
state.db.get_model_stats(app_type.as_deref())
|
||||
state
|
||||
.db
|
||||
.get_model_stats(start_date, end_date, app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct FailoverQueueItem {
|
||||
pub provider_id: String,
|
||||
pub provider_name: String,
|
||||
pub sort_index: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider_notes: Option<String>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
@@ -23,7 +25,7 @@ impl Database {
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, sort_index
|
||||
"SELECT id, name, sort_index, notes
|
||||
FROM providers
|
||||
WHERE app_type = ?1 AND in_failover_queue = 1
|
||||
ORDER BY COALESCE(sort_index, 999999), id ASC",
|
||||
@@ -36,6 +38,7 @@ impl Database {
|
||||
provider_id: row.get(0)?,
|
||||
provider_name: row.get(1)?,
|
||||
sort_index: row.get(2)?,
|
||||
provider_notes: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?
|
||||
|
||||
@@ -13,7 +13,7 @@ impl Database {
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -31,6 +31,7 @@ impl Database {
|
||||
let enabled_codex: bool = row.get(8)?;
|
||||
let enabled_gemini: bool = row.get(9)?;
|
||||
let enabled_opencode: bool = row.get(10)?;
|
||||
let enabled_hermes: bool = row.get(11)?;
|
||||
|
||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
@@ -46,6 +47,7 @@ impl Database {
|
||||
codex: enabled_codex,
|
||||
gemini: enabled_gemini,
|
||||
opencode: enabled_opencode,
|
||||
hermes: enabled_hermes,
|
||||
},
|
||||
description,
|
||||
homepage,
|
||||
@@ -70,8 +72,8 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mcp_servers (
|
||||
id, name, server_config, description, homepage, docs, tags,
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
@@ -87,6 +89,7 @@ impl Database {
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
server.apps.opencode,
|
||||
server.apps.hermes,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -23,7 +23,7 @@ impl Database {
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
installed_at, content_hash, updated_at
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -44,10 +44,11 @@ impl Database {
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
},
|
||||
installed_at: row.get(12)?,
|
||||
content_hash: row.get(13)?,
|
||||
updated_at: row.get::<_, i64>(14).unwrap_or(0),
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -67,7 +68,7 @@ impl Database {
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
installed_at, content_hash, updated_at
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -87,10 +88,11 @@ impl Database {
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
},
|
||||
installed_at: row.get(12)?,
|
||||
content_hash: row.get(13)?,
|
||||
updated_at: row.get::<_, i64>(14).unwrap_or(0),
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -107,9 +109,9 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
|
||||
installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -123,6 +125,7 @@ impl Database {
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.apps.opencode,
|
||||
skill.apps.hermes,
|
||||
skill.installed_at,
|
||||
skill.content_hash,
|
||||
skill.updated_at,
|
||||
@@ -154,8 +157,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
|
||||
@@ -4,13 +4,61 @@
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use chrono::{Duration, Local, TimeZone};
|
||||
|
||||
/// Compute the rollup/prune cutoff aligned to a local-day boundary.
|
||||
///
|
||||
/// Anything strictly older than the returned timestamp will be aggregated into
|
||||
/// `usage_daily_rollups` and deleted from `proxy_request_logs`. Aligning to the
|
||||
/// next local midnight after `(now - retain_days)` guarantees that the youngest
|
||||
/// rollup row always represents a *complete* local day. Without this alignment
|
||||
/// the cutoff falls mid-day, leaving the day half-rolled-up and half-pruned —
|
||||
/// which would silently under-count any range query that touches that day
|
||||
/// after `compute_rollup_date_bounds` trims partial-coverage rollup days.
|
||||
fn compute_local_midnight_cutoff(
|
||||
now: chrono::DateTime<Local>,
|
||||
retain_days: i64,
|
||||
) -> Result<i64, AppError> {
|
||||
let target_day = now
|
||||
.checked_sub_signed(Duration::days(retain_days))
|
||||
.ok_or_else(|| AppError::Database("rollup cutoff overflow".to_string()))?
|
||||
.date_naive();
|
||||
|
||||
// Use the *next* day's midnight so anything before it has fully been bucketed.
|
||||
let next_day = target_day
|
||||
.succ_opt()
|
||||
.ok_or_else(|| AppError::Database("rollup cutoff next-day overflow".to_string()))?;
|
||||
let naive_midnight = next_day
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| AppError::Database("rollup cutoff midnight overflow".to_string()))?;
|
||||
|
||||
let local_dt = match Local.from_local_datetime(&naive_midnight) {
|
||||
chrono::LocalResult::Single(dt) => dt,
|
||||
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
|
||||
chrono::LocalResult::None => {
|
||||
// DST gap: fall back to one hour later, which always exists.
|
||||
let bumped = naive_midnight + Duration::hours(1);
|
||||
match Local.from_local_datetime(&bumped) {
|
||||
chrono::LocalResult::Single(dt) => dt,
|
||||
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
|
||||
chrono::LocalResult::None => {
|
||||
return Err(AppError::Database(
|
||||
"rollup cutoff fell into DST gap".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(local_dt.timestamp())
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Aggregate proxy_request_logs older than `retain_days` into usage_daily_rollups,
|
||||
/// then delete the aggregated detail rows.
|
||||
/// Returns the number of deleted detail rows.
|
||||
pub fn rollup_and_prune(&self, retain_days: i64) -> Result<u64, AppError> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
|
||||
let cutoff = compute_local_midnight_cutoff(Local::now(), retain_days)?;
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// Check if there are any rows to process
|
||||
@@ -110,8 +158,49 @@ impl Database {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compute_local_midnight_cutoff;
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use chrono::{Local, TimeZone};
|
||||
|
||||
fn local_dt(
|
||||
year: i32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
hour: u32,
|
||||
minute: u32,
|
||||
second: u32,
|
||||
) -> chrono::DateTime<Local> {
|
||||
match Local.with_ymd_and_hms(year, month, day, hour, minute, second) {
|
||||
chrono::LocalResult::Single(dt) => dt,
|
||||
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
|
||||
chrono::LocalResult::None => panic!("invalid local datetime in test fixture"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutoff_is_aligned_to_local_midnight_after_target_day() -> Result<(), AppError> {
|
||||
// now = 2026-04-16 14:32:17 local; retain_days = 30
|
||||
// target day = 2026-03-17; cutoff should be 2026-03-18 00:00 local.
|
||||
let now = local_dt(2026, 4, 16, 14, 32, 17);
|
||||
let cutoff_ts = compute_local_midnight_cutoff(now, 30)?;
|
||||
let cutoff_dt = Local.timestamp_opt(cutoff_ts, 0).single().unwrap();
|
||||
let expected = local_dt(2026, 3, 18, 0, 0, 0);
|
||||
assert_eq!(cutoff_dt, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutoff_at_local_midnight_now_still_lands_on_midnight() -> Result<(), AppError> {
|
||||
// If `now` is itself local midnight, the math should not introduce drift.
|
||||
let now = local_dt(2026, 4, 16, 0, 0, 0);
|
||||
let cutoff_ts = compute_local_midnight_cutoff(now, 7)?;
|
||||
let cutoff_dt = Local.timestamp_opt(cutoff_ts, 0).single().unwrap();
|
||||
// (2026-04-16 - 7d) = 2026-04-09; cutoff = 2026-04-10 00:00 local.
|
||||
let expected = local_dt(2026, 4, 10, 0, 0, 0);
|
||||
assert_eq!(cutoff_dt, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_and_prune() -> Result<(), AppError> {
|
||||
|
||||
@@ -44,7 +44,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 8;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 10;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -65,7 +65,8 @@ impl Database {
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -93,6 +94,7 @@ impl Database {
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
@@ -418,6 +420,16 @@ impl Database {
|
||||
Self::migrate_v7_to_v8(conn)?;
|
||||
Self::set_user_version(conn, 8)?;
|
||||
}
|
||||
8 => {
|
||||
log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)");
|
||||
Self::migrate_v8_to_v9(conn)?;
|
||||
Self::set_user_version(conn, 9)?;
|
||||
}
|
||||
9 => {
|
||||
log::info!("迁移数据库从 v9 到 v10(添加 Hermes Agent 支持)");
|
||||
Self::migrate_v9_to_v10(conn)?;
|
||||
Self::set_user_version(conn, 10)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1144,11 +1156,62 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v8 → v9: 全面补充模型定价(清空 + 重新 seed)
|
||||
fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?;
|
||||
conn.execute("DELETE FROM model_pricing", [])
|
||||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||||
Self::seed_model_pricing(conn)?;
|
||||
log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v9 -> v10 迁移:添加 Hermes Agent 支持
|
||||
fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"mcp_servers",
|
||||
"enabled_hermes",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// skills table may not exist in databases migrated from very old versions
|
||||
if Self::table_exists(conn, "skills")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_hermes",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
|
||||
log::info!("v9 -> v10 迁移完成:已添加 Hermes Agent 支持");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_data = [
|
||||
// Claude 4.7 系列
|
||||
(
|
||||
"claude-opus-4-7",
|
||||
"Claude Opus 4.7",
|
||||
"5",
|
||||
"25",
|
||||
"0.50",
|
||||
"6.25",
|
||||
),
|
||||
// Claude 4.6 系列
|
||||
(
|
||||
"claude-opus-4-6-20260206",
|
||||
@@ -1491,6 +1554,38 @@ impl Database {
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-pro",
|
||||
"Doubao Seed 2.0 Pro",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-code",
|
||||
"Doubao Seed 2.0 Code",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-lite",
|
||||
"Doubao Seed 2.0 Lite",
|
||||
"0.25",
|
||||
"2",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-mini",
|
||||
"Doubao Seed 2.0 Mini",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek 系列
|
||||
(
|
||||
"deepseek-v3.2",
|
||||
@@ -1512,17 +1607,17 @@ impl Database {
|
||||
(
|
||||
"deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
"0.28",
|
||||
"0.42",
|
||||
"0.028",
|
||||
"0.27",
|
||||
"1.10",
|
||||
"0.07",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-reasoner",
|
||||
"DeepSeek Reasoner",
|
||||
"0.28",
|
||||
"0.42",
|
||||
"0.028",
|
||||
"0.55",
|
||||
"2.19",
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
// Kimi (月之暗面)
|
||||
@@ -1543,7 +1638,8 @@ impl Database {
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
|
||||
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
|
||||
// MiniMax 系列
|
||||
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
|
||||
(
|
||||
@@ -1555,35 +1651,211 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
|
||||
("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
|
||||
(
|
||||
"minimax-m2.5-lightning",
|
||||
"MiniMax M2.5 Lightning",
|
||||
"0.30",
|
||||
"2.40",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"minimax-m2.7",
|
||||
"MiniMax M2.7",
|
||||
"0.30",
|
||||
"1.20",
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
(
|
||||
"minimax-m2.7-highspeed",
|
||||
"MiniMax M2.7 Highspeed",
|
||||
"0.60",
|
||||
"2.40",
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
|
||||
// Mimo (小米)
|
||||
("glm-5", "GLM-5", "0.72", "2.30", "0", "0"),
|
||||
("glm-5.1", "GLM-5.1", "0.95", "3.15", "0", "0"),
|
||||
// MiMo (小米)
|
||||
(
|
||||
"mimo-v2-flash",
|
||||
"Mimo V2 Flash",
|
||||
"MiMo V2 Flash",
|
||||
"0.09",
|
||||
"0.29",
|
||||
"0.009",
|
||||
"0",
|
||||
),
|
||||
("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
|
||||
// Qwen 系列 (阿里巴巴)
|
||||
("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
|
||||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||||
(
|
||||
"qwen3-235b-a22b",
|
||||
"Qwen3 235B-A22B",
|
||||
"0.70",
|
||||
"8.40",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-plus",
|
||||
"Qwen3 Coder Plus",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-flash",
|
||||
"Qwen3 Coder Flash",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-next",
|
||||
"Qwen3 Coder Next",
|
||||
"0.12",
|
||||
"0.75",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"),
|
||||
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||
// Grok 系列 (xAI)
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
"Grok 4.20 Reasoning",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"Grok 4.20",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4-1-fast-reasoning",
|
||||
"Grok 4.1 Fast Reasoning",
|
||||
"0.20",
|
||||
"0.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4-1-fast-non-reasoning",
|
||||
"Grok 4.1 Fast",
|
||||
"0.20",
|
||||
"0.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
("grok-4", "Grok 4", "3", "15", "0.75", "0"),
|
||||
(
|
||||
"grok-code-fast-1",
|
||||
"Grok Code Fast",
|
||||
"0.20",
|
||||
"1.50",
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
|
||||
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
|
||||
// Mistral 系列
|
||||
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
|
||||
(
|
||||
"devstral-small-1.1",
|
||||
"Devstral Small 1.1",
|
||||
"0.07",
|
||||
"0.28",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
|
||||
(
|
||||
"devstral-medium",
|
||||
"Devstral Medium",
|
||||
"0.40",
|
||||
"2",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-large-3-2512",
|
||||
"Mistral Large 3",
|
||||
"0.50",
|
||||
"1.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-medium-3.1",
|
||||
"Mistral Medium 3.1",
|
||||
"0.40",
|
||||
"2",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-small-3.2-24b",
|
||||
"Mistral Small 3.2",
|
||||
"0.075",
|
||||
"0.20",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
("magistral-medium", "Magistral Medium", "2", "5", "0", "0"),
|
||||
// Cohere 系列
|
||||
("command-a", "Cohere Command A", "2.50", "10", "0", "0"),
|
||||
(
|
||||
"command-r-plus",
|
||||
"Cohere Command R+",
|
||||
"2.50",
|
||||
"10",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"),
|
||||
// OpenAI 补充
|
||||
("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
|
||||
("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"),
|
||||
("o1", "OpenAI o1", "15", "60", "7.50", "0"),
|
||||
("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
|
||||
("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
|
||||
("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
|
||||
("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
|
||||
];
|
||||
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
conn.execute(
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"INSERT OR IGNORE 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 (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input,
|
||||
output,
|
||||
cache_read,
|
||||
cache_creation
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?;
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
stmt.execute(rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input,
|
||||
output,
|
||||
cache_read,
|
||||
cache_creation
|
||||
])
|
||||
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
};
|
||||
|
||||
for app in apps_str.split(',') {
|
||||
@@ -179,6 +180,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
// OpenClaw doesn't support MCP, ignore silently
|
||||
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
|
||||
}
|
||||
"hermes" => apps.hermes = true,
|
||||
other => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': {other}"
|
||||
|
||||
@@ -31,7 +31,7 @@ pub use skill::import_skill_from_deeplink;
|
||||
///
|
||||
/// Represents a parsed ccswitch:// URL ready for processing.
|
||||
/// This struct contains all possible fields for all resource types.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeepLinkImportRequest {
|
||||
/// Protocol version (e.g., "v1")
|
||||
|
||||
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -262,10 +262,10 @@ fn parse_mcp_deeplink(
|
||||
let trimmed = app.trim();
|
||||
if !matches!(
|
||||
trimmed,
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{trimmed}'"
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,8 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw => build_openclaw_settings(request),
|
||||
AppType::OpenClaw => build_additive_app_settings(request),
|
||||
AppType::Hermes => build_hermes_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -393,11 +394,11 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
/// Build settings for OpenClaw (camelCase live config).
|
||||
/// Format: { baseUrl, apiKey, api, models }
|
||||
fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
// Build OpenClaw provider config
|
||||
// Format: { baseUrl, apiKey, api, models }
|
||||
let mut config = serde_json::Map::new();
|
||||
|
||||
if !endpoint.is_empty() {
|
||||
@@ -408,10 +409,49 @@ fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value
|
||||
config.insert("apiKey".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
// Default to OpenAI-compatible API
|
||||
config.insert("api".to_string(), json!("openai-completions"));
|
||||
|
||||
// Build models array
|
||||
if let Some(model) = &request.model {
|
||||
config.insert(
|
||||
"models".to_string(),
|
||||
json!([{ "id": model, "name": model }]),
|
||||
);
|
||||
}
|
||||
|
||||
json!(config)
|
||||
}
|
||||
|
||||
/// Build Hermes provider settings (snake_case YAML-native fields).
|
||||
///
|
||||
/// Hermes' `custom_providers:` entries use `base_url` / `api_key` / `api_mode`
|
||||
/// (see `_VALID_CUSTOM_PROVIDER_FIELDS` in upstream `hermes_cli/config.py`).
|
||||
/// Emitting camelCase here — as the OpenClaw path does — would poison the
|
||||
/// YAML with unknown root fields the Hermes runtime ignores.
|
||||
///
|
||||
/// `api_mode` is always written explicitly. Deeplinks have no field to carry
|
||||
/// it, so we default to `chat_completions` (the most widely compatible
|
||||
/// protocol) and let the user adjust via the UI after import. We never rely
|
||||
/// on Hermes' built-in URL heuristics, which only recognize a handful of
|
||||
/// official endpoints.
|
||||
fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
let mut config = serde_json::Map::new();
|
||||
|
||||
if let Some(name) = request.name.as_deref().filter(|s| !s.is_empty()) {
|
||||
config.insert("name".to_string(), json!(name));
|
||||
}
|
||||
|
||||
if !endpoint.is_empty() {
|
||||
config.insert("base_url".to_string(), json!(endpoint));
|
||||
}
|
||||
|
||||
if let Some(api_key) = &request.api_key {
|
||||
config.insert("api_key".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
config.insert("api_mode".to_string(), json!("chat_completions"));
|
||||
|
||||
if let Some(model) = &request.model {
|
||||
config.insert(
|
||||
"models".to_string(),
|
||||
@@ -484,7 +524,7 @@ pub fn parse_and_merge_config(
|
||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||
// Additive mode apps use JSON config directly; pass through as-is
|
||||
"openclaw" | "opencode" => {
|
||||
"openclaw" | "opencode" | "hermes" => {
|
||||
merge_additive_config(&mut merged, &config_value)?;
|
||||
}
|
||||
"" => {
|
||||
@@ -711,3 +751,89 @@ fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hermes_request() -> DeepLinkImportRequest {
|
||||
DeepLinkImportRequest {
|
||||
resource: "provider".to_string(),
|
||||
app: Some("hermes".to_string()),
|
||||
name: Some("MyHermes".to_string()),
|
||||
endpoint: Some("https://api.example.com/v1".to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
model: Some("anthropic/claude-opus-4-7".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hermes_settings_emits_snake_case() {
|
||||
let settings = build_hermes_settings(&hermes_request());
|
||||
let obj = settings.as_object().expect("settings must be object");
|
||||
|
||||
assert_eq!(obj.get("name").unwrap(), "MyHermes");
|
||||
assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com/v1");
|
||||
assert_eq!(obj.get("api_key").unwrap(), "sk-test");
|
||||
|
||||
// camelCase and legacy fields must NOT be present
|
||||
assert!(obj.get("baseUrl").is_none(), "no camelCase baseUrl");
|
||||
assert!(obj.get("apiKey").is_none(), "no camelCase apiKey");
|
||||
assert!(obj.get("api").is_none(), "no legacy 'api' field");
|
||||
|
||||
// models array with the deeplink model id
|
||||
let models = obj.get("models").unwrap().as_array().unwrap();
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0]["id"], "anthropic/claude-opus-4-7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hermes_settings_writes_default_api_mode() {
|
||||
let settings = build_hermes_settings(&hermes_request());
|
||||
assert_eq!(
|
||||
settings.as_object().unwrap().get("api_mode").unwrap(),
|
||||
"chat_completions",
|
||||
"api_mode must be written explicitly so Hermes never falls back to URL auto-detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hermes_settings_skips_missing_optional_fields() {
|
||||
let request = DeepLinkImportRequest {
|
||||
resource: "provider".to_string(),
|
||||
app: Some("hermes".to_string()),
|
||||
name: Some("Minimal".to_string()),
|
||||
endpoint: None,
|
||||
api_key: None,
|
||||
model: None,
|
||||
..Default::default()
|
||||
};
|
||||
let settings = build_hermes_settings(&request);
|
||||
let obj = settings.as_object().unwrap();
|
||||
|
||||
assert_eq!(obj.get("name").unwrap(), "Minimal");
|
||||
assert!(obj.get("base_url").is_none());
|
||||
assert!(obj.get("api_key").is_none());
|
||||
assert!(obj.get("models").is_none());
|
||||
assert_eq!(obj.get("api_mode").unwrap(), "chat_completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openclaw_still_uses_camel_case() {
|
||||
// OpenClaw's live config natively uses camelCase; guard against a
|
||||
// refactor accidentally flipping it to snake_case.
|
||||
let request = DeepLinkImportRequest {
|
||||
resource: "provider".to_string(),
|
||||
app: Some("openclaw".to_string()),
|
||||
name: Some("c".to_string()),
|
||||
endpoint: Some("https://api.example.com".to_string()),
|
||||
api_key: Some("k".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let settings = build_additive_app_settings(&request);
|
||||
let obj = settings.as_object().unwrap();
|
||||
assert!(obj.contains_key("baseUrl"));
|
||||
assert!(obj.contains_key("apiKey"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ pub enum AppError {
|
||||
McpValidation(String),
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("HTTP {status}: {body}")]
|
||||
HttpStatus { status: u16, body: String },
|
||||
#[error("{zh} ({en})")]
|
||||
Localized {
|
||||
key: &'static str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+35
-2
@@ -11,6 +11,7 @@ mod deeplink;
|
||||
mod error;
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
pub mod hermes_config;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -168,7 +169,7 @@ async fn update_tray_menu(
|
||||
) -> Result<bool, String> {
|
||||
match tray::create_tray_menu(&app, state.inner()) {
|
||||
Ok(new_menu) => {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Some(tray) = app.tray_by_id(tray::TRAY_ID) {
|
||||
tray.set_menu(Some(new_menu))
|
||||
.map_err(|e| format!("更新托盘菜单失败: {e}"))?;
|
||||
return Ok(true);
|
||||
@@ -540,6 +541,13 @@ pub fn run() {
|
||||
Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import OpenClaw providers: {e}"),
|
||||
}
|
||||
match crate::services::provider::import_hermes_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} Hermes provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No new Hermes providers to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Hermes providers: {e}"),
|
||||
}
|
||||
|
||||
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
{
|
||||
@@ -627,6 +635,14 @@ pub fn run() {
|
||||
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_hermes(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from Hermes");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No Hermes MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Hermes MCP: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 导入提示词文件(表空时触发)
|
||||
@@ -639,6 +655,7 @@ pub fn run() {
|
||||
crate::app_config::AppType::Gemini,
|
||||
crate::app_config::AppType::OpenCode,
|
||||
crate::app_config::AppType::OpenClaw,
|
||||
crate::app_config::AppType::Hermes,
|
||||
] {
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
@@ -728,7 +745,7 @@ pub fn run() {
|
||||
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
|
||||
|
||||
// 构建托盘
|
||||
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||
let mut tray_builder = TrayIconBuilder::with_id(tray::TRAY_ID)
|
||||
.on_tray_icon_event(|_tray, event| match event {
|
||||
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
|
||||
TrayIconEvent::Click { .. } => {}
|
||||
@@ -971,6 +988,10 @@ pub fn run() {
|
||||
// 静默启动:根据设置决定是否显示主窗口
|
||||
let settings = crate::settings::get_settings();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
// 在窗口首次显示前同步装饰状态,避免前端加载后再切换导致标题栏闪烁
|
||||
// 仅 Linux 生效:解决 Wayland 下系统窗口按钮不可用的问题
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = window.set_decorations(!settings.use_app_window_controls);
|
||||
if settings.silent_startup {
|
||||
// 静默启动模式:保持窗口隐藏
|
||||
let _ = window.hide();
|
||||
@@ -1230,6 +1251,18 @@ pub fn run() {
|
||||
commands::set_openclaw_env,
|
||||
commands::get_openclaw_tools,
|
||||
commands::set_openclaw_tools,
|
||||
// Hermes specific
|
||||
commands::import_hermes_providers_from_live,
|
||||
commands::get_hermes_live_provider_ids,
|
||||
commands::get_hermes_live_provider,
|
||||
commands::scan_hermes_config_health,
|
||||
commands::get_hermes_model_config,
|
||||
commands::open_hermes_web_ui,
|
||||
commands::launch_hermes_dashboard,
|
||||
commands::get_hermes_memory,
|
||||
commands::set_hermes_memory,
|
||||
commands::get_hermes_memory_limits,
|
||||
commands::set_hermes_memory_enabled,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
|
||||
@@ -92,6 +92,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -236,6 +236,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -88,6 +88,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
codex: false,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Hermes MCP sync and import module
|
||||
//!
|
||||
//! Handles conversion between CC Switch unified MCP format and Hermes config.yaml format.
|
||||
//!
|
||||
//! ## Format mapping
|
||||
//!
|
||||
//! | CC Switch unified (JSON) | Hermes config.yaml (YAML) |
|
||||
//! |-------------------------------------------------|---------------------------------|
|
||||
//! | `{"type":"stdio","command":"npx","args":[...],"env":{}}` | `command: npx`, `args: [...]`, `env: {}` |
|
||||
//! | `{"type":"sse"/"http","url":"...","headers":{}}` | `url: "..."`, `headers: {}` |
|
||||
//!
|
||||
//! Key differences from Claude format:
|
||||
//! - Hermes has NO explicit `type` field -- it infers stdio (has `command`) vs HTTP (has `url`)
|
||||
//! - Hermes has extra fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling`
|
||||
//! - These Hermes-specific fields are preserved on merge-on-write and stripped on import
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
use crate::hermes_config;
|
||||
|
||||
use super::validation::validate_server_spec;
|
||||
|
||||
/// Hermes-specific fields preserved on merge-on-write, stripped on import.
|
||||
/// Update this list when Hermes adds new per-server config fields.
|
||||
///
|
||||
/// `auth` ("oauth" / absent) is an OAuth-type declaration read by Hermes —
|
||||
/// CC Switch has no OAuth UI, but losing the field on round-trip downgrades
|
||||
/// the server to unauthenticated calls.
|
||||
const HERMES_EXTRA_FIELDS: &[&str] = &[
|
||||
"enabled",
|
||||
"timeout",
|
||||
"connect_timeout",
|
||||
"tools",
|
||||
"sampling",
|
||||
"roots",
|
||||
"auth",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Check if Hermes MCP sync should proceed
|
||||
fn should_sync_hermes_mcp() -> bool {
|
||||
hermes_config::get_hermes_dir().exists()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format Conversion: CC Switch -> Hermes
|
||||
// ============================================================================
|
||||
|
||||
/// Convert CC Switch unified format to Hermes format
|
||||
///
|
||||
/// Conversion rules:
|
||||
/// - `stdio`: output `command`, `args`, `env` (strip `type` field)
|
||||
/// - `sse`/`http`: output `url`, `headers` (strip `type` field)
|
||||
/// - Always add `enabled: true`
|
||||
fn convert_to_hermes_format(spec: &Value) -> Result<Value, AppError> {
|
||||
let obj = spec
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
|
||||
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
match typ {
|
||||
"stdio" => {
|
||||
if let Some(command) = obj.get("command") {
|
||||
result.insert("command".into(), command.clone());
|
||||
}
|
||||
if let Some(args) = obj.get("args") {
|
||||
if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
|
||||
result.insert("args".into(), args.clone());
|
||||
}
|
||||
}
|
||||
if let Some(env) = obj.get("env") {
|
||||
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("env".into(), env.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
"sse" | "http" => {
|
||||
if let Some(url) = obj.get("url") {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
|
||||
}
|
||||
}
|
||||
|
||||
result.insert("enabled".into(), json!(true));
|
||||
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format Conversion: Hermes -> CC Switch
|
||||
// ============================================================================
|
||||
|
||||
/// Convert Hermes format to CC Switch unified format
|
||||
///
|
||||
/// Conversion rules:
|
||||
/// - If `command` exists: set `type: "stdio"`, extract `command`, `args`, `env`
|
||||
/// - If `url` exists: set `type: "sse"`, extract `url`, `headers`
|
||||
/// - Strip Hermes-specific fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling`
|
||||
fn convert_from_hermes_format(id: &str, spec: &Value) -> Result<Value, AppError> {
|
||||
let obj = spec
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?;
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
if obj.contains_key("command") {
|
||||
// stdio type
|
||||
result.insert("type".into(), json!("stdio"));
|
||||
|
||||
if let Some(command) = obj.get("command") {
|
||||
result.insert("command".into(), command.clone());
|
||||
}
|
||||
if let Some(args) = obj.get("args") {
|
||||
if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
|
||||
result.insert("args".into(), args.clone());
|
||||
}
|
||||
}
|
||||
if let Some(env) = obj.get("env") {
|
||||
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("env".into(), env.clone());
|
||||
}
|
||||
}
|
||||
} else if obj.contains_key("url") {
|
||||
// HTTP/SSE type
|
||||
result.insert("type".into(), json!("sse"));
|
||||
|
||||
if let Some(url) = obj.get("url") {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"Hermes MCP server '{id}' has neither 'command' nor 'url' field"
|
||||
)));
|
||||
}
|
||||
|
||||
// Note: Hermes-specific fields (enabled, timeout, connect_timeout, tools, sampling)
|
||||
// are intentionally NOT copied -- they are stripped on import.
|
||||
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API: Sync Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Sync a single MCP server to Hermes live config (merge-on-write)
|
||||
///
|
||||
/// Strategy:
|
||||
/// 1. Read existing mcp_servers from config.yaml
|
||||
/// 2. If server already exists, merge: keep Hermes-specific fields, overwrite core fields
|
||||
/// 3. Set `enabled: true`
|
||||
/// 4. Write back
|
||||
pub fn sync_single_server_to_hermes(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_hermes_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hermes_spec = convert_to_hermes_format(server_spec)?;
|
||||
let id_owned = id.to_string();
|
||||
|
||||
hermes_config::update_mcp_servers_yaml(|servers| {
|
||||
let id_yaml = serde_yaml::Value::String(id_owned.clone());
|
||||
|
||||
let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) {
|
||||
let existing_json = hermes_config::yaml_to_json(existing_yaml)?;
|
||||
merge_hermes_spec(&existing_json, &hermes_spec)
|
||||
} else {
|
||||
hermes_spec.clone()
|
||||
};
|
||||
|
||||
let merged_yaml_value = hermes_config::json_to_yaml(&merged_json)?;
|
||||
servers.insert(id_yaml, merged_yaml_value);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge new spec into existing Hermes spec, preserving Hermes-specific fields.
|
||||
///
|
||||
/// Core fields (command, args, env, url, headers) come from `new_spec`.
|
||||
/// Hermes-specific fields (enabled, tools, sampling, etc.) are kept from
|
||||
/// `existing` — this prevents CC Switch from overwriting user customizations.
|
||||
fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value {
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
// Copy Hermes-specific fields from existing config
|
||||
if let Some(existing_obj) = existing.as_object() {
|
||||
for &field in HERMES_EXTRA_FIELDS {
|
||||
if let Some(val) = existing_obj.get(field) {
|
||||
result.insert(field.to_string(), val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overwrite with core fields from new spec; for Hermes-specific fields,
|
||||
// only apply from new_spec if existing didn't already have them
|
||||
if let Some(new_obj) = new_spec.as_object() {
|
||||
for (key, val) in new_obj {
|
||||
if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) {
|
||||
continue; // Existing Hermes-specific field takes precedence
|
||||
}
|
||||
result.insert(key.clone(), val.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(result)
|
||||
}
|
||||
|
||||
/// Remove a single MCP server from Hermes live config
|
||||
pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_hermes_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let id_owned = id.to_string();
|
||||
hermes_config::update_mcp_servers_yaml(|servers| {
|
||||
servers.remove(serde_yaml::Value::String(id_owned.clone()));
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Import MCP servers from Hermes config to unified structure
|
||||
///
|
||||
/// Existing servers will have Hermes app enabled without overwriting other fields.
|
||||
pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let yaml_map = hermes_config::get_mcp_servers_yaml()?;
|
||||
if yaml_map.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Ensure servers map exists
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (key, spec_yaml) in &yaml_map {
|
||||
let id = match key.as_str() {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
log::warn!("Skip Hermes MCP server with non-string key");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert YAML value to JSON
|
||||
let spec_json = match hermes_config::yaml_to_json(spec_yaml) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("Skip Hermes MCP server '{id}': failed to convert YAML to JSON: {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert from Hermes format to unified format
|
||||
let unified_spec = match convert_from_hermes_format(&id, &spec_json) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Skip invalid Hermes MCP server '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate the converted spec
|
||||
if let Err(e) = validate_server_spec(&unified_spec) {
|
||||
log::warn!("Skip invalid MCP server '{id}' after conversion: {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(&id) {
|
||||
// Existing server: just enable Hermes app
|
||||
if !existing.apps.hermes {
|
||||
existing.apps.hermes = true;
|
||||
changed += 1;
|
||||
log::info!("MCP server '{id}' enabled for Hermes");
|
||||
}
|
||||
} else {
|
||||
// New server: default to only Hermes enabled
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: unified_spec,
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: true,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("Imported new MCP server '{id}' from Hermes");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!(
|
||||
"Import completed with {} failures: {:?}",
|
||||
errors.len(),
|
||||
errors
|
||||
);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ========================================================================
|
||||
// convert_to_hermes_format tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_convert_stdio_to_hermes() {
|
||||
let spec = json!({
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"env": { "HOME": "/Users/test" }
|
||||
});
|
||||
|
||||
let result = convert_to_hermes_format(&spec).unwrap();
|
||||
// No type field in Hermes format
|
||||
assert!(result.get("type").is_none());
|
||||
assert_eq!(result["command"], "npx");
|
||||
assert_eq!(result["args"][0], "-y");
|
||||
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
|
||||
assert_eq!(result["env"]["HOME"], "/Users/test");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_http_to_hermes() {
|
||||
let spec = json!({
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" }
|
||||
});
|
||||
|
||||
let result = convert_to_hermes_format(&spec).unwrap();
|
||||
assert!(result.get("type").is_none());
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_http_type_to_hermes() {
|
||||
let spec = json!({
|
||||
"type": "http",
|
||||
"url": "https://example.com/mcp"
|
||||
});
|
||||
|
||||
let result = convert_to_hermes_format(&spec).unwrap();
|
||||
assert!(result.get("type").is_none());
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_stdio_empty_env_to_hermes() {
|
||||
let spec = json!({
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [],
|
||||
"env": {}
|
||||
});
|
||||
|
||||
let result = convert_to_hermes_format(&spec).unwrap();
|
||||
assert_eq!(result["command"], "node");
|
||||
// Empty args and env should be omitted
|
||||
assert!(result.get("args").is_none());
|
||||
assert!(result.get("env").is_none());
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_unknown_type_to_hermes_fails() {
|
||||
let spec = json!({ "type": "grpc", "command": "foo" });
|
||||
assert!(convert_to_hermes_format(&spec).is_err());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// convert_from_hermes_format tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_convert_hermes_stdio_to_unified() {
|
||||
let spec = json!({
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"env": { "HOME": "/Users/test" },
|
||||
"enabled": true,
|
||||
"timeout": 30,
|
||||
"connect_timeout": 10,
|
||||
"tools": { "include": ["read_file"] },
|
||||
"sampling": { "enabled": true }
|
||||
});
|
||||
|
||||
let result = convert_from_hermes_format("filesystem", &spec).unwrap();
|
||||
assert_eq!(result["type"], "stdio");
|
||||
assert_eq!(result["command"], "npx");
|
||||
assert_eq!(result["args"][0], "-y");
|
||||
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
|
||||
assert_eq!(result["env"]["HOME"], "/Users/test");
|
||||
// Hermes-specific fields should be stripped
|
||||
assert!(result.get("enabled").is_none());
|
||||
assert!(result.get("timeout").is_none());
|
||||
assert!(result.get("connect_timeout").is_none());
|
||||
assert!(result.get("tools").is_none());
|
||||
assert!(result.get("sampling").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_hermes_http_to_unified() {
|
||||
let spec = json!({
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" },
|
||||
"enabled": true,
|
||||
"timeout": 60
|
||||
});
|
||||
|
||||
let result = convert_from_hermes_format("remote-server", &spec).unwrap();
|
||||
assert_eq!(result["type"], "sse");
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||
// Hermes-specific fields should be stripped
|
||||
assert!(result.get("enabled").is_none());
|
||||
assert!(result.get("timeout").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_hermes_no_command_no_url_fails() {
|
||||
let spec = json!({ "enabled": true, "timeout": 30 });
|
||||
assert!(convert_from_hermes_format("bad-server", &spec).is_err());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Merge-on-write tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_merge_preserves_hermes_specific_fields() {
|
||||
let existing = json!({
|
||||
"command": "old-cmd",
|
||||
"args": ["old-arg"],
|
||||
"enabled": true,
|
||||
"timeout": 30,
|
||||
"connect_timeout": 10,
|
||||
"tools": { "include": ["read_file"] },
|
||||
"sampling": { "enabled": true }
|
||||
});
|
||||
|
||||
let new_spec = json!({
|
||||
"command": "new-cmd",
|
||||
"args": ["new-arg"],
|
||||
"env": { "KEY": "value" },
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let merged = merge_hermes_spec(&existing, &new_spec);
|
||||
|
||||
// Core fields should be overwritten
|
||||
assert_eq!(merged["command"], "new-cmd");
|
||||
assert_eq!(merged["args"][0], "new-arg");
|
||||
assert_eq!(merged["env"]["KEY"], "value");
|
||||
|
||||
// Hermes-specific fields should be preserved from existing
|
||||
assert_eq!(merged["timeout"], 30);
|
||||
assert_eq!(merged["connect_timeout"], 10);
|
||||
assert_eq!(merged["tools"]["include"][0], "read_file");
|
||||
assert_eq!(merged["sampling"]["enabled"], true);
|
||||
assert_eq!(merged["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_preserves_auth_field() {
|
||||
let existing = json!({
|
||||
"url": "https://mcp.example.com",
|
||||
"auth": "oauth",
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let new_spec = json!({
|
||||
"url": "https://mcp.example.com/updated",
|
||||
"headers": { "X-Trace": "abc" },
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let merged = merge_hermes_spec(&existing, &new_spec);
|
||||
|
||||
assert_eq!(merged["url"], "https://mcp.example.com/updated");
|
||||
assert_eq!(merged["headers"]["X-Trace"], "abc");
|
||||
assert_eq!(
|
||||
merged["auth"], "oauth",
|
||||
"auth declaration must survive CC Switch round-trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_hermes_strips_auth_on_import() {
|
||||
let spec = json!({
|
||||
"url": "https://mcp.example.com",
|
||||
"auth": "oauth",
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let result = convert_from_hermes_format("remote", &spec).unwrap();
|
||||
assert_eq!(result["type"], "sse");
|
||||
assert_eq!(result["url"], "https://mcp.example.com");
|
||||
assert!(
|
||||
result.get("auth").is_none(),
|
||||
"auth stays Hermes-specific; stripped from unified format"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_new_server_no_existing_extra_fields() {
|
||||
let existing = json!({
|
||||
"command": "old-cmd"
|
||||
});
|
||||
|
||||
let new_spec = json!({
|
||||
"command": "new-cmd",
|
||||
"args": ["arg1"],
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let merged = merge_hermes_spec(&existing, &new_spec);
|
||||
assert_eq!(merged["command"], "new-cmd");
|
||||
assert_eq!(merged["args"][0], "arg1");
|
||||
assert_eq!(merged["enabled"], true);
|
||||
// No extra fields to preserve
|
||||
assert!(merged.get("timeout").is_none());
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@
|
||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||
//! - `gemini` - Gemini MCP 同步和导入
|
||||
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
|
||||
//! - `hermes` - Hermes MCP 同步和导入
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod hermes;
|
||||
mod opencode;
|
||||
mod validation;
|
||||
|
||||
@@ -28,6 +30,7 @@ pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
|
||||
pub use opencode::{
|
||||
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||
};
|
||||
|
||||
@@ -259,6 +259,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: true,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -914,6 +914,7 @@ pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<OpenClawWriteOutc
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
@@ -966,6 +967,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn default_model_write_preserves_top_level_comments() {
|
||||
let source = r#"{
|
||||
// top-level comment
|
||||
@@ -994,6 +996,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn default_model_noop_write_skips_backup() {
|
||||
let source = r#"{
|
||||
models: {
|
||||
@@ -1028,6 +1031,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn save_detects_external_conflict() {
|
||||
let source = r#"{
|
||||
models: {
|
||||
@@ -1050,6 +1054,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn remove_last_provider_writes_empty_providers_without_panic() {
|
||||
let source = r#"{
|
||||
models: {
|
||||
|
||||
@@ -61,7 +61,12 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||
json5::from_str(&content).map_err(|e| {
|
||||
AppError::Config(format!(
|
||||
"Failed to parse OpenCode config: {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||
|
||||
@@ -16,14 +16,14 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Gemini => get_gemini_dir(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode => "AGENTS.md",
|
||||
AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
|
||||
@@ -172,29 +172,6 @@ pub struct ProviderTestConfig {
|
||||
pub max_retries: Option<u32>,
|
||||
}
|
||||
|
||||
/// 供应商单独的代理配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderProxyConfig {
|
||||
/// 是否启用单独配置(false 时使用全局/系统代理)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// 代理类型:http, https, socks5
|
||||
#[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_type: Option<String>,
|
||||
/// 代理主机
|
||||
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_host: Option<String>,
|
||||
/// 代理端口
|
||||
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_port: Option<u16>,
|
||||
/// 代理用户名(可选)
|
||||
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_username: Option<String>,
|
||||
/// 代理密码(可选)
|
||||
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 认证绑定来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -262,9 +239,6 @@ pub struct ProviderMeta {
|
||||
/// 供应商单独的模型测试配置
|
||||
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub test_config: Option<ProviderTestConfig>,
|
||||
/// 供应商单独的代理配置
|
||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_config: Option<ProviderProxyConfig>,
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
@@ -282,9 +256,9 @@ pub struct ProviderMeta {
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
/// Prompt cache key for OpenAI Responses-compatible endpoints.
|
||||
/// When set, injected into converted Responses requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during Claude -> Responses conversion.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//!
|
||||
//! 参考实现: https://github.com/caozhiyuan/copilot-api
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
@@ -21,6 +23,9 @@ pub struct CopilotClassification {
|
||||
pub is_warmup: bool,
|
||||
/// 是否为上下文压缩请求
|
||||
pub is_compact: bool,
|
||||
/// 是否为 Claude Code 子代理请求(Agent tool 生成的 subagent)
|
||||
/// 子代理请求应设置 x-interaction-type=conversation-subagent,不计 premium interaction
|
||||
pub is_subagent: bool,
|
||||
}
|
||||
|
||||
/// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。
|
||||
@@ -38,12 +43,17 @@ pub struct CopilotClassification {
|
||||
///
|
||||
/// `compact_detection`:是否启用 compact 检测。为 false 时跳过,
|
||||
/// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。
|
||||
///
|
||||
/// `subagent_detection`:是否启用子代理检测。为 true 时,会扫描首条用户消息
|
||||
/// 中的 `__SUBAGENT_MARKER__` 标记,将子代理请求标记为不计费。
|
||||
pub fn classify_request(
|
||||
body: &Value,
|
||||
has_anthropic_beta: bool,
|
||||
compact_detection: bool,
|
||||
subagent_detection: bool,
|
||||
) -> CopilotClassification {
|
||||
let is_compact = compact_detection && is_compact_request(body);
|
||||
let is_subagent = subagent_detection && detect_subagent(body);
|
||||
|
||||
let messages = match body.get("messages").and_then(|m| m.as_array()) {
|
||||
Some(msgs) if !msgs.is_empty() => msgs,
|
||||
@@ -52,6 +62,7 @@ pub fn classify_request(
|
||||
initiator: "user",
|
||||
is_warmup: is_warmup_request(body, has_anthropic_beta, false),
|
||||
is_compact: false,
|
||||
is_subagent,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -62,29 +73,33 @@ pub fn classify_request(
|
||||
// 只有 role=user 的消息需要细分
|
||||
if role != "user" {
|
||||
return CopilotClassification {
|
||||
initiator: "user",
|
||||
initiator: if is_subagent { "agent" } else { "user" },
|
||||
is_warmup: false,
|
||||
is_compact,
|
||||
is_subagent,
|
||||
};
|
||||
}
|
||||
|
||||
// 参考实现的判定逻辑(Messages API 路径):
|
||||
// 如果 content 是数组,检查是否有非 tool_result 的 block
|
||||
// 有 → "user",全是 tool_result → "agent"
|
||||
// 如果 content 是字符串 → "user"
|
||||
// 判定逻辑(与 copilot-api 的 merge-then-classify 效果对齐):
|
||||
// 只要 content 数组中包含 tool_result → 视为工具续写 → agent
|
||||
// 这覆盖了 skill/edit hook/plan follow-up 等常见场景,
|
||||
// 它们的 content 通常是 [tool_result, text] 混合形态。
|
||||
// copilot-api 通过先 merge(text 吸收进 tool_result)再 classify 实现同等效果;
|
||||
// 直接在分类层处理更稳健,不依赖 merge 启用状态和执行顺序。
|
||||
let is_user_initiated = match last_msg.get("content") {
|
||||
Some(content) if content.is_array() => {
|
||||
let blocks = content.as_array().unwrap();
|
||||
// 存在非 tool_result block → 用户发起
|
||||
blocks
|
||||
// 含有 tool_result → 工具续写(agent),否则 → 用户发起(user)
|
||||
!blocks
|
||||
.iter()
|
||||
.any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result"))
|
||||
.any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result"))
|
||||
}
|
||||
Some(content) if content.is_string() => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let initiator = if !is_user_initiated || is_compact {
|
||||
// 子代理请求始终标记为 agent(即使首条消息包含用户文本)
|
||||
let initiator = if is_subagent || !is_user_initiated || is_compact {
|
||||
"agent"
|
||||
} else {
|
||||
"user"
|
||||
@@ -94,6 +109,7 @@ pub fn classify_request(
|
||||
initiator,
|
||||
is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact),
|
||||
is_compact,
|
||||
is_subagent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,23 +139,11 @@ fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) -
|
||||
fn is_compact_request(body: &Value) -> bool {
|
||||
// 信号 1: system prompt 以 Claude Code compact 专用前缀开头
|
||||
// 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号
|
||||
if let Some(system) = body.get("system") {
|
||||
let system_text = if let Some(s) = system.as_str() {
|
||||
s.to_string()
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if system_text
|
||||
.starts_with("You are a helpful AI assistant tasked with summarizing conversations")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let system_text = extract_system_text(body);
|
||||
if system_text
|
||||
.starts_with("You are a helpful AI assistant tasked with summarizing conversations")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 信号 2 & 3: 检查最后一条用户消息中的机器生成特征
|
||||
@@ -259,8 +263,9 @@ pub fn merge_tool_results(mut body: Value) -> Value {
|
||||
|
||||
/// 基于最后一条用户消息内容生成确定性 Request ID。
|
||||
///
|
||||
/// 与参考实现对齐:
|
||||
/// CC Switch 额外策略(参考项目 copilot-api 使用随机 UUID):
|
||||
/// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control)
|
||||
/// - 相同内容产生相同 ID,可能帮助 Copilot 去重
|
||||
/// - 找不到用户内容时退化为随机 UUID
|
||||
/// - 使用 UUID v4 格式
|
||||
pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
|
||||
@@ -285,8 +290,211 @@ pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于 session ID 生成稳定的 Interaction ID。
|
||||
///
|
||||
/// 与参考实现(copilot-api session.ts)对齐:
|
||||
/// - 同一主对话的所有请求共享同一个 interaction ID
|
||||
/// - 哈希输入: 仅 session ID(不包含消息内容,与 request ID 不同)
|
||||
/// - Copilot 用此 ID 将请求聚合为同一个 "interaction",影响 premium 计费归属
|
||||
/// - 空 session ID 时返回 None(不应注入随机值,避免 interaction 碎片化)
|
||||
pub fn deterministic_interaction_id(session_id: &str) -> Option<String> {
|
||||
if session_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"interaction:");
|
||||
hasher.update(session_id.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes.copy_from_slice(&result[..16]);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||
|
||||
Some(Uuid::from_bytes(bytes).to_string())
|
||||
}
|
||||
|
||||
/// 检测请求是否来自 Claude Code 子代理(Agent tool 生成的 subagent)。
|
||||
///
|
||||
/// Claude Code 的 Agent tool 会在子代理首条用户消息的 `<system-reminder>` 标签中
|
||||
/// 注入 `__SUBAGENT_MARKER__` JSON 标记,格式如:
|
||||
/// ```json
|
||||
/// {"__SUBAGENT_MARKER__": {"session_id": "...", "agent_id": "...", "agent_type": "..."}}
|
||||
/// ```
|
||||
///
|
||||
/// 扫描策略(与 copilot-api 的 subagent-marker.ts 对齐):
|
||||
/// 1. 遍历所有 user 消息(不仅是第一条,因为 context 压缩可能重排消息)
|
||||
/// 2. 在消息文本中查找 `__SUBAGENT_MARKER__` 关键字
|
||||
/// 3. 找到即判定为子代理请求
|
||||
fn detect_subagent(body: &Value) -> bool {
|
||||
// 信号 1: 显式 __SUBAGENT_MARKER__(Claude Code 2.x+ 自动注入)
|
||||
if extract_system_text(body).contains("__SUBAGENT_MARKER__") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
for msg in messages {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
let text = extract_text_from_message(msg);
|
||||
if text.contains("__SUBAGENT_MARKER__") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 信号 2(fallback): metadata.user_id 包含子代理标识
|
||||
// Claude Code 的 Agent tool 会将 subagent session 标记为
|
||||
// "parentSessionId_agent_agentId" 格式,检测 "_agent_" 后缀
|
||||
if let Some(user_id) = body.pointer("/metadata/user_id").and_then(|v| v.as_str()) {
|
||||
// "_agent_" 是 Claude Code Agent tool 的内部标记
|
||||
if user_id.contains("_agent_") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 信号 3(fallback): system prompt 包含 Claude Code 子代理的典型框架文本
|
||||
// Agent tool 生成的子代理会在 system prompt 中包含由 Agent tool 注入的任务描述,
|
||||
// 但主对话的 system prompt 由 Claude Code CLI 直接生成,两者格式不同
|
||||
// 这个信号不够可靠(用户 prompt 也可能包含这些词),因此只作为辅助判据
|
||||
// 暂不启用,预留接口
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 清理孤立的 tool_result — 没有对应 tool_use 的 tool_result 转为 text block。
|
||||
///
|
||||
/// 场景:上下文压缩、消息截断等可能导致 assistant 消息中的 tool_use 被删除,
|
||||
/// 但后续 user 消息中的 tool_result 仍在。上游 API 可能因不匹配而报错/重试。
|
||||
///
|
||||
/// 与 copilot-api 的 `sanitizeOrphanToolResults` 对齐。
|
||||
pub fn sanitize_orphan_tool_results(mut body: Value) -> Value {
|
||||
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
Some(msgs) if msgs.len() >= 2 => msgs,
|
||||
_ => return body,
|
||||
};
|
||||
|
||||
// Anthropic 协议要求 tool_result 紧跟其对应 tool_use 所在的 assistant turn。
|
||||
// 只检查 messages[i-1](紧邻上一条 assistant)来判定是否 orphan,
|
||||
// 与参考实现 sanitizeOrphanToolResults 对齐。
|
||||
for i in 1..messages.len() {
|
||||
if messages[i].get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 收集紧邻上一条 assistant 的 tool_use id
|
||||
let prev_tool_use_ids: HashSet<String> =
|
||||
if messages[i - 1].get("role").and_then(|r| r.as_str()) == Some("assistant") {
|
||||
messages[i - 1]
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|blocks| {
|
||||
blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||
.filter_map(|b| b.get("id").and_then(|i| i.as_str()).map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
// 上一条不是 assistant → 这条 user 中的所有 tool_result 都是 orphan
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
let content = match messages[i]
|
||||
.get_mut("content")
|
||||
.and_then(|c| c.as_array_mut())
|
||||
{
|
||||
Some(blocks) => blocks,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
for block in content.iter_mut() {
|
||||
if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
|
||||
continue;
|
||||
}
|
||||
let tool_use_id = block
|
||||
.get("tool_use_id")
|
||||
.and_then(|id| id.as_str())
|
||||
.unwrap_or("");
|
||||
// 空 tool_use_id 或不在紧邻 assistant 的 tool_use 中 → orphan
|
||||
if tool_use_id.is_empty() || !prev_tool_use_ids.contains(tool_use_id) {
|
||||
let content_text = match block.get("content") {
|
||||
Some(c) if c.is_string() => c.as_str().unwrap_or("").to_string(),
|
||||
Some(c) if c.is_array() => c
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => String::new(),
|
||||
};
|
||||
*block = serde_json::json!({
|
||||
"type": "text",
|
||||
"text": format!("[Tool result for {}]: {}", tool_use_id, content_text)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
/// 请求前主动剥离所有 assistant 消息里的 thinking / redacted_thinking block
|
||||
///
|
||||
/// Copilot 的三条目标端点(`/chat/completions`、`/v1/responses`、`/v1/chat/completions`)
|
||||
/// 均为 OpenAI 兼容格式,不识别 Anthropic 的 thinking block。若原样转发,上游会
|
||||
/// 拒绝并返回 invalid_request_error —— 届时 `thinking_rectifier` 才做反应式清理并
|
||||
/// 重试。那次已经失败的请求依旧消耗一次 premium quota,所以此处提前剥离。
|
||||
///
|
||||
/// 与 `thinking_rectifier::rectify_anthropic_request` 的区别:
|
||||
/// - 本函数只剥 thinking / redacted_thinking 两类 block,不触碰 signature,也不
|
||||
/// 移除顶层 thinking 字段——那些是错误路径上的激进整流,常规路径不需要。
|
||||
/// - 保持与 `merge_tool_results` / `sanitize_orphan_tool_results` 一致的"消费 body、
|
||||
/// 返回新 body"签名,便于接入 forwarder 管道。
|
||||
pub fn strip_thinking_blocks(mut body: Value) -> Value {
|
||||
let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else {
|
||||
return body;
|
||||
};
|
||||
|
||||
for msg in messages.iter_mut() {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") {
|
||||
continue;
|
||||
}
|
||||
let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) else {
|
||||
continue;
|
||||
};
|
||||
content.retain(|block| {
|
||||
!matches!(
|
||||
block.get("type").and_then(|t| t.as_str()),
|
||||
Some("thinking") | Some("redacted_thinking")
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
// ─── 内部辅助 ─────────────────────────────────
|
||||
|
||||
/// 从请求体的 `system` 字段提取文本(处理 string/array 两种格式)。
|
||||
fn extract_system_text(body: &Value) -> String {
|
||||
match body.get("system") {
|
||||
Some(s) if s.is_string() => s.as_str().unwrap_or("").to_string(),
|
||||
Some(arr) if arr.is_array() => arr
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找最后一条 user 消息的非 tool_result 文本内容。
|
||||
///
|
||||
/// 与参考实现的 `findLastUserContent` 对齐:
|
||||
@@ -433,7 +641,7 @@ mod tests {
|
||||
{"role": "user", "content": "Hello, please help me write some code"}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
@@ -448,7 +656,7 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
@@ -468,15 +676,16 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, true, true);
|
||||
let result = classify_request(&body, true, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_tool_result_with_text_block() {
|
||||
// 参考实现的关键场景:tool_result + text block
|
||||
// 有非 tool_result block → 仍然是 "user"
|
||||
// tool_result + text block(skill/edit hook/plan follow-up 的常见形态)
|
||||
// 含有 tool_result → 视为工具续写 → agent
|
||||
// 与 copilot-api 的 merge-then-classify 效果对齐
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
@@ -486,8 +695,8 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -496,14 +705,14 @@ mod tests {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": []
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_no_messages() {
|
||||
let body = json!({"model": "claude-sonnet-4-20250514"});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
@@ -517,7 +726,7 @@ mod tests {
|
||||
{"role": "user", "content": "Here is the conversation history to summarize..."}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_compact);
|
||||
}
|
||||
@@ -533,7 +742,7 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_compact);
|
||||
}
|
||||
@@ -548,7 +757,7 @@ mod tests {
|
||||
{"role": "user", "content": "Summarize"}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, false); // compact_detection=false
|
||||
let result = classify_request(&body, false, false, false); // compact_detection=false
|
||||
assert_eq!(result.initiator, "user"); // 不被标记为 agent
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
@@ -562,7 +771,7 @@ mod tests {
|
||||
{"role": "user", "content": "Please summarize the conversation so far into a concise summary."}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
// 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_compact);
|
||||
@@ -579,7 +788,7 @@ mod tests {
|
||||
]
|
||||
});
|
||||
// has_anthropic_beta=true, 无 tools → warmup
|
||||
let result = classify_request(&body, true, true);
|
||||
let result = classify_request(&body, true, true, false);
|
||||
assert!(result.is_warmup);
|
||||
}
|
||||
|
||||
@@ -592,7 +801,7 @@ mod tests {
|
||||
]
|
||||
});
|
||||
// has_anthropic_beta=false → 不是 warmup
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
@@ -606,7 +815,7 @@ mod tests {
|
||||
]
|
||||
});
|
||||
// 有 tools → 不是 warmup(即使有 anthropic-beta)
|
||||
let result = classify_request(&body, true, true);
|
||||
let result = classify_request(&body, true, true, false);
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
@@ -621,7 +830,7 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, true, true);
|
||||
let result = classify_request(&body, true, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
@@ -831,6 +1040,44 @@ mod tests {
|
||||
assert!(Uuid::parse_str(&id).is_ok());
|
||||
}
|
||||
|
||||
// === deterministic_interaction_id 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_stable_for_same_session() {
|
||||
let id1 = deterministic_interaction_id("session_abc");
|
||||
let id2 = deterministic_interaction_id("session_abc");
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_differs_across_sessions() {
|
||||
let id1 = deterministic_interaction_id("session_abc");
|
||||
let id2 = deterministic_interaction_id("session_def");
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_differs_from_request_id() {
|
||||
let body = json!({
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let interaction = deterministic_interaction_id("session_abc").unwrap();
|
||||
let request = deterministic_request_id(&body, "session_abc");
|
||||
assert_ne!(interaction, request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_empty_session_is_none() {
|
||||
// 无 session 时不应生成 interaction ID(避免碎片化)
|
||||
assert!(deterministic_interaction_id("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_is_valid_uuid() {
|
||||
let id = deterministic_interaction_id("test_session").unwrap();
|
||||
assert!(Uuid::parse_str(&id).is_ok());
|
||||
}
|
||||
|
||||
// === compact 检测增强测试 ===
|
||||
|
||||
#[test]
|
||||
@@ -898,4 +1145,399 @@ mod tests {
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
|
||||
// === detect_subagent 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_with_marker_in_user_message() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc123\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nPlease search the codebase for auth handlers"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
assert!(detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_with_marker_in_system() {
|
||||
let body = json!({
|
||||
"system": "You are an agent. {\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"plan-1\",\"agent_type\":\"Plan\"}}",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Design the implementation plan"}
|
||||
]
|
||||
});
|
||||
assert!(detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_no_marker() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, please help me write code"}
|
||||
]
|
||||
});
|
||||
assert!(!detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_via_metadata_user_id() {
|
||||
// fallback 信号: metadata.user_id 包含 "_agent_" 标记
|
||||
let body = json!({
|
||||
"metadata": {
|
||||
"user_id": "session_abc123_agent_explore-1"
|
||||
},
|
||||
"messages": [
|
||||
{"role": "user", "content": "Search for files"}
|
||||
]
|
||||
});
|
||||
assert!(detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_normal_user_id_not_matched() {
|
||||
// 普通 session ID 不应被误判
|
||||
let body = json!({
|
||||
"metadata": {
|
||||
"user_id": "session_abc123"
|
||||
},
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
assert!(!detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_subagent_sets_agent_initiator() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nSearch for files"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_subagent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_subagent_disabled_flag() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nSearch for files"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
// subagent_detection=false → 不检测子代理
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_subagent);
|
||||
}
|
||||
|
||||
// === sanitize_orphan_tool_results 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_tool_results_converts_orphans() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Help me"},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "tool_1", "content": "file contents"},
|
||||
{"type": "tool_result", "tool_use_id": "tool_orphan", "content": "orphan data"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body);
|
||||
let msgs = result["messages"].as_array().unwrap();
|
||||
let last_content = msgs[2]["content"].as_array().unwrap();
|
||||
// tool_1 保留为 tool_result
|
||||
assert_eq!(last_content[0]["type"], "tool_result");
|
||||
// tool_orphan 转为 text
|
||||
assert_eq!(last_content[1]["type"], "text");
|
||||
assert!(last_content[1]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("tool_orphan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_tool_results_no_orphans() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "tool_1", "content": "ok"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body.clone());
|
||||
// 无孤立 tool_result,不应有变化
|
||||
assert_eq!(result["messages"][1]["content"][0]["type"], "tool_result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_non_adjacent_assistant_tool_use_is_orphan() {
|
||||
// tool_use 在更早的 assistant 中,但 tool_result 的紧邻上一条是另一个 assistant
|
||||
// → 对 Anthropic 协议来说这个 tool_result 是 orphan
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "step 1"},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "old_tool", "name": "search", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "old_tool", "content": "found it"}
|
||||
]},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "OK, now let me think..."}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "old_tool", "content": "stale ref"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body);
|
||||
let msgs = result["messages"].as_array().unwrap();
|
||||
// messages[2]: 紧邻 assistant 有 old_tool → 保留
|
||||
assert_eq!(msgs[2]["content"][0]["type"], "tool_result");
|
||||
// messages[4]: 紧邻 assistant 无 tool_use → orphan → text
|
||||
assert_eq!(msgs[4]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_prev_not_assistant() {
|
||||
// tool_result 紧邻上一条是 user(非 assistant)→ 全部 orphan
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "data"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body);
|
||||
assert_eq!(result["messages"][1]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
/// 关键场景:orphan tool_result(上下文压缩丢失了紧邻 tool_use)
|
||||
/// 在分类时仍应被视为 agent continuation,不能因为后续的 sanitize
|
||||
/// 将其转为 text 而变成 user 请求。
|
||||
///
|
||||
/// 这个测试验证 classify_request 在原始(未 sanitize)的 body 上
|
||||
/// 正确识别 orphan tool_result 为 agent。
|
||||
#[test]
|
||||
fn test_orphan_tool_result_classified_as_agent_before_sanitize() {
|
||||
// 场景:最后一条 user 消息全是 tool_result,但紧邻的 assistant
|
||||
// 消息里没有对应的 tool_use(因上下文压缩丢失了)
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "I'll help you with that."},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "orphan_tool_1", "content": "file contents here"},
|
||||
{"type": "tool_result", "tool_use_id": "orphan_tool_2", "content": "another result"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
// 在原始 body 上分类 → 全是 tool_result → agent
|
||||
let classification = classify_request(&body, false, false, false);
|
||||
assert_eq!(classification.initiator, "agent");
|
||||
|
||||
// sanitize 后 → tool_result 变为 text → 如果再分类就会变成 user
|
||||
let sanitized = sanitize_orphan_tool_results(body);
|
||||
let classification_after = classify_request(&sanitized, false, false, false);
|
||||
assert_eq!(
|
||||
classification_after.initiator, "user",
|
||||
"sanitize 后 orphan tool_result 变为 text,分类变成 user — \
|
||||
这就是为什么分类必须在 sanitize 之前执行"
|
||||
);
|
||||
}
|
||||
|
||||
/// orphan tool_result + text 混合场景:
|
||||
/// 分类器直接识别含 tool_result 的消息为 agent(无论是否有 text block),
|
||||
/// 不依赖 merge 的执行顺序。即使 orphan tool_result 后续被 sanitize 转为 text,
|
||||
/// 分类结果在此之前已经确定为 agent。
|
||||
#[test]
|
||||
fn test_orphan_tool_result_with_text_classified_as_agent() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "Processing..."},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "orphan_1", "content": "result data"},
|
||||
{"type": "text", "text": "Here's the output from the tool"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
// 含有 tool_result → agent(无论是否有 text block)
|
||||
let classification = classify_request(&body, false, false, false);
|
||||
assert_eq!(classification.initiator, "agent");
|
||||
|
||||
// sanitize 后 orphan tool_result 变为 text → 纯 text → 分类会变成 user
|
||||
// 但正确的执行顺序是先分类再 sanitize,所以这不是问题
|
||||
let sanitized = sanitize_orphan_tool_results(body);
|
||||
let classification_after = classify_request(&sanitized, false, false, false);
|
||||
assert_eq!(classification_after.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_empty_tool_use_id_is_orphan() {
|
||||
// tool_use_id 为空或缺失 → 无法匹配任何 tool_use → orphan
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "tool_1", "name": "read", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "", "content": "empty id"},
|
||||
{"type": "tool_result", "content": "missing id field"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body);
|
||||
let content = result["messages"][1]["content"].as_array().unwrap();
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert_eq!(content[1]["type"], "text");
|
||||
}
|
||||
|
||||
// === strip_thinking_blocks 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_strip_thinking_removes_assistant_thinking_blocks() {
|
||||
let body = serde_json::json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "thinking", "thinking": "let me ponder", "signature": "sig"},
|
||||
{"type": "redacted_thinking", "data": "opaque"},
|
||||
{"type": "text", "text": "hello"},
|
||||
{"type": "tool_use", "id": "t1", "name": "read", "input": {}}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = strip_thinking_blocks(body);
|
||||
let content = result["messages"][1]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 2);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert_eq!(content[1]["type"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_thinking_leaves_user_messages_untouched() {
|
||||
// 仅处理 assistant,user 的 thinking 块(极少见,但可能)不动
|
||||
let body = serde_json::json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "thinking", "thinking": "x"},
|
||||
{"type": "text", "text": "hi"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = strip_thinking_blocks(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_thinking_handles_missing_messages() {
|
||||
let body = serde_json::json!({ "model": "claude-3-5-sonnet" });
|
||||
let result = strip_thinking_blocks(body.clone());
|
||||
assert_eq!(result, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_thinking_leaves_empty_content_array() {
|
||||
// 仅含 thinking 的 assistant 消息剥完后 content 为空——保留上游自处理
|
||||
let body = serde_json::json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "thinking", "thinking": "solo"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = strip_thinking_blocks(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_thinking_preserves_signature_on_non_thinking_blocks() {
|
||||
// signature 留给 thinking_rectifier 在错误路径处理,此处不动
|
||||
let body = serde_json::json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "t1", "name": "x", "input": {}, "signature": "s"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = strip_thinking_blocks(body);
|
||||
let block = &result["messages"][0]["content"][0];
|
||||
assert_eq!(block["signature"], "s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_thinking_multiple_assistant_turns() {
|
||||
let body = serde_json::json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "q1"}]},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "thinking", "thinking": "a"},
|
||||
{"type": "text", "text": "r1"}
|
||||
]},
|
||||
{"role": "user", "content": [{"type": "text", "text": "q2"}]},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "redacted_thinking", "data": "x"},
|
||||
{"type": "text", "text": "r2"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = strip_thinking_blocks(body);
|
||||
let a1 = result["messages"][1]["content"].as_array().unwrap();
|
||||
let a2 = result["messages"][3]["content"].as_array().unwrap();
|
||||
assert_eq!(a1.len(), 1);
|
||||
assert_eq!(a1[0]["text"], "r1");
|
||||
assert_eq!(a2.len(), 1);
|
||||
assert_eq!(a2[0]["text"], "r2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_thinking_ignores_string_content() {
|
||||
// assistant.content 是字符串而非 block 数组 — 历史请求或极简客户端会这样
|
||||
// 不应崩溃,也不应转换结构
|
||||
let body = serde_json::json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "plain text response"}
|
||||
]
|
||||
});
|
||||
let result = strip_thinking_blocks(body.clone());
|
||||
assert_eq!(result, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_thinking_preserves_block_order() {
|
||||
let body = serde_json::json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "thinking", "thinking": "pre"},
|
||||
{"type": "text", "text": "A"},
|
||||
{"type": "tool_use", "id": "t1", "name": "x", "input": {}},
|
||||
{"type": "redacted_thinking", "data": "mid"},
|
||||
{"type": "text", "text": "B"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = strip_thinking_blocks(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 3);
|
||||
assert_eq!(content[0]["text"], "A");
|
||||
assert_eq!(content[1]["type"], "tool_use");
|
||||
assert_eq!(content[2]["text"], "B");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ impl FailoverSwitchManager {
|
||||
}
|
||||
|
||||
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
log::error!("[Failover] 更新托盘菜单失败: {e}");
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ use super::{
|
||||
failover_switch::FailoverSwitchManager,
|
||||
log_codes::fwd as log_fwd,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, ProviderType},
|
||||
providers::{
|
||||
gemini_shadow::GeminiShadowStore, get_adapter, AuthInfo, AuthStrategy, ProviderAdapter,
|
||||
ProviderType,
|
||||
},
|
||||
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
@@ -43,12 +46,15 @@ pub struct RequestForwarder {
|
||||
router: Arc<ProviderRouter>,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
gemini_shadow: Arc<GeminiShadowStore>,
|
||||
/// 故障转移切换管理器
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
/// AppHandle,用于发射事件和更新托盘
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
/// 代理会话 ID(用于 Gemini Native shadow replay)
|
||||
session_id: String,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
@@ -66,9 +72,11 @@ impl RequestForwarder {
|
||||
non_streaming_timeout: u64,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
gemini_shadow: Arc<GeminiShadowStore>,
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
current_provider_id_at_start: String,
|
||||
session_id: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
@@ -79,9 +87,11 @@ impl RequestForwarder {
|
||||
router,
|
||||
status,
|
||||
current_providers,
|
||||
gemini_shadow,
|
||||
failover_manager,
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
@@ -776,32 +786,48 @@ impl RequestForwarder {
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
// --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) ---
|
||||
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
|
||||
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
|
||||
//
|
||||
// 执行顺序(与 copilot-api 对齐):
|
||||
// 1. 先在原始 body 上分类(保留 tool_result 语义,避免误判为 user)
|
||||
// 2. 再清洗孤立 tool_result(防止上游 API 报错)
|
||||
// 3. 再合并 tool_result + text(减少 premium 计费)
|
||||
let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled {
|
||||
// 1. Tool result 合并 — 必须在分类之前执行
|
||||
// 合并将 [tool_result, text] 变为 [tool_result(含text)],
|
||||
// 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block)
|
||||
if self.copilot_optimizer_config.tool_result_merging {
|
||||
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
|
||||
}
|
||||
|
||||
// 2. 在合并后的 body 上进行分类
|
||||
// 1. 在原始 body 上分类 — 必须在清洗/合并之前执行
|
||||
// 孤立 tool_result 仍保持 tool_result 类型,分类能正确识别为 agent
|
||||
let has_anthropic_beta = headers.contains_key("anthropic-beta");
|
||||
let classification = super::copilot_optimizer::classify_request(
|
||||
&mapped_body,
|
||||
has_anthropic_beta,
|
||||
self.copilot_optimizer_config.compact_detection,
|
||||
self.copilot_optimizer_config.subagent_detection,
|
||||
);
|
||||
|
||||
log::debug!(
|
||||
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}",
|
||||
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}",
|
||||
classification.initiator,
|
||||
classification.is_warmup,
|
||||
classification.is_compact
|
||||
classification.is_compact,
|
||||
classification.is_subagent
|
||||
);
|
||||
|
||||
// 3. Warmup 小模型降级
|
||||
// 2. 孤立 tool_result 清理 — 分类完成后再清洗
|
||||
// 防止上游 API 因不匹配的 tool_result 报错导致重试/重复计费
|
||||
mapped_body = super::copilot_optimizer::sanitize_orphan_tool_results(mapped_body);
|
||||
|
||||
// 3. Tool result 合并 — 将 [tool_result, text] 变为 [tool_result(含text)]
|
||||
if self.copilot_optimizer_config.tool_result_merging {
|
||||
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
|
||||
}
|
||||
|
||||
// 3.5. 主动剥离 thinking block — Copilot 走 OpenAI 兼容端点不识别该块
|
||||
// 避免上游拒绝后由 rectifier 反应式重试(首次请求已消耗 quota)
|
||||
if self.copilot_optimizer_config.strip_thinking {
|
||||
mapped_body = super::copilot_optimizer::strip_thinking_blocks(mapped_body);
|
||||
}
|
||||
|
||||
// 4. Warmup 小模型降级
|
||||
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
|
||||
log::info!(
|
||||
"[Copilot] Warmup 请求降级到模型: {}",
|
||||
@@ -812,22 +838,52 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
// 预计算确定性 Request ID(在 body 被 move 之前)
|
||||
// 使用 session_id 从 body.metadata.user_id 或请求头提取
|
||||
let session_id = body
|
||||
.pointer("/metadata/user_id")
|
||||
// Session 提取优先级(与 session.rs extract_from_metadata 对齐):
|
||||
// 1. metadata.user_id 中的 _session_ 后缀
|
||||
// 2. metadata.session_id(直接字段)
|
||||
// 3. raw metadata.user_id(整串 fallback)
|
||||
// 4. x-session-id header
|
||||
let metadata = body.get("metadata");
|
||||
let session_id = metadata
|
||||
.and_then(|m| m.get("user_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok()))
|
||||
.unwrap_or("");
|
||||
.and_then(super::session::parse_session_from_user_id)
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.and_then(|m| m.get("user_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-session-id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let det_request_id = if self.copilot_optimizer_config.deterministic_request_id {
|
||||
Some(super::copilot_optimizer::deterministic_request_id(
|
||||
&mapped_body,
|
||||
session_id,
|
||||
&session_id,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some((classification, det_request_id))
|
||||
// 从 session ID 派生稳定的 interaction ID(同一主对话共享)
|
||||
let interaction_id =
|
||||
super::copilot_optimizer::deterministic_interaction_id(&session_id);
|
||||
|
||||
Some((classification, det_request_id, interaction_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -878,7 +934,7 @@ impl RequestForwarder {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
|
||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body)
|
||||
} else {
|
||||
(
|
||||
endpoint.to_string(),
|
||||
@@ -888,7 +944,13 @@ impl RequestForwarder {
|
||||
)
|
||||
};
|
||||
|
||||
let url = if is_full_url {
|
||||
let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) {
|
||||
super::gemini_url::resolve_gemini_native_url(
|
||||
&base_url,
|
||||
&effective_endpoint,
|
||||
is_full_url,
|
||||
)
|
||||
} else if is_full_url {
|
||||
append_query_to_full_url(&base_url, passthrough_query.as_deref())
|
||||
} else {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
@@ -904,6 +966,8 @@ impl RequestForwarder {
|
||||
mapped_body,
|
||||
provider,
|
||||
api_format,
|
||||
Some(&self.session_id),
|
||||
Some(self.gemini_shadow.as_ref()),
|
||||
)?
|
||||
} else {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
@@ -1039,12 +1103,18 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id)) = copilot_optimization {
|
||||
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
|
||||
copilot_optimization
|
||||
{
|
||||
for (name, value) in auth_headers.iter_mut() {
|
||||
match name.as_str() {
|
||||
"x-initiator" if self.copilot_optimizer_config.request_classification => {
|
||||
*value = http::HeaderValue::from_static(classification.initiator);
|
||||
}
|
||||
"x-interaction-type" if classification.is_subagent => {
|
||||
// 子代理请求:conversation-subagent 不计 premium interaction
|
||||
*value = http::HeaderValue::from_static("conversation-subagent");
|
||||
}
|
||||
"x-request-id" | "x-agent-task-id" => {
|
||||
if let Some(ref det_id) = det_request_id {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(det_id) {
|
||||
@@ -1055,6 +1125,19 @@ impl RequestForwarder {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// x-interaction-id:仅在有 session 时注入(不在 get_auth_headers 中)
|
||||
if let Some(ref iid) = interaction_id {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(iid) {
|
||||
auth_headers.push((http::HeaderName::from_static("x-interaction-id"), hv));
|
||||
}
|
||||
}
|
||||
|
||||
if classification.is_subagent {
|
||||
log::info!(
|
||||
"[Copilot] 子代理请求: x-initiator=agent, x-interaction-type=conversation-subagent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
|
||||
@@ -1069,6 +1152,7 @@ impl RequestForwarder {
|
||||
// 新增 headers
|
||||
"x-initiator",
|
||||
"x-interaction-type",
|
||||
"x-interaction-id",
|
||||
"x-vscode-user-agent-library-version",
|
||||
"x-request-id",
|
||||
"x-agent-task-id",
|
||||
@@ -1083,8 +1167,11 @@ impl RequestForwarder {
|
||||
.ok()
|
||||
.and_then(|u| u.authority().map(|a| a.to_string()));
|
||||
|
||||
let should_send_anthropic_headers = adapter.name() == "Claude"
|
||||
&& matches!(resolved_claude_api_format.as_deref(), Some("anthropic"));
|
||||
|
||||
// 预计算 anthropic-beta 值(仅 Claude)
|
||||
let anthropic_beta_value = if adapter.name() == "Claude" {
|
||||
let anthropic_beta_value = if should_send_anthropic_headers {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
Some(if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
@@ -1204,8 +1291,10 @@ impl RequestForwarder {
|
||||
|
||||
// --- anthropic-version — 透传客户端值 ---
|
||||
if key_str.eq_ignore_ascii_case("anthropic-version") {
|
||||
saw_anthropic_version = true;
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
if should_send_anthropic_headers {
|
||||
saw_anthropic_version = true;
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1246,7 +1335,7 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
// anthropic-version:仅在缺失时补充默认值
|
||||
if adapter.name() == "Claude" && !saw_anthropic_version {
|
||||
if should_send_anthropic_headers && !saw_anthropic_version {
|
||||
ordered_headers.append(
|
||||
"anthropic-version",
|
||||
http::HeaderValue::from_static("2023-06-01"),
|
||||
@@ -1287,12 +1376,8 @@ impl RequestForwarder {
|
||||
self.non_streaming_timeout
|
||||
};
|
||||
|
||||
// 解析上游代理 URL(供应商单独代理 > 全局代理 > 无)
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let upstream_proxy_url: Option<String> = proxy_config
|
||||
.filter(|c| c.enabled)
|
||||
.and_then(super::http_client::build_proxy_url_from_config)
|
||||
.or_else(super::http_client::get_current_proxy_url);
|
||||
// 获取全局代理 URL
|
||||
let upstream_proxy_url: Option<String> = super::http_client::get_current_proxy_url();
|
||||
|
||||
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
|
||||
let is_socks_proxy = upstream_proxy_url
|
||||
@@ -1308,7 +1393,7 @@ impl RequestForwarder {
|
||||
let response = if is_socks_proxy {
|
||||
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
|
||||
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
|
||||
let client = super::http_client::get_for_provider(proxy_config);
|
||||
let client = super::http_client::get();
|
||||
let mut request = client.post(&url);
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
@@ -1594,6 +1679,7 @@ fn rewrite_claude_transform_endpoint(
|
||||
endpoint: &str,
|
||||
api_format: &str,
|
||||
is_copilot: bool,
|
||||
body: &Value,
|
||||
) -> (String, Option<String>) {
|
||||
let (path, query) = split_endpoint_and_query(endpoint);
|
||||
let passthrough_query = if is_claude_messages_path(path) {
|
||||
@@ -1606,6 +1692,36 @@ fn rewrite_claude_transform_endpoint(
|
||||
return (endpoint.to_string(), passthrough_query);
|
||||
}
|
||||
|
||||
if api_format == "gemini_native" {
|
||||
let model =
|
||||
super::providers::transform_gemini::extract_gemini_model(body).unwrap_or("unknown");
|
||||
// Accept both bare ids (`gemini-2.5-pro`) and the resource-name
|
||||
// form (`models/gemini-2.5-pro`) that Gemini SDKs emit. See
|
||||
// `normalize_gemini_model_id` for rationale.
|
||||
let model = super::gemini_url::normalize_gemini_model_id(model);
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
let target_path = if is_stream {
|
||||
format!("/v1beta/models/{model}:streamGenerateContent")
|
||||
} else {
|
||||
format!("/v1beta/models/{model}:generateContent")
|
||||
};
|
||||
|
||||
let rewritten_query = merge_query_params(
|
||||
passthrough_query.as_deref(),
|
||||
if is_stream { Some("alt=sse") } else { None },
|
||||
);
|
||||
|
||||
let rewritten = match rewritten_query.as_deref() {
|
||||
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
|
||||
_ => target_path,
|
||||
};
|
||||
|
||||
return (rewritten, rewritten_query);
|
||||
}
|
||||
|
||||
let target_path = if is_copilot && api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else if is_copilot {
|
||||
@@ -1624,6 +1740,26 @@ fn rewrite_claude_transform_endpoint(
|
||||
(rewritten, passthrough_query)
|
||||
}
|
||||
|
||||
fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option<String> {
|
||||
let mut params: Vec<String> = base_query
|
||||
.into_iter()
|
||||
.flat_map(|query| query.split('&'))
|
||||
.filter(|pair| !pair.is_empty())
|
||||
.filter(|pair| !pair.starts_with("alt="))
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
if let Some(extra_param) = extra_param {
|
||||
params.push(extra_param.to_string());
|
||||
}
|
||||
|
||||
if params.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(params.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(query) if !query.is_empty() => {
|
||||
@@ -1752,6 +1888,7 @@ mod tests {
|
||||
"/v1/messages?beta=true&foo=bar",
|
||||
"openai_chat",
|
||||
false,
|
||||
&json!({ "model": "gpt-5.4" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/chat/completions?foo=bar");
|
||||
@@ -1764,6 +1901,7 @@ mod tests {
|
||||
"/claude/v1/messages?beta=true&x-id=1",
|
||||
"openai_responses",
|
||||
false,
|
||||
&json!({ "model": "gpt-5.4" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||
@@ -1772,8 +1910,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_uses_copilot_path() {
|
||||
let (endpoint, passthrough_query) =
|
||||
rewrite_claude_transform_endpoint("/v1/messages?beta=true&x-id=1", "anthropic", true);
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&x-id=1",
|
||||
"anthropic",
|
||||
true,
|
||||
&json!({ "model": "claude-sonnet-4-6" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/chat/completions?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
@@ -1785,12 +1927,60 @@ mod tests {
|
||||
"/v1/messages?beta=true&x-id=1",
|
||||
"openai_responses",
|
||||
true,
|
||||
&json!({ "model": "gpt-5.4" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_maps_gemini_generate_content() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&x-id=1",
|
||||
"gemini_native",
|
||||
false,
|
||||
&json!({ "model": "gemini-2.5-pro" }),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent?x-id=1"
|
||||
);
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
/// Regression: body.model arriving as the resource-name form
|
||||
/// `models/gemini-2.5-pro` must not produce a doubled
|
||||
/// `/v1beta/models/models/...` path.
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_strips_gemini_model_resource_prefix() {
|
||||
let (endpoint, _) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages",
|
||||
"gemini_native",
|
||||
false,
|
||||
&json!({ "model": "models/gemini-2.5-pro" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1beta/models/gemini-2.5-pro:generateContent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_maps_gemini_streaming() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true",
|
||||
"gemini_native",
|
||||
false,
|
||||
&json!({ "model": "gemini-2.5-flash", "stream": true }),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
assert_eq!(passthrough_query.as_deref(), Some("alt=sse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_to_full_url_preserves_existing_query_string() {
|
||||
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
|
||||
@@ -1798,6 +1988,43 @@ mod tests {
|
||||
assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_gemini_native_url_uses_origin_when_base_ends_with_v1beta() {
|
||||
let url = crate::proxy::gemini_url::build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_gemini_native_url_uses_origin_when_base_already_contains_models_prefix() {
|
||||
let url = crate::proxy::gemini_url::build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_gemini_native_url_keeps_opaque_full_url_as_is() {
|
||||
let url = crate::proxy::gemini_url::resolve_gemini_native_url(
|
||||
"https://relay.example/custom/generate-content",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_identity_for_stream_flag_requests() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
//! Gemini Native URL helpers.
|
||||
//!
|
||||
//! Normalizes legacy Gemini/OpenAI-compatible base URLs into the canonical
|
||||
//! Gemini Native `models/*:generateContent` endpoints.
|
||||
|
||||
/// Normalize a Gemini model identifier to its bare form, stripping an
|
||||
/// optional leading `models/` (and any leading `/`) so that the value can
|
||||
/// be safely interpolated into a URL template like
|
||||
/// `/v1beta/models/{model}:generateContent`.
|
||||
///
|
||||
/// Gemini SDKs and documentation commonly surface model ids as
|
||||
/// `models/gemini-2.5-pro` (the resource-name form). Passing that value
|
||||
/// through to the format string would otherwise yield a doubled prefix
|
||||
/// like `/v1beta/models/models/gemini-2.5-pro:generateContent`, which is
|
||||
/// rejected by the upstream API and turns any health check or live
|
||||
/// request into a false negative.
|
||||
pub fn normalize_gemini_model_id(model: &str) -> &str {
|
||||
let trimmed = model.strip_prefix('/').unwrap_or(model);
|
||||
trimmed.strip_prefix("models/").unwrap_or(trimmed)
|
||||
}
|
||||
|
||||
pub fn resolve_gemini_native_url(base_url: &str, endpoint: &str, is_full_url: bool) -> String {
|
||||
if !is_full_url || should_normalize_gemini_full_url(base_url) {
|
||||
return build_gemini_native_url(base_url, endpoint);
|
||||
}
|
||||
|
||||
let base_url = base_url
|
||||
.split_once('#')
|
||||
.map_or(base_url, |(base, _)| base)
|
||||
.trim_end_matches('/');
|
||||
let (base_without_query, base_query) = split_query(base_url);
|
||||
let (_, endpoint_query) = split_query(endpoint);
|
||||
|
||||
let mut url = base_without_query.to_string();
|
||||
if let Some(query) = merge_queries(base_query, endpoint_query) {
|
||||
url.push('?');
|
||||
url.push_str(&query);
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
pub fn build_gemini_native_url(base_url: &str, endpoint: &str) -> String {
|
||||
let base_url = base_url
|
||||
.split_once('#')
|
||||
.map_or(base_url, |(base, _)| base)
|
||||
.trim_end_matches('/');
|
||||
let (base_without_query, base_query) = split_query(base_url);
|
||||
let (endpoint_without_query, endpoint_query) = split_query(endpoint);
|
||||
|
||||
let endpoint_path = format!("/{}", endpoint_without_query.trim_start_matches('/'));
|
||||
let (origin, raw_path) = split_origin_and_path(base_without_query);
|
||||
let prefix_path = normalize_gemini_base_path(raw_path);
|
||||
|
||||
let mut url = if prefix_path.is_empty() {
|
||||
format!("{origin}{endpoint_path}")
|
||||
} else {
|
||||
format!("{origin}{prefix_path}{endpoint_path}")
|
||||
};
|
||||
|
||||
if let Some(query) = merge_queries(base_query, endpoint_query) {
|
||||
url.push('?');
|
||||
url.push_str(&query);
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn should_normalize_gemini_full_url(base_url: &str) -> bool {
|
||||
let base_url = base_url
|
||||
.split_once('#')
|
||||
.map_or(base_url, |(base, _)| base)
|
||||
.trim_end_matches('/');
|
||||
let (base_without_query, _) = split_query(base_url);
|
||||
let (origin, path) = split_origin_and_path(base_without_query);
|
||||
|
||||
if path.is_empty() || path == "/" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let path = path.trim_end_matches('/');
|
||||
let on_google_host = is_google_gemini_host(extract_host(origin));
|
||||
|
||||
// Unconditional layer: only paths whose grammar is *intrinsically*
|
||||
// Gemini-specific — the `/models/...:generateContent` method-call
|
||||
// shape and the deep OpenAI-compat endpoints (`/openai/chat/completions`,
|
||||
// `/openai/responses`) that are implausibly used as a relay's fixed
|
||||
// terminal path — get rewritten regardless of host.
|
||||
if matches_structured_gemini_models_path(path)
|
||||
|| path.ends_with("/v1beta/openai/chat/completions")
|
||||
|| path.ends_with("/v1beta/openai/responses")
|
||||
|| path.ends_with("/openai/chat/completions")
|
||||
|| path.ends_with("/openai/responses")
|
||||
|| path.ends_with("/v1/openai/chat/completions")
|
||||
|| path.ends_with("/v1/openai/responses")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// All other version / resource-root suffixes — `/v1beta`, `/v1`,
|
||||
// `/models`, `/openai`, and variants — could legitimately be an
|
||||
// opaque relay's fixed endpoint (`https://relay.example/custom/v1beta`
|
||||
// is a real deployment shape, even if uncommon outside Google's
|
||||
// ecosystem). Only rewrite when the host itself is Google's Gemini
|
||||
// or Vertex AI endpoint.
|
||||
if on_google_host
|
||||
&& (path.ends_with("/v1beta")
|
||||
|| path.ends_with("/v1beta/models")
|
||||
|| path.ends_with("/v1beta/openai")
|
||||
|| path.ends_with("/v1")
|
||||
|| path.ends_with("/v1/models")
|
||||
|| path.ends_with("/models")
|
||||
|| path.ends_with("/v1/openai")
|
||||
|| path.ends_with("/openai"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract the host portion of an origin like `https://host:port` or
|
||||
/// `https://host`. Returns an empty string if no host can be found (e.g.
|
||||
/// bare `http://`).
|
||||
fn extract_host(origin: &str) -> &str {
|
||||
let after_scheme = origin.split_once("://").map_or(origin, |(_, rest)| rest);
|
||||
// authority may carry credentials (`user:pass@host`) and a port
|
||||
// (`host:port`). Strip userinfo first, then port.
|
||||
let without_userinfo = after_scheme
|
||||
.rsplit_once('@')
|
||||
.map_or(after_scheme, |(_, h)| h);
|
||||
let without_port = without_userinfo
|
||||
.split_once(':')
|
||||
.map_or(without_userinfo, |(h, _)| h);
|
||||
// Strip trailing `/` defensively (split_origin_and_path already handled
|
||||
// it, but this helper may be reused elsewhere).
|
||||
without_port.trim_end_matches('/')
|
||||
}
|
||||
|
||||
/// Returns true when `host` is one of Google's Gemini / Vertex AI endpoints.
|
||||
/// Case-insensitive. Requires exact match or a real `-aiplatform.googleapis.com`
|
||||
/// subdomain suffix — not a substring match, so lookalikes like
|
||||
/// `aiplatform.example.com` are rejected.
|
||||
fn is_google_gemini_host(host: &str) -> bool {
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
host_lower == "generativelanguage.googleapis.com"
|
||||
|| host_lower == "aiplatform.googleapis.com"
|
||||
|| host_lower.ends_with("-aiplatform.googleapis.com")
|
||||
}
|
||||
|
||||
fn split_query(input: &str) -> (&str, Option<&str>) {
|
||||
input
|
||||
.split_once('?')
|
||||
.map_or((input, None), |(path, query)| (path, Some(query)))
|
||||
}
|
||||
|
||||
fn split_origin_and_path(base_url: &str) -> (&str, &str) {
|
||||
let Some(scheme_sep) = base_url.find("://") else {
|
||||
return (base_url, "");
|
||||
};
|
||||
let authority_start = scheme_sep + 3;
|
||||
let Some(path_start_rel) = base_url[authority_start..].find('/') else {
|
||||
return (base_url, "");
|
||||
};
|
||||
let path_start = authority_start + path_start_rel;
|
||||
(&base_url[..path_start], &base_url[path_start..])
|
||||
}
|
||||
|
||||
fn normalize_gemini_base_path(path: &str) -> String {
|
||||
let path = path.trim_end_matches('/');
|
||||
if path.is_empty() || path == "/" {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
for marker in ["/v1beta/models/", "/v1/models/", "/models/"] {
|
||||
if let Some(index) = path.find(marker) {
|
||||
return normalize_prefix(&path[..index]);
|
||||
}
|
||||
}
|
||||
|
||||
for suffix in [
|
||||
"/v1beta/openai/chat/completions",
|
||||
"/v1/openai/chat/completions",
|
||||
"/openai/chat/completions",
|
||||
"/v1beta/openai/responses",
|
||||
"/v1/openai/responses",
|
||||
"/openai/responses",
|
||||
"/v1beta/openai",
|
||||
"/v1/openai",
|
||||
"/openai",
|
||||
"/v1beta/models",
|
||||
"/v1/models",
|
||||
"/models",
|
||||
"/v1beta",
|
||||
"/v1",
|
||||
] {
|
||||
if path == suffix {
|
||||
return String::new();
|
||||
}
|
||||
if let Some(prefix) = path.strip_suffix(suffix) {
|
||||
return normalize_prefix(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn normalize_prefix(prefix: &str) -> String {
|
||||
let prefix = prefix.trim_end_matches('/');
|
||||
if prefix.is_empty() || prefix == "/" {
|
||||
String::new()
|
||||
} else {
|
||||
prefix.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when `path` contains a `/models/` segment followed by a
|
||||
/// canonical Gemini method call (`*:generateContent` or
|
||||
/// `*:streamGenerateContent`). The `/models/` segment alone is not enough:
|
||||
/// opaque relay routes such as `/v1/models/invoke` or `/custom/models/foo`
|
||||
/// also contain `/models/` but are not Gemini-structured and must not be
|
||||
/// rewritten.
|
||||
fn matches_structured_gemini_models_path(path: &str) -> bool {
|
||||
let mut cursor = path;
|
||||
while let Some(idx) = cursor.find("/models/") {
|
||||
let after = &cursor[idx + "/models/".len()..];
|
||||
if after.contains(":generateContent") || after.contains(":streamGenerateContent") {
|
||||
return true;
|
||||
}
|
||||
// Advance past this `/models/` occurrence so a later Gemini-style
|
||||
// segment in the same path (unusual but cheap to handle) can still
|
||||
// match.
|
||||
cursor = &cursor[idx + "/models/".len()..];
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option<String> {
|
||||
let parts: Vec<&str> = [base_query, endpoint_query]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|query| query.split('&'))
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_gemini_native_url, normalize_gemini_model_id, resolve_gemini_native_url};
|
||||
|
||||
#[test]
|
||||
fn strips_version_root_for_official_base() {
|
||||
let url = build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_openai_compat_path_for_official_base() {
|
||||
let url = build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_custom_proxy_prefix_while_stripping_openai_suffix() {
|
||||
let url = build_gemini_native_url(
|
||||
"https://proxy.example.com/google/v1beta/openai/chat/completions",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://proxy.example.com/google/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_model_method_path_from_full_url_base() {
|
||||
let url = build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_structured_full_url_by_normalizing_to_requested_method() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_opaque_full_url_without_appending_gemini_models_path() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/generate-content",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_containing_models_segment() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/models/invoke",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/models/invoke?alt=sse");
|
||||
}
|
||||
|
||||
/// Regression: a relay whose fixed path starts with `/v1/models/` is an
|
||||
/// opaque route, not a Gemini-structured endpoint. The previous
|
||||
/// heuristic matched any `contains("/v1/models/")` and rewrote it to
|
||||
/// `/v1beta/models/{model}:generateContent`, dropping the relay's own
|
||||
/// route component (`/invoke`) and sending traffic to the wrong place.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1_models_prefix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/v1/models/invoke",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/v1/models/invoke?alt=sse");
|
||||
}
|
||||
|
||||
/// Same regression, `/v1beta/models/` variant — relays may use Google's
|
||||
/// path layout defensively for routing while still being opaque.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1beta_models_prefix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/v1beta/models/route",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/v1beta/models/route?alt=sse");
|
||||
}
|
||||
|
||||
/// Counter-case: a full URL that *does* carry a genuine Gemini method
|
||||
/// segment (`:generateContent`) under `/v1/models/` should still be
|
||||
/// recognized as structured and normalized to the requested model.
|
||||
#[test]
|
||||
fn normalizes_structured_v1_models_path_with_method_segment() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/v1/models/gemini-2.5-pro:generateContent",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://relay.example/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Google-host whitelist tests (generic REST suffix handling)
|
||||
//
|
||||
// Generic REST conventions like `/v1`, `/models`, `/openai` legitimately
|
||||
// appear on opaque relays. `should_normalize_gemini_full_url` only
|
||||
// treats these as structured Gemini endpoints when the host itself is
|
||||
// Google's Gemini or Vertex AI endpoint.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Regression: a relay whose fixed path ends with `/v1` (a ubiquitous
|
||||
/// REST convention) used to be rewritten to
|
||||
/// `/v1beta/models/{model}:generateContent`, dropping the relay's own
|
||||
/// `/v1` endpoint.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/v1?alt=sse");
|
||||
}
|
||||
|
||||
/// Companion case: bare `/models` suffix on a non-Google host is a
|
||||
/// generic REST path, not a Gemini-structured endpoint.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_models_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/models?alt=sse");
|
||||
}
|
||||
|
||||
/// Companion case: `/v1/models` — same ambiguity as `/models`, with the
|
||||
/// version prefix. Must stay as-is on non-Google hosts.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1_models_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/v1/models?alt=sse");
|
||||
}
|
||||
|
||||
/// Companion case: a relay that exposes an `/openai` compatibility
|
||||
/// surface without the deep `/openai/chat/completions` path. Must stay
|
||||
/// as-is on non-Google hosts.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_openai_suffix_on_non_google_host() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/openai",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/openai?alt=sse");
|
||||
}
|
||||
|
||||
/// Counter-case: `/v1` on the official Gemini host must still be
|
||||
/// normalized to the full `/v1beta/models/...` endpoint — users who
|
||||
/// paste `https://generativelanguage.googleapis.com/v1` as their base
|
||||
/// URL expect the proxy to resolve the method path.
|
||||
#[test]
|
||||
fn normalizes_google_host_with_v1_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Counter-case: `/models` on the official Gemini host is recognized
|
||||
/// and normalized.
|
||||
#[test]
|
||||
fn normalizes_google_host_with_models_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Counter-case: Vertex AI regional endpoints live under
|
||||
/// `*-aiplatform.googleapis.com`. Those should also be treated as
|
||||
/// Google-host for the whitelist.
|
||||
#[test]
|
||||
fn normalizes_vertex_aiplatform_host_with_v1_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://us-central1-aiplatform.googleapis.com/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Safety: the Google-host whitelist must do an exact/suffix match, not
|
||||
/// a `contains`. A lookalike host like `aiplatform.example.com` must
|
||||
/// NOT be treated as Google.
|
||||
#[test]
|
||||
fn preserves_non_google_aiplatform_lookalike_host() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://aiplatform.example.com/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://aiplatform.example.com/v1?alt=sse");
|
||||
}
|
||||
|
||||
/// Regression: `/v1beta` by itself is Google-conventional but not
|
||||
/// literally impossible on other hosts. An opaque relay fronting a
|
||||
/// non-Gemini service at `https://relay.example/custom/v1beta` would
|
||||
/// be silently rewritten if `/v1beta` were classified as unconditional
|
||||
/// structured Gemini. Require the Google-host whitelist instead.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_bare_v1beta_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1beta",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/v1beta?alt=sse");
|
||||
}
|
||||
|
||||
/// Companion case: `/v1beta/models` (no method segment) on a non-Google
|
||||
/// host stays as-is too.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1beta_models_suffix_no_method() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1beta/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/v1beta/models?alt=sse");
|
||||
}
|
||||
|
||||
/// Counter-case: `/v1beta` on the official Gemini host must still
|
||||
/// normalize — this is the canonical base URL shape users paste from
|
||||
/// AI Studio documentation.
|
||||
#[test]
|
||||
fn normalizes_google_host_with_v1beta_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression guard: in non-full-URL mode, a versioned third-party
|
||||
/// relay base must have its `/v1beta` suffix **stripped** so the
|
||||
/// appended standard endpoint (`/v1beta/models/{model}:method`) does
|
||||
/// not produce a doubled `/v1beta/v1beta/models/...` path. Non-full
|
||||
/// mode's contract is "base URL + cc-switch appends the canonical
|
||||
/// Gemini endpoint" — a user who wants a relay's custom namespace
|
||||
/// (e.g. `/v1/models/...`) must use full-URL mode instead.
|
||||
///
|
||||
/// This test pins the intentional asymmetry with
|
||||
/// `preserves_opaque_full_url_with_bare_v1beta_suffix` (full-URL
|
||||
/// preserves, non-full strips) so nobody "fixes" one side into
|
||||
/// breaking the other.
|
||||
#[test]
|
||||
fn strips_versioned_relay_base_suffix_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1beta",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://relay.example/custom/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion case: `/v1` base suffix also stripped in non-full-URL
|
||||
/// mode regardless of host.
|
||||
#[test]
|
||||
fn strips_v1_relay_base_suffix_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://relay.example/custom/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Model ID normalization tests.
|
||||
//
|
||||
// Gemini SDKs and documentation commonly surface model identifiers as
|
||||
// `models/gemini-2.5-pro` (resource-name form). If that value flows
|
||||
// straight into our URL builder, the format string
|
||||
// `/v1beta/models/{model}:generateContent` produces a doubled prefix
|
||||
// `/v1beta/models/models/gemini-2.5-pro:generateContent`, which is
|
||||
// rejected upstream. `normalize_gemini_model_id` is the single source
|
||||
// of truth callers should run the model through first.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_strips_models_prefix() {
|
||||
assert_eq!(
|
||||
normalize_gemini_model_id("models/gemini-2.5-pro"),
|
||||
"gemini-2.5-pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_leaves_bare_id_unchanged() {
|
||||
assert_eq!(
|
||||
normalize_gemini_model_id("gemini-2.5-pro"),
|
||||
"gemini-2.5-pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_preserves_nested_slashes_after_prefix() {
|
||||
// e.g. tuned model resource like `models/gemini-2.5-pro/tunedModels/xxx`
|
||||
// — the caller asked for a specific tuned model resource, keep its
|
||||
// identity intact after stripping only the single leading prefix.
|
||||
assert_eq!(
|
||||
normalize_gemini_model_id("models/tunedModels/my-tuned"),
|
||||
"tunedModels/my-tuned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_tolerates_leading_slash() {
|
||||
assert_eq!(
|
||||
normalize_gemini_model_id("/models/gemini-2.5-flash"),
|
||||
"gemini-2.5-flash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_preserves_empty_input() {
|
||||
// Edge: caller has no model at all. Pass through so the URL error
|
||||
// surfaces at the request layer rather than producing a misleading
|
||||
// empty segment here.
|
||||
assert_eq!(normalize_gemini_model_id(""), "");
|
||||
}
|
||||
}
|
||||
@@ -218,9 +218,11 @@ impl RequestContext {
|
||||
non_streaming_timeout,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
state.gemini_shadow.clone(),
|
||||
state.failover_manager.clone(),
|
||||
state.app_handle.clone(),
|
||||
self.current_provider_id.clone(),
|
||||
self.session_id.clone(),
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
|
||||
@@ -15,12 +15,14 @@ use super::{
|
||||
handler_context::RequestContext,
|
||||
providers::{
|
||||
get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,
|
||||
streaming_gemini::create_anthropic_sse_stream_from_gemini,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_responses,
|
||||
transform_gemini, transform_responses,
|
||||
},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
|
||||
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
|
||||
SseUsageCollector,
|
||||
},
|
||||
server::ProxyState,
|
||||
types::*,
|
||||
@@ -144,11 +146,13 @@ async fn handle_claude_transform(
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
_original_body: &Value,
|
||||
original_body: &Value,
|
||||
is_stream: bool,
|
||||
api_format: &str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body);
|
||||
let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints);
|
||||
|
||||
if is_stream {
|
||||
// 根据 api_format 选择流式转换器
|
||||
@@ -157,6 +161,14 @@ async fn handle_claude_transform(
|
||||
dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,
|
||||
> = if api_format == "openai_responses" {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream)))
|
||||
} else if api_format == "gemini_native" {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream_from_gemini(
|
||||
stream,
|
||||
Some(state.gemini_shadow.clone()),
|
||||
Some(ctx.provider.id.clone()),
|
||||
Some(ctx.session_id.clone()),
|
||||
tool_schema_hints.clone(),
|
||||
)))
|
||||
} else {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
|
||||
};
|
||||
@@ -216,10 +228,6 @@ async fn handle_claude_transform(
|
||||
"Cache-Control",
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
headers.insert(
|
||||
"Connection",
|
||||
axum::http::HeaderValue::from_static("keep-alive"),
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
return Ok((headers, body).into_response());
|
||||
@@ -245,6 +253,14 @@ async fn handle_claude_transform(
|
||||
// 根据 api_format 选择非流式转换器
|
||||
let anthropic_response = if api_format == "openai_responses" {
|
||||
transform_responses::responses_to_anthropic(upstream_response)
|
||||
} else if api_format == "gemini_native" {
|
||||
transform_gemini::gemini_to_anthropic_with_shadow_and_hints(
|
||||
upstream_response,
|
||||
Some(state.gemini_shadow.as_ref()),
|
||||
Some(&ctx.provider.id),
|
||||
Some(&ctx.session_id),
|
||||
tool_schema_hints.as_ref(),
|
||||
)
|
||||
} else {
|
||||
transform::openai_to_anthropic(upstream_response)
|
||||
}
|
||||
@@ -287,6 +303,7 @@ async fn handle_claude_transform(
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
for (key, value) in response_headers.iter() {
|
||||
builder = builder.header(key, value);
|
||||
@@ -603,7 +620,7 @@ async fn log_usage(
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let request_id = usage.dedup_request_id();
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
request_id,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! 提供支持全局代理配置的 HTTP 客户端。
|
||||
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
||||
|
||||
use crate::provider::ProviderProxyConfig;
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::Client;
|
||||
use std::env;
|
||||
@@ -334,103 +333,6 @@ pub fn mask_url(url: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据供应商单独代理配置构建代理 URL
|
||||
///
|
||||
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
|
||||
pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
|
||||
let host = config.proxy_host.as_deref()?;
|
||||
let port = config.proxy_port?;
|
||||
|
||||
// 构建带认证的代理 URL
|
||||
if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {
|
||||
if !username.is_empty() && !password.is_empty() {
|
||||
return Some(format!(
|
||||
"{proxy_type}://{username}:{password}@{host}:{port}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!("{proxy_type}://{host}:{port}"))
|
||||
}
|
||||
|
||||
/// 根据供应商单独代理配置构建 HTTP 客户端
|
||||
///
|
||||
/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端;
|
||||
/// 否则返回 None,调用方应使用全局客户端。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_config` - 供应商的代理配置
|
||||
///
|
||||
/// # Returns
|
||||
/// 如果配置有效则返回 Some(Client),否则返回 None
|
||||
pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option<Client> {
|
||||
let config = proxy_config.filter(|c| c.enabled)?;
|
||||
|
||||
let proxy_url = build_proxy_url_from_config(config)?;
|
||||
|
||||
log::debug!(
|
||||
"[ProviderProxy] Building client with proxy: {}",
|
||||
mask_url(&proxy_url)
|
||||
);
|
||||
|
||||
// 构建带代理的客户端
|
||||
let proxy = match reqwest::Proxy::all(&proxy_url) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[ProviderProxy] Failed to create proxy from '{}': {}",
|
||||
mask_url(&proxy_url),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate()
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
{
|
||||
Ok(client) => {
|
||||
log::info!(
|
||||
"[ProviderProxy] Client built with proxy: {}",
|
||||
mask_url(&proxy_url)
|
||||
);
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[ProviderProxy] Failed to build client: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取供应商专用的 HTTP 客户端
|
||||
///
|
||||
/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_config` - 供应商的代理配置
|
||||
///
|
||||
/// # Returns
|
||||
/// 返回适合该供应商的 HTTP 客户端
|
||||
pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {
|
||||
// 优先使用供应商单独代理
|
||||
if let Some(client) = build_client_for_provider(proxy_config) {
|
||||
return client;
|
||||
}
|
||||
|
||||
// 回退到全局客户端
|
||||
get()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod error;
|
||||
pub mod error_mapper;
|
||||
pub(crate) mod failover_switch;
|
||||
mod forwarder;
|
||||
pub mod gemini_url;
|
||||
pub mod handler_config;
|
||||
pub mod handler_context;
|
||||
mod handlers;
|
||||
|
||||
@@ -11,7 +11,6 @@ pub struct ModelMapping {
|
||||
pub sonnet_model: Option<String>,
|
||||
pub opus_model: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub reasoning_model: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelMapping {
|
||||
@@ -40,11 +39,6 @@ impl ModelMapping {
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
reasoning_model: env
|
||||
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,21 +48,13 @@ impl ModelMapping {
|
||||
|| self.sonnet_model.is_some()
|
||||
|| self.opus_model.is_some()
|
||||
|| self.default_model.is_some()
|
||||
|| self.reasoning_model.is_some()
|
||||
}
|
||||
|
||||
/// 根据原始模型名称获取映射后的模型
|
||||
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
|
||||
pub fn map_model(&self, original_model: &str) -> String {
|
||||
let model_lower = original_model.to_lowercase();
|
||||
|
||||
// 1. thinking 模式优先使用推理模型
|
||||
if has_thinking {
|
||||
if let Some(ref m) = self.reasoning_model {
|
||||
return m.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 按模型类型匹配
|
||||
// 1. 按模型类型匹配
|
||||
if model_lower.contains("haiku") {
|
||||
if let Some(ref m) = self.haiku_model {
|
||||
return m.clone();
|
||||
@@ -85,35 +71,16 @@ impl ModelMapping {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 默认模型
|
||||
// 2. 默认模型
|
||||
if let Some(ref m) = self.default_model {
|
||||
return m.clone();
|
||||
}
|
||||
|
||||
// 4. 无映射,保持原样
|
||||
// 3. 无映射,保持原样
|
||||
original_model.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测请求是否启用了 thinking 模式
|
||||
pub fn has_thinking_enabled(body: &Value) -> bool {
|
||||
match body
|
||||
.get("thinking")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| o.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
Some("enabled") | Some("adaptive") => true,
|
||||
Some("disabled") | None => false,
|
||||
Some(other) => {
|
||||
log::warn!(
|
||||
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 对请求体应用模型映射
|
||||
///
|
||||
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
|
||||
@@ -133,8 +100,7 @@ pub fn apply_model_mapping(
|
||||
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
||||
|
||||
if let Some(ref original) = original_model {
|
||||
let has_thinking = has_thinking_enabled(&body);
|
||||
let mapped = mapping.map_model(original, has_thinking);
|
||||
let mapped = mapping.map_model(original);
|
||||
|
||||
if mapped != *original {
|
||||
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||
@@ -160,8 +126,7 @@ mod tests {
|
||||
"ANTHROPIC_MODEL": "default-model",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
|
||||
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
|
||||
}
|
||||
}),
|
||||
website_url: None,
|
||||
@@ -193,27 +158,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_provider_with_reasoning_only() -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
settings_config: json!({
|
||||
"env": {
|
||||
"ANTHROPIC_REASONING_MODEL": "reasoning-only-model"
|
||||
}
|
||||
}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sonnet_mapping() {
|
||||
let provider = create_provider_with_mapping();
|
||||
@@ -243,40 +187,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_mode() {
|
||||
fn test_thinking_does_not_affect_model_mapping() {
|
||||
// Issue #2081: thinking 参数不应影响模型映射
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "enabled"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-model");
|
||||
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_only_mapping_in_thinking_mode() {
|
||||
let provider = create_provider_with_reasoning_only();
|
||||
fn test_thinking_adaptive_does_not_affect_model_mapping() {
|
||||
// Issue #2081: adaptive thinking 也不应影响模型映射
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "enabled"}
|
||||
"thinking": {"type": "adaptive"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-only-model");
|
||||
assert_eq!(mapped, Some("reasoning-only-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_only_mapping_does_not_affect_non_thinking() {
|
||||
let provider = create_provider_with_reasoning_only();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "disabled"}
|
||||
});
|
||||
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
|
||||
assert!(mapped.is_none());
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -310,30 +243,6 @@ mod tests {
|
||||
assert!(mapped.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "adaptive"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-model");
|
||||
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_unknown_type() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "some_future_type"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||||
//! - **gemini_native**: Google Gemini Native generateContent 格式,需要 Anthropic ↔ Gemini 转换
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
@@ -35,6 +36,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
"gemini_native" => "gemini_native",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
@@ -49,6 +51,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
"gemini_native" => "gemini_native",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
@@ -73,20 +76,61 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||
matches!(api_format, "openai_chat" | "openai_responses")
|
||||
matches!(
|
||||
api_format,
|
||||
"openai_chat" | "openai_responses" | "gemini_native"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn transform_claude_request_for_api_format(
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
session_id: Option<&str>,
|
||||
shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
let cache_key = provider
|
||||
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
|
||||
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
|
||||
// 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| provider
|
||||
.settings_config
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|u| u.contains("githubcopilot.com"));
|
||||
let session_cache_key: Option<String> = if is_copilot {
|
||||
let metadata = body.get("metadata");
|
||||
// Session 提取优先级(与 forwarder 和 session.rs 统一):
|
||||
// 1. metadata.user_id 中的 _session_ 后缀
|
||||
// 2. metadata.session_id(直接字段)
|
||||
metadata
|
||||
.and_then(|m| m.get("user_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(super::super::session::parse_session_from_user_id)
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cache_key = session_cache_key
|
||||
.as_deref()
|
||||
.or_else(|| {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
})
|
||||
.unwrap_or(&provider.id);
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
|
||||
@@ -102,7 +146,24 @@ pub fn transform_claude_request_for_api_format(
|
||||
is_codex_oauth,
|
||||
)
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
"openai_chat" => {
|
||||
let mut result = super::transform::anthropic_to_openai(body)?;
|
||||
// Inject prompt_cache_key only if explicitly configured in meta
|
||||
if let Some(key) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
{
|
||||
result["prompt_cache_key"] = serde_json::json!(key);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
||||
body,
|
||||
shadow_store,
|
||||
Some(&provider.id),
|
||||
session_id,
|
||||
),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
@@ -124,6 +185,16 @@ impl ClaudeAdapter {
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 Gemini Native 格式
|
||||
if self.get_api_format(provider) == "gemini_native" {
|
||||
return match self.extract_key(provider) {
|
||||
Some(key) if key.starts_with("ya29.") || key.starts_with('{') => {
|
||||
ProviderType::GeminiCli
|
||||
}
|
||||
_ => ProviderType::Gemini,
|
||||
};
|
||||
}
|
||||
|
||||
// 检测 Codex OAuth (ChatGPT Plus/Pro)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return ProviderType::CodexOAuth;
|
||||
@@ -226,6 +297,7 @@ impl ClaudeAdapter {
|
||||
if let Some(key) = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
||||
@@ -234,6 +306,7 @@ impl ClaudeAdapter {
|
||||
if let Some(key) = env
|
||||
.get("ANTHROPIC_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
|
||||
@@ -243,6 +316,7 @@ impl ClaudeAdapter {
|
||||
if let Some(key) = env
|
||||
.get("OPENROUTER_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
|
||||
@@ -252,6 +326,7 @@ impl ClaudeAdapter {
|
||||
if let Some(key) = env
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||||
@@ -265,6 +340,7 @@ impl ClaudeAdapter {
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 apiKey/api_key");
|
||||
@@ -352,14 +428,43 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
));
|
||||
}
|
||||
|
||||
let strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
_ => AuthStrategy::Anthropic,
|
||||
};
|
||||
let key = self.extract_key(provider)?;
|
||||
|
||||
self.extract_key(provider)
|
||||
.map(|key| AuthInfo::new(key, strategy))
|
||||
match provider_type {
|
||||
ProviderType::GeminiCli => {
|
||||
// Parse stored OAuth JSON and only attach access_token when
|
||||
// it's actually usable. `parse_oauth_credentials` accepts
|
||||
// refresh-token-only JSON (which is legitimate before the
|
||||
// first refresh) and also surfaces `{"access_token": "", ...}`
|
||||
// for expired credentials. In both cases we would otherwise
|
||||
// send `Authorization: Bearer ` to upstream and get a 401.
|
||||
//
|
||||
// CC Switch does not currently exchange the refresh_token for
|
||||
// a fresh access_token. Until that path exists, degrade to
|
||||
// plain GoogleOAuth strategy (which still sends the raw key
|
||||
// as a fallback) and log loudly so users know to refresh
|
||||
// their `~/.gemini/oauth_creds.json`.
|
||||
match super::gemini::GeminiAdapter::new().parse_oauth_credentials(&key) {
|
||||
Some(creds) if !creds.access_token.is_empty() => {
|
||||
Some(AuthInfo::with_access_token(key, creds.access_token))
|
||||
}
|
||||
Some(_) => {
|
||||
log::warn!(
|
||||
"[Gemini OAuth] access_token missing or empty for provider `{}`; \
|
||||
bearer auth will likely fail with 401. Refresh \
|
||||
~/.gemini/oauth_creds.json via the gemini CLI to obtain a new token.",
|
||||
provider.id
|
||||
);
|
||||
Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth))
|
||||
}
|
||||
None => Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth)),
|
||||
}
|
||||
}
|
||||
ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)),
|
||||
ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)),
|
||||
ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)),
|
||||
_ => Some(AuthInfo::new(key, AuthStrategy::Anthropic)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
@@ -401,6 +506,23 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
AuthStrategy::Google => vec![(
|
||||
HeaderName::from_static("x-goog-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
)],
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-goog-api-client"),
|
||||
HeaderValue::from_static("GeminiCLI/1.0"),
|
||||
),
|
||||
]
|
||||
}
|
||||
AuthStrategy::CodexOAuth => {
|
||||
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
|
||||
// ChatGPT-Account-Id 由 forwarder 注入额外 header
|
||||
@@ -456,6 +578,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderName::from_static("x-interaction-type"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
// x-interaction-id 由 forwarder 按需注入(仅在有 session 时)
|
||||
(
|
||||
HeaderName::from_static("x-vscode-user-agent-library-version"),
|
||||
HeaderValue::from_static("electron-fetch"),
|
||||
@@ -470,7 +593,6 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +613,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||||
matches!(
|
||||
self.get_api_format(provider),
|
||||
"openai_chat" | "openai_responses"
|
||||
"openai_chat" | "openai_responses" | "gemini_native"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -500,7 +622,13 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
|
||||
transform_claude_request_for_api_format(
|
||||
body,
|
||||
provider,
|
||||
self.get_api_format(provider),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
@@ -509,7 +637,9 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// config, so we can't check api_format here. Instead we rely on the fact that
|
||||
// Responses API always returns "output" while Chat Completions returns "choices".
|
||||
// This is safe because the two formats are structurally disjoint.
|
||||
if body.get("output").is_some() {
|
||||
if body.get("candidates").is_some() || body.get("promptFeedback").is_some() {
|
||||
super::transform_gemini::gemini_to_anthropic(body)
|
||||
} else if body.get("output").is_some() {
|
||||
super::transform_responses::responses_to_anthropic(body)
|
||||
} else {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
@@ -647,6 +777,146 @@ mod tests {
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
|
||||
/// Regression: a Gemini OAuth credential JSON that carries only a
|
||||
/// refresh_token (no active access_token) must not be surfaced as an
|
||||
/// `AuthInfo` whose bearer would be empty. Without the guard, downstream
|
||||
/// header injection produces `Authorization: Bearer ` and a deterministic
|
||||
/// 401 from upstream.
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_refresh_only_json_does_not_expose_empty_bearer() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let refresh_only_json =
|
||||
r#"{"refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#;
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": refresh_only_json
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
// access_token must not be surfaced as `Some("")` — the OAuth header
|
||||
// builder uses `access_token.as_ref().unwrap_or(&api_key)`, so a
|
||||
// `Some("")` would win over the raw key and emit `Bearer `.
|
||||
assert!(
|
||||
auth.access_token.as_deref().is_none_or(|t| !t.is_empty()),
|
||||
"empty access_token leaked into AuthInfo"
|
||||
);
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
/// Companion case: a JSON credential with an empty-string `access_token`
|
||||
/// field (the shape an expired credential can take after partial writes)
|
||||
/// must degrade the same way.
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_empty_access_token_degrades_to_raw_key() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let expired_json = r#"{"access_token":"","refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#;
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": expired_json
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert!(
|
||||
auth.access_token.as_deref().is_none_or(|t| !t.is_empty()),
|
||||
"empty access_token leaked into AuthInfo"
|
||||
);
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
/// Counter-case: a well-formed JSON credential with a non-empty
|
||||
/// access_token must still flow through the OAuth path unchanged.
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_valid_json_keeps_access_token() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#;
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": valid_json
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.access_token.as_deref(), Some("ya29.valid"));
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
/// 回归:从 oauth_creds.json 复制时常带前导换行/空格。未 trim 时
|
||||
/// `starts_with('{')` 会落空,导致误分类为 `ProviderType::Gemini`,再
|
||||
/// 以 raw JSON 当 `x-goog-api-key` 发出去触发 401。trim 应在 provider
|
||||
/// 类型判定和 OAuth 解析前统一生效。
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_json_with_leading_whitespace_classifies_correctly() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#;
|
||||
let key_with_whitespace = format!("\n {valid_json}\n");
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": key_with_whitespace
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.access_token.as_deref(), Some("ya29.valid"));
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
/// 回归:裸 `ya29.` access_token 若带前导换行,也应被 trim 后识别为
|
||||
/// Gemini CLI OAuth,避免前导空白把 `starts_with("ya29.")` 检查顶穿。
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_access_token_with_leading_newline_classifies_correctly() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": "\nya29.raw-token-value\n"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.access_token.as_deref(), Some("ya29.raw-token-value"));
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_detection() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
@@ -813,6 +1083,24 @@ mod tests {
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_responses_provider));
|
||||
|
||||
let gemini_native_provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(adapter.needs_transform(&gemini_native_provider));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&gemini_native_provider),
|
||||
ProviderType::Gemini
|
||||
);
|
||||
|
||||
// meta takes precedence over legacy settings_config fields
|
||||
let meta_precedence_over_settings = create_provider_with_meta(
|
||||
json!({
|
||||
@@ -920,11 +1208,106 @@ mod tests {
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap();
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
body,
|
||||
&provider,
|
||||
"openai_responses",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transformed["model"], "gpt-5.4");
|
||||
assert!(transformed.get("input").is_some());
|
||||
assert!(transformed.get("max_output_tokens").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_gemini_native() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gemini-2.5-pro",
|
||||
"system": "You are helpful.",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "gemini_native", None, None)
|
||||
.unwrap();
|
||||
|
||||
assert!(transformed.get("contents").is_some());
|
||||
assert_eq!(
|
||||
transformed["systemInstruction"]["parts"][0]["text"],
|
||||
"You are helpful."
|
||||
);
|
||||
assert_eq!(transformed["generationConfig"]["maxOutputTokens"], 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_openai_chat_skips_prompt_cache_key_by_default()
|
||||
{
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
|
||||
assert!(transformed.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_openai_chat_keeps_explicit_prompt_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
prompt_cache_key: Some("claude-cache-route".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transformed["prompt_cache_key"], "claude-cache-route");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ impl From<&CodexAccountData> for GitHubAccount {
|
||||
.unwrap_or_else(|| format!("ChatGPT ({})", &data.account_id)),
|
||||
avatar_url: None,
|
||||
authenticated_at: data.authenticated_at,
|
||||
github_domain: "github.com".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,26 +24,114 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
/// GitHub OAuth 客户端 ID(VS Code 使用的 ID)
|
||||
/// GitHub OAuth 客户端 ID(VS Code)- 用于 github.com
|
||||
const GITHUB_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
|
||||
|
||||
/// GitHub OAuth 客户端 ID(与 OpenCode 相同)- 在所有 GHES Copilot 实例上预注册
|
||||
const GITHUB_CLIENT_ID_GHES: &str = "Ov23li8tweQw6odWQebz";
|
||||
|
||||
/// 默认 GitHub 域名
|
||||
const DEFAULT_GITHUB_DOMAIN: &str = "github.com";
|
||||
|
||||
/// 根据域名选择 OAuth 客户端 ID
|
||||
fn github_client_id(domain: &str) -> &'static str {
|
||||
if domain == DEFAULT_GITHUB_DOMAIN {
|
||||
GITHUB_CLIENT_ID
|
||||
} else {
|
||||
GITHUB_CLIENT_ID_GHES
|
||||
}
|
||||
}
|
||||
|
||||
fn default_github_domain() -> String {
|
||||
DEFAULT_GITHUB_DOMAIN.to_string()
|
||||
}
|
||||
|
||||
/// GitHub 设备码 URL
|
||||
const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
|
||||
fn github_device_code_url(domain: &str) -> String {
|
||||
format!("https://{domain}/login/device/code")
|
||||
}
|
||||
|
||||
/// GitHub OAuth Token URL
|
||||
const GITHUB_OAUTH_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
|
||||
fn github_oauth_token_url(domain: &str) -> String {
|
||||
format!("https://{domain}/login/oauth/access_token")
|
||||
}
|
||||
|
||||
/// GitHub API 基础 URL(github.com 用 api.github.com,GHES 用 {domain}/api/v3)
|
||||
fn github_api_base(domain: &str) -> String {
|
||||
if domain == DEFAULT_GITHUB_DOMAIN {
|
||||
"https://api.github.com".to_string()
|
||||
} else {
|
||||
format!("https://{domain}/api/v3")
|
||||
}
|
||||
}
|
||||
|
||||
/// Copilot Token URL
|
||||
const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token";
|
||||
fn copilot_token_url(domain: &str) -> String {
|
||||
format!("{}/copilot_internal/v2/token", github_api_base(domain))
|
||||
}
|
||||
|
||||
/// GitHub User API URL
|
||||
const GITHUB_USER_URL: &str = "https://api.github.com/user";
|
||||
fn github_user_url(domain: &str) -> String {
|
||||
format!("{}/user", github_api_base(domain))
|
||||
}
|
||||
|
||||
/// Copilot 使用量 API URL
|
||||
fn copilot_usage_url(domain: &str) -> String {
|
||||
format!("{}/copilot_internal/user", github_api_base(domain))
|
||||
}
|
||||
|
||||
/// Copilot API 基础地址(github.com 用 api.githubcopilot.com,GHES 用 copilot-api.{domain})
|
||||
fn copilot_api_base(domain: &str) -> String {
|
||||
if domain == DEFAULT_GITHUB_DOMAIN {
|
||||
"https://api.githubcopilot.com".to_string()
|
||||
} else {
|
||||
format!("https://copilot-api.{domain}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Token 刷新提前量(秒)
|
||||
const TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60;
|
||||
|
||||
/// Copilot API 端点
|
||||
const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
|
||||
/// 判断是否为 GitHub Enterprise Server(非 github.com)
|
||||
fn is_ghes(domain: &str) -> bool {
|
||||
domain != DEFAULT_GITHUB_DOMAIN
|
||||
}
|
||||
|
||||
/// 归一化 GitHub 域名(SSOT):
|
||||
/// - 小写化
|
||||
/// - 剥离协议(https:// http://)
|
||||
/// - 剥离尾斜杠、path、query、fragment
|
||||
/// - 拒绝包含 userinfo(@)的输入
|
||||
/// - 保留端口号(如有)
|
||||
fn normalize_github_domain(raw: &str) -> Result<String, CopilotAuthError> {
|
||||
let s = raw.trim();
|
||||
// 剥离协议
|
||||
let s = s
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| s.strip_prefix("http://"))
|
||||
.unwrap_or(s);
|
||||
// 取 host 部分(到第一个 / 或 ? 或 #)
|
||||
let host = s.split(&['/', '?', '#'][..]).next().unwrap_or(s);
|
||||
// 拒绝 userinfo
|
||||
if host.contains('@') {
|
||||
return Err(CopilotAuthError::InvalidDomain(raw.to_string()));
|
||||
}
|
||||
let normalized = host.to_lowercase();
|
||||
if normalized.is_empty() {
|
||||
return Err(CopilotAuthError::InvalidDomain(raw.to_string()));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// 生成复合账号 ID,确保不同 GHES 实例的 user ID 不会冲突。
|
||||
/// github.com 账号保持原格式(向后兼容),GHES 账号使用 `domain:user_id` 格式。
|
||||
fn composite_account_id(domain: &str, user_id: u64) -> String {
|
||||
if domain == DEFAULT_GITHUB_DOMAIN {
|
||||
user_id.to_string()
|
||||
} else {
|
||||
format!("{}:{}", domain, user_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copilot API Header 常量
|
||||
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.110.1";
|
||||
@@ -52,12 +140,6 @@ pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.38.2";
|
||||
pub const COPILOT_API_VERSION: &str = "2025-10-01";
|
||||
pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
|
||||
|
||||
/// Copilot 使用量 API URL
|
||||
const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user";
|
||||
|
||||
/// 默认 Copilot API 端点
|
||||
const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com";
|
||||
|
||||
/// Copilot 使用量响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CopilotUsageResponse {
|
||||
@@ -169,6 +251,9 @@ pub enum CopilotAuthError {
|
||||
|
||||
#[error("账号不存在: {0}")]
|
||||
AccountNotFound(String),
|
||||
|
||||
#[error("无效的 GitHub 域名: {0}")]
|
||||
InvalidDomain(String),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for CopilotAuthError {
|
||||
@@ -253,15 +338,19 @@ pub struct GitHubAccount {
|
||||
pub avatar_url: Option<String>,
|
||||
/// 认证时间戳
|
||||
pub authenticated_at: i64,
|
||||
/// GitHub 域名(github.com 或 GHES 域名)
|
||||
#[serde(default = "default_github_domain")]
|
||||
pub github_domain: String,
|
||||
}
|
||||
|
||||
impl From<&GitHubAccountData> for GitHubAccount {
|
||||
fn from(data: &GitHubAccountData) -> Self {
|
||||
GitHubAccount {
|
||||
id: data.user.id.to_string(),
|
||||
id: composite_account_id(&data.github_domain, data.user.id),
|
||||
login: data.user.login.clone(),
|
||||
avatar_url: data.user.avatar_url.clone(),
|
||||
authenticated_at: data.authenticated_at,
|
||||
github_domain: data.github_domain.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +384,9 @@ struct GitHubAccountData {
|
||||
pub user: GitHubUser,
|
||||
/// 认证时间戳
|
||||
pub authenticated_at: i64,
|
||||
/// GitHub 域名(github.com 或 GHES 域名)
|
||||
#[serde(default = "default_github_domain")]
|
||||
pub github_domain: String,
|
||||
}
|
||||
|
||||
/// 持久化存储结构(v3 多账号 + 默认账号格式)
|
||||
@@ -437,14 +529,16 @@ impl CopilotAuthManager {
|
||||
&self,
|
||||
github_token: String,
|
||||
user: GitHubUser,
|
||||
github_domain: String,
|
||||
) -> Result<GitHubAccount, CopilotAuthError> {
|
||||
let account_id = user.id.to_string();
|
||||
let account_id = composite_account_id(&github_domain, user.id);
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
let account_data = GitHubAccountData {
|
||||
github_token,
|
||||
user: user.clone(),
|
||||
authenticated_at: now,
|
||||
github_domain: github_domain.clone(),
|
||||
};
|
||||
|
||||
let account = GitHubAccount {
|
||||
@@ -452,6 +546,7 @@ impl CopilotAuthManager {
|
||||
login: user.login.clone(),
|
||||
avatar_url: user.avatar_url.clone(),
|
||||
authenticated_at: now,
|
||||
github_domain,
|
||||
};
|
||||
|
||||
{
|
||||
@@ -497,15 +592,25 @@ impl CopilotAuthManager {
|
||||
// ==================== 设备码流程 ====================
|
||||
|
||||
/// 启动设备码流程
|
||||
pub async fn start_device_flow(&self) -> Result<GitHubDeviceCodeResponse, CopilotAuthError> {
|
||||
log::info!("[CopilotAuth] 启动设备码流程");
|
||||
pub async fn start_device_flow(
|
||||
&self,
|
||||
github_domain: Option<&str>,
|
||||
) -> Result<GitHubDeviceCodeResponse, CopilotAuthError> {
|
||||
let domain = match github_domain {
|
||||
Some(d) => normalize_github_domain(d)?,
|
||||
None => DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
};
|
||||
log::info!("[CopilotAuth] 启动设备码流程 (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(GITHUB_DEVICE_CODE_URL)
|
||||
.post(github_device_code_url(&domain))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")])
|
||||
.form(&[
|
||||
("client_id", github_client_id(&domain)),
|
||||
("scope", "read:user"),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -534,16 +639,21 @@ impl CopilotAuthManager {
|
||||
pub async fn poll_for_token(
|
||||
&self,
|
||||
device_code: &str,
|
||||
github_domain: Option<&str>,
|
||||
) -> Result<Option<GitHubAccount>, CopilotAuthError> {
|
||||
log::debug!("[CopilotAuth] 轮询 OAuth Token");
|
||||
let domain = match github_domain {
|
||||
Some(d) => normalize_github_domain(d)?,
|
||||
None => DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
};
|
||||
log::debug!("[CopilotAuth] 轮询 OAuth Token (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(GITHUB_OAUTH_TOKEN_URL)
|
||||
.post(github_oauth_token_url(&domain))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("client_id", github_client_id(&domain)),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
@@ -578,14 +688,28 @@ impl CopilotAuthManager {
|
||||
log::info!("[CopilotAuth] OAuth Token 获取成功");
|
||||
|
||||
// 获取用户信息
|
||||
let user = self.fetch_user_info_with_token(&access_token).await?;
|
||||
|
||||
// 验证 Copilot 订阅(获取 Copilot Token)
|
||||
self.fetch_copilot_token_with_github_token(&access_token, &user.id.to_string())
|
||||
let user = self
|
||||
.fetch_user_info_with_token(&access_token, &domain)
|
||||
.await?;
|
||||
|
||||
// GHES 无需换取 Copilot Token,直接使用 OAuth token 作为 Bearer
|
||||
// 参考 OpenCode 的实现:GHE Copilot 直接用 OAuth token 调用 copilot-api.{domain}
|
||||
if !is_ghes(&domain) {
|
||||
// github.com:验证 Copilot 订阅(获取 Copilot Token)
|
||||
self.fetch_copilot_token_with_github_token(
|
||||
&access_token,
|
||||
&user.id.to_string(),
|
||||
&domain,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
log::info!("[CopilotAuth] GHES 账号,跳过 Copilot Token 兑换,直接使用 OAuth token");
|
||||
}
|
||||
|
||||
// 添加账号
|
||||
let account = self.add_account_internal(access_token, user).await?;
|
||||
let account = self
|
||||
.add_account_internal(access_token, user, domain)
|
||||
.await?;
|
||||
|
||||
Ok(Some(account))
|
||||
}
|
||||
@@ -600,6 +724,16 @@ impl CopilotAuthManager {
|
||||
// 确保迁移完成
|
||||
self.ensure_migration_complete().await?;
|
||||
|
||||
// GHES 账号直接使用 GitHub OAuth token,无需 Copilot token 交换
|
||||
let domain = self.get_account_domain(account_id).await;
|
||||
if is_ghes(&domain) {
|
||||
let accounts = self.accounts.read().await;
|
||||
return accounts
|
||||
.get(account_id)
|
||||
.map(|a| a.github_token.clone())
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()));
|
||||
}
|
||||
|
||||
// 检查缓存的 token
|
||||
{
|
||||
let tokens = self.copilot_tokens.read().await;
|
||||
@@ -627,16 +761,16 @@ impl CopilotAuthManager {
|
||||
}
|
||||
|
||||
// 获取账号的 GitHub token
|
||||
let github_token = {
|
||||
let (github_token, domain) = {
|
||||
let accounts = self.accounts.read().await;
|
||||
accounts
|
||||
let account = accounts
|
||||
.get(account_id)
|
||||
.map(|a| a.github_token.clone())
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?;
|
||||
(account.github_token.clone(), account.github_domain.clone())
|
||||
};
|
||||
|
||||
// 刷新 Copilot token
|
||||
self.fetch_copilot_token_with_github_token(&github_token, account_id)
|
||||
self.fetch_copilot_token_with_github_token(&github_token, account_id, &domain)
|
||||
.await?;
|
||||
|
||||
// 返回新 token
|
||||
@@ -687,11 +821,19 @@ impl CopilotAuthManager {
|
||||
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||
let copilot_token = self.get_valid_token_for_account(account_id).await?;
|
||||
|
||||
// 使用 get_api_endpoint() 动态解析 Copilot API 基础 URL。
|
||||
// 对于 github.com 账号,会查询 /copilot_internal/user 获取 endpoints.api 字段。
|
||||
// 对于 GHES 账号,/copilot_internal/user 可能不返回 endpoints——此时
|
||||
// get_api_endpoint() 会回退到 copilot_api_base(&domain),与之前的静态 URL
|
||||
// 拼接结果一致。该回退行为是安全且符合预期的。
|
||||
let api_base = self.get_api_endpoint(account_id).await;
|
||||
let models_url = format!("{}/models", api_base);
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_MODELS_URL)
|
||||
.get(&models_url)
|
||||
.header("Authorization", format!("Bearer {copilot_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("copilot-integration-id", "vscode-chat")
|
||||
@@ -767,19 +909,19 @@ impl CopilotAuthManager {
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<CopilotUsageResponse, CopilotAuthError> {
|
||||
let github_token = {
|
||||
let (github_token, domain) = {
|
||||
let accounts = self.accounts.read().await;
|
||||
accounts
|
||||
let account = accounts
|
||||
.get(account_id)
|
||||
.map(|a| a.github_token.clone())
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?;
|
||||
(account.github_token.clone(), account.github_domain.clone())
|
||||
};
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_USAGE_URL)
|
||||
.get(copilot_usage_url(&domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
@@ -862,7 +1004,8 @@ impl CopilotAuthManager {
|
||||
log::debug!(
|
||||
"[CopilotAuth] 获取账号 {account_id} 动态 API 端点失败: {e},使用默认值"
|
||||
);
|
||||
DEFAULT_COPILOT_API_ENDPOINT.to_string()
|
||||
let domain = self.get_account_domain(account_id).await;
|
||||
copilot_api_base(&domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -873,24 +1016,27 @@ impl CopilotAuthManager {
|
||||
|
||||
match self.resolve_default_account_id().await {
|
||||
Some(id) => self.get_api_endpoint(&id).await,
|
||||
None => DEFAULT_COPILOT_API_ENDPOINT.to_string(),
|
||||
None => {
|
||||
// 无账号时回退到 github.com 的默认端点
|
||||
copilot_api_base(DEFAULT_GITHUB_DOMAIN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_and_cache_endpoint(&self, account_id: &str) -> Result<String, CopilotAuthError> {
|
||||
let github_token = {
|
||||
let (github_token, domain) = {
|
||||
let accounts = self.accounts.read().await;
|
||||
accounts
|
||||
let account = accounts
|
||||
.get(account_id)
|
||||
.map(|a| a.github_token.clone())
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?;
|
||||
(account.github_token.clone(), account.github_domain.clone())
|
||||
};
|
||||
|
||||
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_USAGE_URL)
|
||||
.get(copilot_usage_url(&domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
@@ -918,7 +1064,7 @@ impl CopilotAuthManager {
|
||||
|
||||
let endpoint = match usage.endpoints {
|
||||
Some(endpoints) => endpoints.api.clone(),
|
||||
None => DEFAULT_COPILOT_API_ENDPOINT.to_string(),
|
||||
None => copilot_api_base(&domain),
|
||||
};
|
||||
|
||||
// 缓存端点(包括默认值),避免重复请求
|
||||
@@ -1075,6 +1221,15 @@ impl CopilotAuthManager {
|
||||
Self::fallback_default_account_id(&accounts)
|
||||
}
|
||||
|
||||
/// 获取指定账号的 GitHub 域名
|
||||
async fn get_account_domain(&self, account_id: &str) -> String {
|
||||
let accounts = self.accounts.read().await;
|
||||
accounts
|
||||
.get(account_id)
|
||||
.map(|a| a.github_domain.clone())
|
||||
.unwrap_or_else(|| DEFAULT_GITHUB_DOMAIN.to_string())
|
||||
}
|
||||
|
||||
async fn get_refresh_lock(&self, account_id: &str) -> Arc<Mutex<()>> {
|
||||
{
|
||||
let refresh_locks = self.refresh_locks.read().await;
|
||||
@@ -1155,10 +1310,11 @@ impl CopilotAuthManager {
|
||||
async fn fetch_user_info_with_token(
|
||||
&self,
|
||||
github_token: &str,
|
||||
domain: &str,
|
||||
) -> Result<GitHubUser, CopilotAuthError> {
|
||||
let response = self
|
||||
.http_client
|
||||
.get(GITHUB_USER_URL)
|
||||
.get(github_user_url(domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
.header("Editor-Version", COPILOT_EDITOR_VERSION)
|
||||
@@ -1185,12 +1341,13 @@ impl CopilotAuthManager {
|
||||
&self,
|
||||
github_token: &str,
|
||||
account_id: &str,
|
||||
domain: &str,
|
||||
) -> Result<(), CopilotAuthError> {
|
||||
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token");
|
||||
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_TOKEN_URL)
|
||||
.get(copilot_token_url(domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
.header("Editor-Version", COPILOT_EDITOR_VERSION)
|
||||
@@ -1284,20 +1441,32 @@ impl CopilotAuthManager {
|
||||
log::info!("[CopilotAuth] 执行旧格式迁移");
|
||||
|
||||
// 获取用户信息
|
||||
match self.fetch_user_info_with_token(&legacy_token).await {
|
||||
match self
|
||||
.fetch_user_info_with_token(&legacy_token, DEFAULT_GITHUB_DOMAIN)
|
||||
.await
|
||||
{
|
||||
Ok(user) => {
|
||||
let account_id = user.id.to_string();
|
||||
let account_id = composite_account_id(DEFAULT_GITHUB_DOMAIN, user.id);
|
||||
|
||||
// 尝试获取 Copilot token 验证订阅
|
||||
if let Err(e) = self
|
||||
.fetch_copilot_token_with_github_token(&legacy_token, &account_id)
|
||||
.fetch_copilot_token_with_github_token(
|
||||
&legacy_token,
|
||||
&account_id,
|
||||
DEFAULT_GITHUB_DOMAIN,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {e}");
|
||||
}
|
||||
|
||||
// 添加账号
|
||||
self.add_account_internal(legacy_token, user).await?;
|
||||
self.add_account_internal(
|
||||
legacy_token,
|
||||
user,
|
||||
DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
)
|
||||
.await?;
|
||||
self.set_migration_error(None).await;
|
||||
|
||||
log::info!("[CopilotAuth] 旧格式迁移完成");
|
||||
@@ -1387,6 +1556,7 @@ mod tests {
|
||||
login: "testuser".to_string(),
|
||||
avatar_url: Some("https://example.com/avatar.png".to_string()),
|
||||
authenticated_at: 1234567890,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
}],
|
||||
default_account_id: Some("12345".to_string()),
|
||||
migration_error: None,
|
||||
@@ -1420,6 +1590,7 @@ mod tests {
|
||||
avatar_url: Some("https://example.com/alice.png".to_string()),
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
},
|
||||
);
|
||||
accounts.insert(
|
||||
@@ -1432,6 +1603,7 @@ mod tests {
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000001,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1479,6 +1651,7 @@ mod tests {
|
||||
avatar_url: Some("https://example.com/avatar.png".to_string()),
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
};
|
||||
|
||||
let account = GitHubAccount::from(&data);
|
||||
@@ -1504,6 +1677,7 @@ mod tests {
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
},
|
||||
);
|
||||
accounts.insert(
|
||||
@@ -1516,6 +1690,7 @@ mod tests {
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000001,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1546,6 +1721,7 @@ mod tests {
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1630,6 +1806,7 @@ mod tests {
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1664,6 +1841,7 @@ mod tests {
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1746,6 +1924,7 @@ mod tests {
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1801,7 +1980,7 @@ mod tests {
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, DEFAULT_COPILOT_API_ENDPOINT);
|
||||
assert_eq!(endpoint, copilot_api_base(DEFAULT_GITHUB_DOMAIN));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1817,4 +1996,99 @@ mod tests {
|
||||
other => panic!("期望 AccountNotFound 错误,实际: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_github_domain() {
|
||||
// 基本用法
|
||||
assert_eq!(normalize_github_domain("github.com").unwrap(), "github.com");
|
||||
assert_eq!(
|
||||
normalize_github_domain("company.ghe.com").unwrap(),
|
||||
"company.ghe.com"
|
||||
);
|
||||
|
||||
// 剥离协议
|
||||
assert_eq!(
|
||||
normalize_github_domain("https://company.ghe.com").unwrap(),
|
||||
"company.ghe.com"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_github_domain("http://company.ghe.com").unwrap(),
|
||||
"company.ghe.com"
|
||||
);
|
||||
|
||||
// 小写化
|
||||
assert_eq!(normalize_github_domain("GitHub.COM").unwrap(), "github.com");
|
||||
assert_eq!(
|
||||
normalize_github_domain("Company.GHE.Com").unwrap(),
|
||||
"company.ghe.com"
|
||||
);
|
||||
|
||||
// 剥离尾斜杠和 path
|
||||
assert_eq!(
|
||||
normalize_github_domain("company.ghe.com/").unwrap(),
|
||||
"company.ghe.com"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_github_domain("company.ghe.com/api/v3").unwrap(),
|
||||
"company.ghe.com"
|
||||
);
|
||||
|
||||
// 剥离 query 和 fragment
|
||||
assert_eq!(
|
||||
normalize_github_domain("company.ghe.com?foo=bar").unwrap(),
|
||||
"company.ghe.com"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_github_domain("company.ghe.com#section").unwrap(),
|
||||
"company.ghe.com"
|
||||
);
|
||||
|
||||
// 保留端口
|
||||
assert_eq!(
|
||||
normalize_github_domain("company.ghe.com:8443").unwrap(),
|
||||
"company.ghe.com:8443"
|
||||
);
|
||||
|
||||
// 拒绝 userinfo
|
||||
assert!(normalize_github_domain("user@company.ghe.com").is_err());
|
||||
|
||||
// 拒绝空输入
|
||||
assert!(normalize_github_domain("").is_err());
|
||||
assert!(normalize_github_domain(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_composite_account_id() {
|
||||
// github.com 保持原格式(向后兼容)
|
||||
assert_eq!(composite_account_id("github.com", 12345), "12345");
|
||||
|
||||
// GHES 使用复合格式
|
||||
assert_eq!(
|
||||
composite_account_id("company.ghe.com", 12345),
|
||||
"company.ghe.com:12345"
|
||||
);
|
||||
|
||||
// 不同 GHES 实例,相同 user ID,不冲突
|
||||
assert_ne!(
|
||||
composite_account_id("a.ghe.com", 1),
|
||||
composite_account_id("b.ghe.com", 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_account_from_data_ghes_uses_composite_id() {
|
||||
let data = GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "testuser".to_string(),
|
||||
id: 99999,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
github_domain: "company.ghe.com".to_string(),
|
||||
};
|
||||
|
||||
let account = GitHubAccount::from(&data);
|
||||
assert_eq!(account.id, "company.ghe.com:99999");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ impl GeminiAdapter {
|
||||
|
||||
/// 解析 OAuth 凭证
|
||||
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
|
||||
// 防御性 trim:前端在 input 事件中会 trim,但 JSON 编辑器 / deeplink
|
||||
// 导入 / live 回填等路径会绕过。带前导换行的 oauth_creds.json 粘贴
|
||||
// 是常见场景,此处统一兜底。
|
||||
let key = key.trim();
|
||||
|
||||
// 直接是 access_token
|
||||
if key.starts_with("ya29.") {
|
||||
return Some(OAuthCredentials {
|
||||
@@ -120,7 +125,12 @@ impl GeminiAdapter {
|
||||
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
// 使用 GEMINI_API_KEY
|
||||
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
if let Some(key) = env
|
||||
.get("GEMINI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
@@ -131,6 +141,8 @@ impl GeminiAdapter {
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Gemini tool schema helpers.
|
||||
//!
|
||||
//! Gemini `FunctionDeclaration` supports two schema channels:
|
||||
//! - `parameters`: a restricted `Schema` subset
|
||||
//! - `parametersJsonSchema`: richer JSON Schema via arbitrary JSON `Value`
|
||||
//!
|
||||
//! Anthropic tool schemas are closer to JSON Schema, so we choose the richer
|
||||
//! channel when unsupported `Schema` fields are present.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GeminiFunctionParameters {
|
||||
Schema(Value),
|
||||
JsonSchema(Value),
|
||||
}
|
||||
|
||||
pub fn build_gemini_function_parameters(input_schema: Value) -> GeminiFunctionParameters {
|
||||
let schema = ensure_object_schema(normalize_json_schema(input_schema));
|
||||
|
||||
if requires_parameters_json_schema(&schema) {
|
||||
GeminiFunctionParameters::JsonSchema(schema)
|
||||
} else {
|
||||
GeminiFunctionParameters::Schema(to_gemini_schema(schema))
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertex AI rejects FunctionDeclarations whose `parameters` schema lacks an
|
||||
/// explicit `type: "object"`, returning:
|
||||
///
|
||||
/// > functionDeclaration parameters schema should be of type OBJECT.
|
||||
///
|
||||
/// Anthropic tools sometimes arrive with empty or type-less `input_schema`
|
||||
/// (e.g. no-argument tools like Claude Code's `TodoRead`). Normalize those to
|
||||
/// `{type: "object", properties: {}}` so the Gemini upstream accepts them.
|
||||
///
|
||||
/// References: google-gemini/generative-ai-python#423, BerriAI/litellm#5055.
|
||||
fn ensure_object_schema(schema: Value) -> Value {
|
||||
match schema {
|
||||
Value::Object(mut obj) => {
|
||||
obj.entry("type".to_string())
|
||||
.or_insert_with(|| json!("object"));
|
||||
if obj.get("type").and_then(|v| v.as_str()) == Some("object") {
|
||||
obj.entry("properties".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
}
|
||||
Value::Object(obj)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_json_schema(schema: Value) -> Value {
|
||||
match schema {
|
||||
Value::Object(mut obj) => {
|
||||
obj.remove("$schema");
|
||||
obj.remove("$id");
|
||||
|
||||
if let Some(properties) = obj
|
||||
.get_mut("properties")
|
||||
.and_then(|value| value.as_object_mut())
|
||||
{
|
||||
for value in properties.values_mut() {
|
||||
*value = normalize_json_schema(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(items) = obj.get_mut("items") {
|
||||
*items = normalize_json_schema(items.clone());
|
||||
}
|
||||
|
||||
for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
|
||||
if let Some(values) = obj.get_mut(key).and_then(|value| value.as_array_mut()) {
|
||||
for value in values.iter_mut() {
|
||||
*value = normalize_json_schema(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["not", "if", "then", "else", "additionalProperties"] {
|
||||
if let Some(value) = obj.get_mut(key) {
|
||||
*value = normalize_json_schema(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(obj)
|
||||
}
|
||||
Value::Array(values) => {
|
||||
Value::Array(values.into_iter().map(normalize_json_schema).collect())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_parameters_json_schema(schema: &Value) -> bool {
|
||||
match schema {
|
||||
Value::Object(obj) => object_requires_parameters_json_schema(obj),
|
||||
Value::Array(values) => values.iter().any(requires_parameters_json_schema),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn object_requires_parameters_json_schema(obj: &Map<String, Value>) -> bool {
|
||||
for (key, value) in obj {
|
||||
match key.as_str() {
|
||||
"type" => {
|
||||
if value.is_array() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"format" | "title" | "description" | "nullable" | "enum" | "maxItems" | "minItems"
|
||||
| "required" | "minProperties" | "maxProperties" | "minLength" | "maxLength"
|
||||
| "pattern" | "example" | "propertyOrdering" | "default" | "minimum" | "maximum" => {}
|
||||
"properties" => {
|
||||
let Some(properties) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
if properties.values().any(requires_parameters_json_schema) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"items" => {
|
||||
if !value.is_object() || requires_parameters_json_schema(value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"anyOf" => {
|
||||
let Some(values) = value.as_array() else {
|
||||
return true;
|
||||
};
|
||||
if values.iter().any(requires_parameters_json_schema) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// JSON Schema keywords that Gemini `parameters` does not accept.
|
||||
"$ref"
|
||||
| "$defs"
|
||||
| "definitions"
|
||||
| "additionalProperties"
|
||||
| "unevaluatedProperties"
|
||||
| "patternProperties"
|
||||
| "oneOf"
|
||||
| "allOf"
|
||||
| "const"
|
||||
| "not"
|
||||
| "if"
|
||||
| "then"
|
||||
| "else"
|
||||
| "dependentRequired"
|
||||
| "dependentSchemas"
|
||||
| "contains"
|
||||
| "minContains"
|
||||
| "maxContains"
|
||||
| "prefixItems"
|
||||
| "exclusiveMinimum"
|
||||
| "exclusiveMaximum"
|
||||
| "multipleOf"
|
||||
| "examples" => return true,
|
||||
// Be conservative for unknown keywords.
|
||||
_ => return true,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn to_gemini_schema(schema: Value) -> Value {
|
||||
match schema {
|
||||
Value::Object(obj) => {
|
||||
let mut result = Map::new();
|
||||
|
||||
for (key, value) in obj {
|
||||
match key.as_str() {
|
||||
"type" | "format" | "title" | "description" | "nullable" | "enum"
|
||||
| "maxItems" | "minItems" | "required" | "minProperties" | "maxProperties"
|
||||
| "minLength" | "maxLength" | "pattern" | "example" | "propertyOrdering"
|
||||
| "default" | "minimum" | "maximum" => {
|
||||
result.insert(key, value);
|
||||
}
|
||||
"properties" => {
|
||||
if let Some(properties) = value.as_object() {
|
||||
let converted = properties
|
||||
.iter()
|
||||
.map(|(name, property_schema)| {
|
||||
(name.clone(), to_gemini_schema(property_schema.clone()))
|
||||
})
|
||||
.collect();
|
||||
result.insert("properties".to_string(), Value::Object(converted));
|
||||
}
|
||||
}
|
||||
"items" if value.is_object() => {
|
||||
result.insert("items".to_string(), to_gemini_schema(value));
|
||||
}
|
||||
"anyOf" => {
|
||||
if let Some(values) = value.as_array() {
|
||||
result.insert(
|
||||
"anyOf".to_string(),
|
||||
Value::Array(
|
||||
values
|
||||
.iter()
|
||||
.map(|value| to_gemini_schema(value.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(result)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_gemini_function_declaration(
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
input_schema: Value,
|
||||
) -> Value {
|
||||
let mut declaration = Map::new();
|
||||
declaration.insert("name".to_string(), json!(name));
|
||||
declaration.insert("description".to_string(), json!(description.unwrap_or("")));
|
||||
|
||||
match build_gemini_function_parameters(input_schema) {
|
||||
GeminiFunctionParameters::Schema(schema) => {
|
||||
declaration.insert("parameters".to_string(), schema);
|
||||
}
|
||||
GeminiFunctionParameters::JsonSchema(schema) => {
|
||||
declaration.insert("parametersJsonSchema".to_string(), schema);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(declaration)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn uses_schema_for_simple_openapi_subset() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string", "description": "Target city" }
|
||||
},
|
||||
"required": ["city"]
|
||||
});
|
||||
|
||||
let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema);
|
||||
|
||||
assert!(result.get("parameters").is_some());
|
||||
assert!(result.get("parametersJsonSchema").is_none());
|
||||
assert_eq!(result["parameters"]["properties"]["city"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_parameters_json_schema_for_additional_properties() {
|
||||
let schema = json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
},
|
||||
"required": ["city"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
|
||||
let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema);
|
||||
|
||||
assert!(result.get("parameters").is_none());
|
||||
assert!(result.get("parametersJsonSchema").is_some());
|
||||
assert!(result["parametersJsonSchema"].get("$schema").is_none());
|
||||
assert_eq!(
|
||||
result["parametersJsonSchema"]["additionalProperties"],
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_parameters_json_schema_for_one_of() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = build_gemini_function_declaration("search", Some("Search"), schema);
|
||||
|
||||
assert!(result.get("parameters").is_none());
|
||||
assert!(result.get("parametersJsonSchema").is_some());
|
||||
}
|
||||
|
||||
/// Regression for P2 (Vertex AI rejecting empty schemas): zero-argument
|
||||
/// Anthropic tools (no `input_schema`) must produce `parameters` with an
|
||||
/// explicit `type: "object"` and an empty `properties` map so the Gemini
|
||||
/// upstream does not return `schema should be of type OBJECT`.
|
||||
#[test]
|
||||
fn empty_input_schema_produces_explicit_object_type() {
|
||||
let result = build_gemini_function_declaration("ping", Some("no-arg"), json!({}));
|
||||
|
||||
assert_eq!(result["parameters"]["type"], "object");
|
||||
assert!(result["parameters"]["properties"].is_object());
|
||||
}
|
||||
|
||||
/// A schema that carries descriptive fields but no `type` is still a
|
||||
/// zero-arg object for Gemini purposes — promote it explicitly.
|
||||
#[test]
|
||||
fn input_schema_missing_type_is_promoted_to_object() {
|
||||
let result = build_gemini_function_declaration(
|
||||
"noop",
|
||||
None,
|
||||
json!({ "description": "does nothing" }),
|
||||
);
|
||||
|
||||
assert_eq!(result["parameters"]["type"], "object");
|
||||
assert!(result["parameters"]["properties"].is_object());
|
||||
}
|
||||
|
||||
/// Defensive: an atomic (non-object) schema is left untouched, because
|
||||
/// forcing `type: "object"` here would corrupt primitive parameter types
|
||||
/// that happen to flow through this path.
|
||||
#[test]
|
||||
fn non_object_schema_is_not_mutated() {
|
||||
let result = build_gemini_function_declaration("bare", None, json!({ "type": "string" }));
|
||||
|
||||
assert_eq!(result["parameters"]["type"], "string");
|
||||
assert!(result["parameters"].get("properties").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Gemini Native shadow state
|
||||
//!
|
||||
//! Keeps provider/session-scoped assistant content snapshots and tool call metadata
|
||||
//! so Gemini thought signatures and tool turns can be replayed without bloating
|
||||
//! the main proxy files.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// Composite key for a Gemini shadow session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct GeminiShadowKey {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
impl GeminiShadowKey {
|
||||
pub fn new(provider_id: impl Into<String>, session_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
provider_id: provider_id.into(),
|
||||
session_id: session_id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gemini function call metadata captured from an assistant turn.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GeminiToolCallMeta {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
pub args: Value,
|
||||
pub thought_signature: Option<String>,
|
||||
}
|
||||
|
||||
impl GeminiToolCallMeta {
|
||||
pub fn new(
|
||||
id: Option<impl Into<String>>,
|
||||
name: impl Into<String>,
|
||||
args: Value,
|
||||
thought_signature: Option<impl Into<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.map(Into::into),
|
||||
name: name.into(),
|
||||
args,
|
||||
thought_signature: thought_signature.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored assistant turn snapshot.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GeminiAssistantTurn {
|
||||
pub assistant_content: Value,
|
||||
pub tool_calls: Vec<GeminiToolCallMeta>,
|
||||
}
|
||||
|
||||
impl GeminiAssistantTurn {
|
||||
pub fn new(assistant_content: Value, tool_calls: Vec<GeminiToolCallMeta>) -> Self {
|
||||
Self {
|
||||
assistant_content,
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session snapshot returned by read APIs.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GeminiShadowSessionSnapshot {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
pub turns: Vec<GeminiAssistantTurn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GeminiShadowSession {
|
||||
turns: VecDeque<GeminiAssistantTurn>,
|
||||
}
|
||||
|
||||
impl GeminiShadowSession {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
turns: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GeminiShadowInner {
|
||||
sessions: HashMap<GeminiShadowKey, GeminiShadowSession>,
|
||||
session_order: VecDeque<GeminiShadowKey>,
|
||||
}
|
||||
|
||||
impl GeminiShadowInner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
session_order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shadow store for Gemini Native replay state.
|
||||
///
|
||||
/// The store is intentionally small and explicit:
|
||||
/// - sessions are keyed by `(provider_id, session_id)`
|
||||
/// - each session keeps only a bounded number of recent assistant turns
|
||||
/// - the oldest session is evicted first when the store is full
|
||||
#[derive(Debug)]
|
||||
pub struct GeminiShadowStore {
|
||||
max_sessions: usize,
|
||||
max_turns_per_session: usize,
|
||||
inner: RwLock<GeminiShadowInner>,
|
||||
}
|
||||
|
||||
impl Default for GeminiShadowStore {
|
||||
fn default() -> Self {
|
||||
Self::with_limits(200, 64)
|
||||
}
|
||||
}
|
||||
|
||||
impl GeminiShadowStore {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_limits(max_sessions: usize, max_turns_per_session: usize) -> Self {
|
||||
Self {
|
||||
max_sessions: max_sessions.max(1),
|
||||
max_turns_per_session: max_turns_per_session.max(1),
|
||||
inner: RwLock::new(GeminiShadowInner::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a Gemini assistant turn for later replay.
|
||||
pub fn record_assistant_turn(
|
||||
&self,
|
||||
provider_id: impl Into<String>,
|
||||
session_id: impl Into<String>,
|
||||
assistant_content: Value,
|
||||
tool_calls: Vec<GeminiToolCallMeta>,
|
||||
) -> GeminiShadowSessionSnapshot {
|
||||
let key = GeminiShadowKey::new(provider_id, session_id);
|
||||
let turn = GeminiAssistantTurn::new(assistant_content, tool_calls);
|
||||
|
||||
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
|
||||
Self::touch_session_order(&mut inner.session_order, &key);
|
||||
|
||||
let snapshot = {
|
||||
let session = inner
|
||||
.sessions
|
||||
.entry(key.clone())
|
||||
.or_insert_with(GeminiShadowSession::new);
|
||||
session.turns.push_back(turn);
|
||||
while session.turns.len() > self.max_turns_per_session {
|
||||
session.turns.pop_front();
|
||||
}
|
||||
Self::snapshot_session(&key, session)
|
||||
};
|
||||
Self::prune_sessions(&mut inner, self.max_sessions);
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Get the latest assistant content for a provider/session pair.
|
||||
#[allow(dead_code)]
|
||||
pub fn latest_assistant_content(&self, provider_id: &str, session_id: &str) -> Option<Value> {
|
||||
self.get_session(provider_id, session_id)
|
||||
.and_then(|snapshot| {
|
||||
snapshot
|
||||
.turns
|
||||
.last()
|
||||
.map(|turn| turn.assistant_content.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the latest tool calls for a provider/session pair.
|
||||
#[allow(dead_code)]
|
||||
pub fn latest_tool_calls(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
) -> Option<Vec<GeminiToolCallMeta>> {
|
||||
self.get_session(provider_id, session_id)
|
||||
.and_then(|snapshot| snapshot.turns.last().map(|turn| turn.tool_calls.clone()))
|
||||
}
|
||||
|
||||
/// Read a full session snapshot.
|
||||
pub fn get_session(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
) -> Option<GeminiShadowSessionSnapshot> {
|
||||
let key = GeminiShadowKey::new(provider_id, session_id);
|
||||
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
|
||||
let snapshot = inner
|
||||
.sessions
|
||||
.get(&key)
|
||||
.map(|session| Self::snapshot_session(&key, session));
|
||||
if snapshot.is_some() {
|
||||
Self::touch_session_order(&mut inner.session_order, &key);
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Remove a single session from the store.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_session(&self, provider_id: &str, session_id: &str) -> bool {
|
||||
let key = GeminiShadowKey::new(provider_id, session_id);
|
||||
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
|
||||
let removed = inner.sessions.remove(&key).is_some();
|
||||
if removed {
|
||||
Self::remove_key_from_order(&mut inner.session_order, &key);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Remove all sessions for a provider.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_provider(&self, provider_id: &str) -> usize {
|
||||
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
|
||||
let keys: Vec<_> = inner
|
||||
.sessions
|
||||
.keys()
|
||||
.filter(|key| key.provider_id == provider_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in &keys {
|
||||
inner.sessions.remove(key);
|
||||
Self::remove_key_from_order(&mut inner.session_order, key);
|
||||
}
|
||||
keys.len()
|
||||
}
|
||||
|
||||
/// Number of tracked sessions.
|
||||
#[allow(dead_code)]
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("gemini shadow lock poisoned")
|
||||
.sessions
|
||||
.len()
|
||||
}
|
||||
|
||||
fn snapshot_session(
|
||||
key: &GeminiShadowKey,
|
||||
session: &GeminiShadowSession,
|
||||
) -> GeminiShadowSessionSnapshot {
|
||||
GeminiShadowSessionSnapshot {
|
||||
provider_id: key.provider_id.clone(),
|
||||
session_id: key.session_id.clone(),
|
||||
turns: session.turns.iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn touch_session_order(order: &mut VecDeque<GeminiShadowKey>, key: &GeminiShadowKey) {
|
||||
if let Some(pos) = order.iter().position(|existing| existing == key) {
|
||||
order.remove(pos);
|
||||
}
|
||||
order.push_back(key.clone());
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn remove_key_from_order(order: &mut VecDeque<GeminiShadowKey>, key: &GeminiShadowKey) {
|
||||
if let Some(pos) = order.iter().position(|existing| existing == key) {
|
||||
order.remove(pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_sessions(inner: &mut GeminiShadowInner, max_sessions: usize) {
|
||||
while inner.sessions.len() > max_sessions {
|
||||
let Some(evicted_key) = inner.session_order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
inner.sessions.remove(&evicted_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn record_and_read_latest_turn() {
|
||||
let store = GeminiShadowStore::with_limits(8, 4);
|
||||
let snapshot = store.record_assistant_turn(
|
||||
"provider-a",
|
||||
"session-1",
|
||||
json!({"parts": [{"text": "hello", "thoughtSignature": "sig-1"}]}),
|
||||
vec![GeminiToolCallMeta::new(
|
||||
Some("call-1"),
|
||||
"get_weather",
|
||||
json!({"location": "Tokyo"}),
|
||||
Some("sig-1"),
|
||||
)],
|
||||
);
|
||||
|
||||
assert_eq!(snapshot.provider_id, "provider-a");
|
||||
assert_eq!(snapshot.session_id, "session-1");
|
||||
assert_eq!(snapshot.turns.len(), 1);
|
||||
|
||||
let content = store
|
||||
.latest_assistant_content("provider-a", "session-1")
|
||||
.expect("content");
|
||||
assert_eq!(content["parts"][0]["text"], "hello");
|
||||
assert_eq!(content["parts"][0]["thoughtSignature"], "sig-1");
|
||||
|
||||
let tool_calls = store
|
||||
.latest_tool_calls("provider-a", "session-1")
|
||||
.expect("tool calls");
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].id.as_deref(), Some("call-1"));
|
||||
assert_eq!(tool_calls[0].name, "get_weather");
|
||||
assert_eq!(tool_calls[0].args["location"], "Tokyo");
|
||||
assert_eq!(tool_calls[0].thought_signature.as_deref(), Some("sig-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_are_isolated_by_provider_and_session_id() {
|
||||
let store = GeminiShadowStore::with_limits(8, 4);
|
||||
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"text": "a"}), vec![]);
|
||||
store.record_assistant_turn("provider-b", "session-1", json!({"text": "b"}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-2", json!({"text": "c"}), vec![]);
|
||||
|
||||
assert_eq!(store.session_count(), 3);
|
||||
assert_eq!(
|
||||
store.latest_assistant_content("provider-a", "session-1"),
|
||||
Some(json!({"text": "a"}))
|
||||
);
|
||||
assert_eq!(
|
||||
store.latest_assistant_content("provider-b", "session-1"),
|
||||
Some(json!({"text": "b"}))
|
||||
);
|
||||
assert_eq!(
|
||||
store.latest_assistant_content("provider-a", "session-2"),
|
||||
Some(json!({"text": "c"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_only_latest_turns_per_session() {
|
||||
let store = GeminiShadowStore::with_limits(8, 2);
|
||||
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 2}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 3}), vec![]);
|
||||
|
||||
let snapshot = store
|
||||
.get_session("provider-a", "session-1")
|
||||
.expect("snapshot");
|
||||
assert_eq!(snapshot.turns.len(), 2);
|
||||
assert_eq!(snapshot.turns[0].assistant_content, json!({"idx": 2}));
|
||||
assert_eq!(snapshot.turns[1].assistant_content, json!({"idx": 3}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicts_oldest_session_when_capacity_is_exceeded() {
|
||||
let store = GeminiShadowStore::with_limits(2, 2);
|
||||
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-3", json!({"idx": 3}), vec![]);
|
||||
|
||||
assert!(store.get_session("provider-a", "session-1").is_none());
|
||||
assert!(store.get_session("provider-a", "session-2").is_some());
|
||||
assert!(store.get_session("provider-a", "session-3").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_session_and_provider_work() {
|
||||
let store = GeminiShadowStore::with_limits(8, 4);
|
||||
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]);
|
||||
store.record_assistant_turn("provider-b", "session-3", json!({"idx": 3}), vec![]);
|
||||
|
||||
assert!(store.clear_session("provider-a", "session-1"));
|
||||
assert!(store.get_session("provider-a", "session-1").is_none());
|
||||
|
||||
let removed = store.clear_provider("provider-a");
|
||||
assert_eq!(removed, 1);
|
||||
assert!(store.get_session("provider-a", "session-2").is_none());
|
||||
assert!(store.get_session("provider-b", "session-3").is_some());
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,14 @@ mod codex;
|
||||
pub mod codex_oauth_auth;
|
||||
pub mod copilot_auth;
|
||||
mod gemini;
|
||||
pub(crate) mod gemini_schema;
|
||||
pub mod gemini_shadow;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod streaming_gemini;
|
||||
pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_gemini;
|
||||
pub mod transform_responses;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
@@ -101,6 +105,14 @@ impl ProviderType {
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if get_claude_api_format(provider) == "gemini_native" {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
return match adapter.extract_auth(provider).map(|auth| auth.strategy) {
|
||||
Some(AuthStrategy::GoogleOAuth) => ProviderType::GeminiCli,
|
||||
_ => ProviderType::Gemini,
|
||||
};
|
||||
}
|
||||
|
||||
// 检测是否为 GitHub Copilot
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
@@ -162,13 +174,9 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex-like type
|
||||
ProviderType::Codex
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,12 +228,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy, fallback to Codex adapter
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
|
||||
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -85,8 +85,16 @@ struct ToolBlockState {
|
||||
name: String,
|
||||
started: bool,
|
||||
pending_args: String,
|
||||
/// 连续空白字符计数 — 用于检测 Copilot 无限换行 bug
|
||||
/// 当 function call 参数中出现连续 20+ 空白字符时,强制终止流
|
||||
consecutive_whitespace: usize,
|
||||
/// 是否已因无限空白 bug 被中止
|
||||
aborted: bool,
|
||||
}
|
||||
|
||||
/// 无限空白 bug 的连续空白字符阈值
|
||||
const INFINITE_WHITESPACE_THRESHOLD: usize = 20;
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
@@ -110,10 +118,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
Ok(bytes) => {
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let line = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
while let Some(line) = take_sse_block(&mut buffer) {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -297,9 +302,16 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
name: String::new(),
|
||||
started: false,
|
||||
pending_args: String::new(),
|
||||
consecutive_whitespace: 0,
|
||||
aborted: false,
|
||||
}
|
||||
});
|
||||
|
||||
// 如果此 tool call 已被中止(无限空白 bug),跳过后续处理
|
||||
if state.aborted {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(id) = &tool_call.id {
|
||||
state.id = id.clone();
|
||||
}
|
||||
@@ -328,7 +340,22 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
.as_ref()
|
||||
.and_then(|f| f.arguments.clone());
|
||||
let immediate_delta = if let Some(args) = args_delta {
|
||||
if state.started {
|
||||
// 无限空白 bug 检测:跟踪连续空白字符
|
||||
for ch in args.chars() {
|
||||
if ch.is_whitespace() {
|
||||
state.consecutive_whitespace += 1;
|
||||
} else {
|
||||
state.consecutive_whitespace = 0;
|
||||
}
|
||||
}
|
||||
if state.consecutive_whitespace >= INFINITE_WHITESPACE_THRESHOLD {
|
||||
log::warn!(
|
||||
"[Copilot] 检测到无限空白 bug (tool: {}), 中止此 tool call 流",
|
||||
state.name
|
||||
);
|
||||
state.aborted = true;
|
||||
None
|
||||
} else if state.started {
|
||||
Some(args)
|
||||
} else {
|
||||
state.pending_args.push_str(&args);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
||||
//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。
|
||||
|
||||
use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
@@ -122,10 +122,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// SSE 事件由 \n\n 分隔
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let block = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
while let Some(block) = take_sse_block(&mut buffer) {
|
||||
if block.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn supports_reasoning_effort(model: &str) -> bool {
|
||||
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
|
||||
/// (supported by mainstream GPT models). Unknown values are ignored.
|
||||
/// 2. Fallback: `thinking.type` + `budget_tokens`:
|
||||
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
|
||||
/// - `adaptive` → `xhigh` (adaptive = maximum reasoning effort)
|
||||
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 000–15 999) / `high` (≥16 000)
|
||||
/// - `enabled` without budget → `high` (conservative default)
|
||||
/// - `disabled` / absent → `None`
|
||||
@@ -57,7 +57,7 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
// --- Priority 2: thinking.type + budget_tokens fallback ---
|
||||
let thinking = body.get("thinking")?;
|
||||
match thinking.get("type").and_then(|t| t.as_str()) {
|
||||
Some("adaptive") => Some("high"),
|
||||
Some("adaptive") => Some("xhigh"),
|
||||
Some("enabled") => {
|
||||
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
|
||||
match budget {
|
||||
@@ -71,10 +71,8 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
/// Anthropic 请求 → OpenAI Chat Completions 请求
|
||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -175,11 +173,6 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
result["tool_choice"] = v.clone();
|
||||
}
|
||||
|
||||
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
|
||||
if let Some(key) = cache_key {
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -206,6 +199,10 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut inherited_cache_control: Option<Value> = None;
|
||||
let mut cache_control_conflict = false;
|
||||
let mut saw_cache_control = false;
|
||||
let mut saw_missing_cache_control = false;
|
||||
messages.retain(|message| {
|
||||
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
|
||||
return true;
|
||||
@@ -226,11 +223,28 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(cache_control) = message.get("cache_control") {
|
||||
saw_cache_control = true;
|
||||
match &inherited_cache_control {
|
||||
None => inherited_cache_control = Some(cache_control.clone()),
|
||||
Some(existing) if existing == cache_control => {}
|
||||
Some(_) => cache_control_conflict = true,
|
||||
}
|
||||
} else {
|
||||
saw_missing_cache_control = true;
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if !parts.is_empty() {
|
||||
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
|
||||
let mut merged = json!({"role": "system", "content": parts.join("\n")});
|
||||
if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) {
|
||||
if let Some(cache_control) = inherited_cache_control {
|
||||
merged["cache_control"] = cache_control;
|
||||
}
|
||||
}
|
||||
messages.insert(0, merged);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,7 +583,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "claude-3-opus");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
@@ -585,7 +599,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
@@ -607,36 +621,76 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
|
||||
fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code."},
|
||||
{"type": "text", "text": "Be concise."}
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "system", "content": "Follow repo conventions."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise.\nFollow repo conventions."
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise."}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let input = json!({
|
||||
@@ -651,7 +705,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
@@ -671,7 +725,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
@@ -743,31 +797,19 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_cache_key() {
|
||||
fn test_anthropic_to_openai_does_not_inject_prompt_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "provider-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_no_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
@@ -793,7 +835,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
// System message cache_control preserved
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
// Text block cache_control preserved
|
||||
@@ -1019,9 +1061,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive_maps_high() {
|
||||
fn test_thinking_adaptive_maps_xhigh() {
|
||||
let body = json!({"thinking": {"type": "adaptive"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1047,7 +1089,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
@@ -1060,7 +1102,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "medium");
|
||||
}
|
||||
|
||||
@@ -1073,7 +1115,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
@@ -1086,7 +1128,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "low");
|
||||
}
|
||||
|
||||
@@ -1099,8 +1141,8 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "high");
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1111,7 +1153,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
@@ -1124,7 +1166,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(
|
||||
result.get("max_tokens").is_none(),
|
||||
"{model} should not have max_tokens"
|
||||
@@ -1144,7 +1186,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -194,24 +194,16 @@ pub(crate) fn map_responses_stop_reason(
|
||||
incomplete_reason: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
status.map(|s| match s {
|
||||
"completed" => {
|
||||
if has_tool_use {
|
||||
"tool_use"
|
||||
} else {
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
"incomplete" => {
|
||||
"completed" if has_tool_use => "tool_use",
|
||||
"incomplete"
|
||||
if matches!(
|
||||
incomplete_reason,
|
||||
Some("max_output_tokens") | Some("max_tokens")
|
||||
) || incomplete_reason.is_none()
|
||||
{
|
||||
"max_tokens"
|
||||
} else {
|
||||
"end_turn"
|
||||
}
|
||||
) || incomplete_reason.is_none() =>
|
||||
{
|
||||
"max_tokens"
|
||||
}
|
||||
"incomplete" => "end_turn",
|
||||
_ => "end_turn",
|
||||
})
|
||||
}
|
||||
@@ -1047,7 +1039,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_thinking_adaptive_sets_reasoning_high() {
|
||||
fn test_responses_thinking_adaptive_sets_reasoning_xhigh() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
@@ -1056,7 +1048,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use super::session::ProxySession;
|
||||
use super::usage::parser::TokenUsage;
|
||||
use super::ProxyError;
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
@@ -86,10 +86,7 @@ impl StreamHandler {
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 提取完整事件
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_text = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
|
||||
@@ -7,11 +7,11 @@ use super::{
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
hyper_client::ProxyResponse,
|
||||
server::ProxyState,
|
||||
sse::strip_sse_field,
|
||||
sse::{strip_sse_field, take_sse_block},
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::header::HeaderMap;
|
||||
use axum::http::{header::HeaderMap, HeaderName};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
@@ -68,6 +68,41 @@ fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||
.filter(|s| !s.is_empty() && s != "identity")
|
||||
}
|
||||
|
||||
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
|
||||
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
/// 移除响应侧 hop-by-hop 头,以及 `Connection` 中点名的扩展头。
|
||||
pub(crate) fn strip_hop_by_hop_response_headers(headers: &mut HeaderMap) {
|
||||
let connection_listed_headers: Vec<HeaderName> = headers
|
||||
.get_all(axum::http::header::CONNECTION)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.flat_map(|value| value.split(','))
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.filter_map(|name| HeaderName::from_bytes(name.as_bytes()).ok())
|
||||
.collect();
|
||||
|
||||
for name in HOP_BY_HOP_RESPONSE_HEADERS {
|
||||
headers.remove(*name);
|
||||
}
|
||||
|
||||
for name in connection_listed_headers {
|
||||
headers.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除在重建响应体后会失真的实体头。
|
||||
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
@@ -163,10 +198,13 @@ pub async fn handle_streaming(
|
||||
);
|
||||
}
|
||||
|
||||
let mut response_headers = response.headers().clone();
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
// 复制响应头
|
||||
for (key, value) in response.headers() {
|
||||
for (key, value) in &response_headers {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
|
||||
@@ -207,8 +245,9 @@ pub async fn handle_non_streaming(
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let (response_headers, status, body_bytes) =
|
||||
let (mut response_headers, status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
log::debug!(
|
||||
"[{}] 上游响应体内容: {}",
|
||||
@@ -528,7 +567,7 @@ async fn log_usage_internal(
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let request_id = usage.dedup_request_id();
|
||||
|
||||
log::debug!(
|
||||
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
|
||||
@@ -623,10 +662,7 @@ pub fn create_logged_passthrough_stream(
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 尝试解析并记录完整的 SSE 事件
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_text = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
if !event_text.trim().is_empty() {
|
||||
// 提取 data 部分并尝试解析为 JSON
|
||||
for line in event_text.lines() {
|
||||
@@ -687,6 +723,7 @@ mod tests {
|
||||
use crate::provider::ProviderMeta;
|
||||
use crate::proxy::failover_switch::FailoverSwitchManager;
|
||||
use crate::proxy::provider_router::ProviderRouter;
|
||||
use crate::proxy::providers::gemini_shadow::GeminiShadowStore;
|
||||
use crate::proxy::types::{ProxyConfig, ProxyStatus};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
@@ -715,6 +752,90 @@ mod tests {
|
||||
assert_eq!(super::strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_hop_by_hop_response_headers_removes_standard_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CONNECTION,
|
||||
axum::http::HeaderValue::from_static("keep-alive"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::HeaderName::from_static("keep-alive"),
|
||||
axum::http::HeaderValue::from_static("timeout=5"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::TRANSFER_ENCODING,
|
||||
axum::http::HeaderValue::from_static("chunked"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::HeaderName::from_static("proxy-connection"),
|
||||
axum::http::HeaderValue::from_static("keep-alive"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_LENGTH,
|
||||
axum::http::HeaderValue::from_static("12"),
|
||||
);
|
||||
|
||||
strip_hop_by_hop_response_headers(&mut headers);
|
||||
|
||||
assert!(!headers.contains_key(axum::http::header::CONNECTION));
|
||||
assert!(!headers.contains_key("keep-alive"));
|
||||
assert!(!headers.contains_key(axum::http::header::TRANSFER_ENCODING));
|
||||
assert!(!headers.contains_key("proxy-connection"));
|
||||
assert_eq!(
|
||||
headers.get(axum::http::header::CONTENT_TYPE),
|
||||
Some(&axum::http::HeaderValue::from_static("application/json"))
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(axum::http::header::CONTENT_LENGTH),
|
||||
Some(&axum::http::HeaderValue::from_static("12"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_hop_by_hop_response_headers_removes_connection_listed_extensions() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
axum::http::header::CONNECTION,
|
||||
axum::http::HeaderValue::from_static("x-trace-hop, x-debug-hop"),
|
||||
);
|
||||
headers.append(
|
||||
axum::http::header::CONNECTION,
|
||||
axum::http::HeaderValue::from_static("upgrade"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::HeaderName::from_static("x-trace-hop"),
|
||||
axum::http::HeaderValue::from_static("trace"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::HeaderName::from_static("x-debug-hop"),
|
||||
axum::http::HeaderValue::from_static("debug"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::UPGRADE,
|
||||
axum::http::HeaderValue::from_static("websocket"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
|
||||
strip_hop_by_hop_response_headers(&mut headers);
|
||||
|
||||
assert!(!headers.contains_key(axum::http::header::CONNECTION));
|
||||
assert!(!headers.contains_key("x-trace-hop"));
|
||||
assert!(!headers.contains_key("x-debug-hop"));
|
||||
assert!(!headers.contains_key(axum::http::header::UPGRADE));
|
||||
assert_eq!(
|
||||
headers.get(axum::http::header::CONTENT_TYPE),
|
||||
Some(&axum::http::HeaderValue::from_static("text/event-stream"))
|
||||
);
|
||||
}
|
||||
|
||||
fn build_state(db: Arc<Database>) -> ProxyState {
|
||||
ProxyState {
|
||||
db: db.clone(),
|
||||
@@ -723,6 +844,7 @@ mod tests {
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
current_providers: Arc::new(RwLock::new(HashMap::new())),
|
||||
provider_router: Arc::new(ProviderRouter::new(db.clone())),
|
||||
gemini_shadow: Arc::new(GeminiShadowStore::default()),
|
||||
app_handle: None,
|
||||
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
|
||||
}
|
||||
@@ -784,6 +906,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
@@ -843,6 +966,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
use super::{
|
||||
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
|
||||
provider_router::ProviderRouter, types::*, ProxyError,
|
||||
provider_router::ProviderRouter, providers::gemini_shadow::GeminiShadowStore, types::*,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::database::Database;
|
||||
use axum::{
|
||||
@@ -23,7 +24,6 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
/// 代理服务器状态(共享)
|
||||
#[derive(Clone)]
|
||||
@@ -36,6 +36,8 @@ pub struct ProxyState {
|
||||
pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态,跨请求保持)
|
||||
pub provider_router: Arc<ProviderRouter>,
|
||||
/// Gemini Native shadow state,用于 thoughtSignature / tool call 回放
|
||||
pub gemini_shadow: Arc<GeminiShadowStore>,
|
||||
/// AppHandle,用于发射事件和更新托盘菜单
|
||||
pub app_handle: Option<tauri::AppHandle>,
|
||||
/// 故障转移切换管理器
|
||||
@@ -69,6 +71,7 @@ impl ProxyServer {
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
provider_router,
|
||||
gemini_shadow: Arc::new(GeminiShadowStore::default()),
|
||||
app_handle,
|
||||
failover_manager,
|
||||
};
|
||||
@@ -275,11 +278,6 @@ impl ProxyServer {
|
||||
}
|
||||
|
||||
fn build_router(&self) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
// 健康检查
|
||||
.route("/health", get(handlers::health_check))
|
||||
@@ -328,7 +326,6 @@ impl ProxyServer {
|
||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||
// 提高默认请求体大小限制(避免 413 Payload Too Large)
|
||||
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
|
||||
.layer(cors)
|
||||
.with_state(self.state.clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,12 @@ pub fn extract_session_id(
|
||||
body: &serde_json::Value,
|
||||
client_format: &str,
|
||||
) -> SessionIdResult {
|
||||
if client_format == "claude" {
|
||||
if let Some(result) = extract_claude_session(headers, body) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 请求特殊处理
|
||||
if client_format == "codex" || client_format == "openai" {
|
||||
if let Some(result) = extract_codex_session(headers, body) {
|
||||
@@ -258,6 +264,28 @@ pub fn extract_session_id(
|
||||
generate_new_session_id()
|
||||
}
|
||||
|
||||
/// 提取 Claude Session ID
|
||||
fn extract_claude_session(
|
||||
headers: &HeaderMap,
|
||||
body: &serde_json::Value,
|
||||
) -> Option<SessionIdResult> {
|
||||
for header_name in &["x-claude-code-session-id", "claude-code-session-id"] {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
if let Ok(session_id) = value.to_str() {
|
||||
if !session_id.is_empty() {
|
||||
return Some(SessionIdResult {
|
||||
session_id: session_id.to_string(),
|
||||
source: SessionIdSource::Header,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract_from_metadata(body)
|
||||
}
|
||||
|
||||
/// 提取 Codex Session ID
|
||||
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
// 1. 从 headers 提取
|
||||
@@ -337,7 +365,7 @@ fn extract_from_metadata(body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
/// 从 user_id 解析 session_id
|
||||
///
|
||||
/// 格式: `user_identifier_session_actual_session_id`
|
||||
fn parse_session_from_user_id(user_id: &str) -> Option<String> {
|
||||
pub(super) fn parse_session_from_user_id(user_id: &str) -> Option<String> {
|
||||
// 查找 "_session_" 分隔符
|
||||
if let Some(pos) = user_id.find("_session_") {
|
||||
let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9
|
||||
@@ -515,6 +543,47 @@ mod tests {
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_claude_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-claude-code-session-id",
|
||||
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
|
||||
);
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = extract_session_id(&headers, &body, "claude");
|
||||
|
||||
assert_eq!(result.session_id, "d937243f-2702-4f20-97b6-c9682235ab81");
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_claude_header_precedes_metadata() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-claude-code-session-id",
|
||||
"header-session-123".parse().unwrap(),
|
||||
);
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"metadata": {
|
||||
"session_id": "my-session-123"
|
||||
}
|
||||
});
|
||||
|
||||
let result = extract_session_id(&headers, &body, "claude");
|
||||
|
||||
assert_eq!(result.session_id, "header-session-123");
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_codex_previous_response_id() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
@@ -4,6 +4,24 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str>
|
||||
.or_else(|| line.strip_prefix(&format!("{field}:")))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn take_sse_block(buffer: &mut String) -> Option<String> {
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
|
||||
for (delimiter, len) in [("\r\n\r\n", 4usize), ("\n\n", 2usize)] {
|
||||
if let Some(pos) = buffer.find(delimiter) {
|
||||
if best.is_none_or(|(best_pos, _)| pos < best_pos) {
|
||||
best = Some((pos, len));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (pos, len) = best?;
|
||||
let block = buffer[..pos].to_string();
|
||||
buffer.drain(..pos + len);
|
||||
Some(block)
|
||||
}
|
||||
|
||||
/// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte
|
||||
/// characters that are split across chunk boundaries.
|
||||
///
|
||||
@@ -68,7 +86,7 @@ pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec<u8>, new
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{append_utf8_safe, strip_sse_field};
|
||||
use super::{append_utf8_safe, strip_sse_field, take_sse_block};
|
||||
|
||||
#[test]
|
||||
fn strip_sse_field_accepts_optional_space() {
|
||||
@@ -91,6 +109,28 @@ mod tests {
|
||||
assert_eq!(strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_sse_block_supports_lf_delimiters() {
|
||||
let mut buffer = "data: {\"ok\":true}\n\nrest".to_string();
|
||||
|
||||
assert_eq!(
|
||||
take_sse_block(&mut buffer),
|
||||
Some("data: {\"ok\":true}".to_string())
|
||||
);
|
||||
assert_eq!(buffer, "rest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_sse_block_supports_crlf_delimiters() {
|
||||
let mut buffer = "data: {\"ok\":true}\r\n\r\nrest".to_string();
|
||||
|
||||
assert_eq!(
|
||||
take_sse_block(&mut buffer),
|
||||
Some("data: {\"ok\":true}".to_string())
|
||||
);
|
||||
assert_eq!(buffer, "rest");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// append_utf8_safe tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{json, Value};
|
||||
///
|
||||
/// 三路径分发:
|
||||
/// - skip: haiku 模型直接跳过
|
||||
/// - adaptive: opus-4-6 / sonnet-4-6 使用 adaptive thinking
|
||||
/// - adaptive: opus-4-7 / opus-4-6 / sonnet-4-6 使用 adaptive thinking
|
||||
/// - legacy: 其他模型注入 enabled thinking + budget_tokens
|
||||
pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
|
||||
if !config.thinking_optimizer {
|
||||
@@ -24,7 +24,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if model.contains("opus-4-6") || model.contains("sonnet-4-6") {
|
||||
if model.contains("opus-4-7") || model.contains("opus-4-6") || model.contains("sonnet-4-6") {
|
||||
log::info!("[OPT] thinking: adaptive({model})");
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
body["output_config"] = json!({"effort": "max"});
|
||||
|
||||
@@ -52,6 +52,14 @@ pub fn should_rectify_thinking_signature(
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景1b: Gemini/第三方渠道返回 "Thought signature is not valid"
|
||||
// 错误示例: "Unable to submit request because Thought signature is not valid"
|
||||
if lower.contains("thought signature")
|
||||
&& (lower.contains("not valid") || lower.contains("invalid"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景2: assistant 消息必须以 thinking block 开头
|
||||
// 错误示例: "must start with a thinking block"
|
||||
if lower.contains("must start with a thinking block") {
|
||||
@@ -280,6 +288,16 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_thought_signature_message() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(
|
||||
"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help"
|
||||
),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature_nested_json() {
|
||||
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
|
||||
@@ -290,6 +308,15 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_thought_signature_nested_json() {
|
||||
let nested_error = r#"{"error":{"message":"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help","type":"upstream_error","param":"","code":400}}"#;
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(nested_error),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_thinking_expected() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
|
||||
@@ -291,16 +291,26 @@ pub struct CopilotOptimizerConfig {
|
||||
/// 确定性 Request ID(默认开启,P3 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub deterministic_request_id: bool,
|
||||
/// Warmup 小模型降级(默认关闭,P4 优先级,opt-in)
|
||||
#[serde(default)]
|
||||
/// Subagent 检测(默认开启)— 识别 Claude Code 子代理请求,
|
||||
/// 设置 x-initiator=agent + x-interaction-type=conversation-subagent,避免子代理计费
|
||||
#[serde(default = "default_true")]
|
||||
pub subagent_detection: bool,
|
||||
/// Warmup 小模型降级(默认开启 — 与参考实现对齐,避免探针请求消耗 premium quota)
|
||||
#[serde(default = "default_true")]
|
||||
pub warmup_downgrade: bool,
|
||||
/// Warmup 降级使用的模型(默认 "gpt-4o-mini")
|
||||
/// Warmup 降级使用的模型(默认 "gpt-5-mini")
|
||||
#[serde(default = "default_warmup_model")]
|
||||
pub warmup_model: String,
|
||||
/// 请求前主动剥离 assistant 消息里的 thinking / redacted_thinking block
|
||||
///
|
||||
/// Copilot 走 OpenAI 兼容端点,thinking block 会被上游拒绝并触发 rectifier 反应式
|
||||
/// 重试,那时第一次请求已经消耗了一次 premium quota。主动剥离避免这次浪费。
|
||||
#[serde(default = "default_true")]
|
||||
pub strip_thinking: bool,
|
||||
}
|
||||
|
||||
fn default_warmup_model() -> String {
|
||||
"gpt-4o-mini".to_string()
|
||||
"gpt-5-mini".to_string()
|
||||
}
|
||||
|
||||
impl Default for CopilotOptimizerConfig {
|
||||
@@ -311,8 +321,10 @@ impl Default for CopilotOptimizerConfig {
|
||||
tool_result_merging: true,
|
||||
compact_detection: true,
|
||||
deterministic_request_id: true,
|
||||
warmup_downgrade: false,
|
||||
warmup_model: "gpt-4o-mini".to_string(),
|
||||
subagent_detection: true,
|
||||
warmup_downgrade: true,
|
||||
warmup_model: "gpt-5-mini".to_string(),
|
||||
strip_thinking: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ mod tests {
|
||||
cache_read_tokens: 200,
|
||||
cache_creation_tokens: 100,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
|
||||
@@ -144,6 +145,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
|
||||
@@ -165,6 +167,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||
@@ -181,6 +184,7 @@ mod tests {
|
||||
cache_read_tokens: 1,
|
||||
cache_creation_tokens: 1,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
|
||||
|
||||
@@ -73,7 +73,7 @@ impl<'a> UsageLogger<'a> {
|
||||
});
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
@@ -358,6 +358,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
logger.log_with_calculation(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user